feat: unified prompt UI - Phase 3 complete (Issue #28)
Some checks failed
Deploy Development / deploy (push) Failing after 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Frontend Consolidation:
- UnifiedPromptModal: Single editor for base + pipeline prompts
  - Type selector (base/pipeline)
  - Base: Template editor with placeholders
  - Pipeline: Dynamic stage editor
  - Add/remove stages with drag/drop
  - Inline or reference prompts per stage
  - Output key + format per prompt

- AdminPromptsPage redesign:
  - Removed tab switcher (prompts/pipelines)
  - Added type filter (All/Base/Pipeline)
  - Type badge in table
  - Stage count column
  - Icon-based actions (Edit/Copy/Delete)
  - Category filter retained

Changes:
- Completely rewrote AdminPromptsPage (495 → 446 lines)
- Single modal for all prompt types
- Mobile-ready layout
- Simplified state management

Next: Phase 4 - Cleanup deprecated endpoints + docs
This commit is contained in:
Lars 2026-03-25 14:55:25 +01:00
parent 7be7266477
commit 31e2c24a8a
2 changed files with 865 additions and 373 deletions

View File

@ -0,0 +1,549 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import { X, Plus, Trash2, MoveUp, MoveDown } from 'lucide-react'
/**
* Unified Prompt Editor Modal (Issue #28 Phase 3)
*
* Supports both prompt types:
* - Base: Single reusable template
* - Pipeline: Multi-stage workflow with dynamic stages
*/
export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [displayName, setDisplayName] = useState('')
const [description, setDescription] = useState('')
const [type, setType] = useState('pipeline') // 'base' or 'pipeline'
const [category, setCategory] = useState('ganzheitlich')
const [active, setActive] = useState(true)
const [sortOrder, setSortOrder] = useState(0)
// Base prompt fields
const [template, setTemplate] = useState('')
const [outputFormat, setOutputFormat] = useState('text')
// Pipeline prompt fields
const [stages, setStages] = useState([
{
stage: 1,
prompts: []
}
])
// Available prompts for reference selection
const [availablePrompts, setAvailablePrompts] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
loadAvailablePrompts()
if (prompt) {
// Edit mode
setName(prompt.name || '')
setSlug(prompt.slug || '')
setDisplayName(prompt.display_name || '')
setDescription(prompt.description || '')
setType(prompt.type || 'pipeline')
setCategory(prompt.category || 'ganzheitlich')
setActive(prompt.active ?? true)
setSortOrder(prompt.sort_order || 0)
setTemplate(prompt.template || '')
setOutputFormat(prompt.output_format || 'text')
// Parse stages if editing pipeline
if (prompt.type === 'pipeline' && prompt.stages) {
try {
const parsedStages = typeof prompt.stages === 'string'
? JSON.parse(prompt.stages)
: prompt.stages
setStages(parsedStages.length > 0 ? parsedStages : [{ stage: 1, prompts: [] }])
} catch (e) {
console.error('Failed to parse stages:', e)
setStages([{ stage: 1, prompts: [] }])
}
}
}
}, [prompt])
const loadAvailablePrompts = async () => {
try {
const prompts = await api.listAdminPrompts()
setAvailablePrompts(prompts)
} catch (e) {
setError('Fehler beim Laden der Prompts: ' + e.message)
}
}
const addStage = () => {
const nextStageNum = Math.max(...stages.map(s => s.stage), 0) + 1
setStages([...stages, { stage: nextStageNum, prompts: [] }])
}
const removeStage = (stageNum) => {
if (stages.length === 1) {
setError('Mindestens eine Stage erforderlich')
return
}
setStages(stages.filter(s => s.stage !== stageNum))
}
const moveStage = (stageNum, direction) => {
const idx = stages.findIndex(s => s.stage === stageNum)
if (idx === -1) return
const newStages = [...stages]
if (direction === 'up' && idx > 0) {
[newStages[idx], newStages[idx - 1]] = [newStages[idx - 1], newStages[idx]]
} else if (direction === 'down' && idx < newStages.length - 1) {
[newStages[idx], newStages[idx + 1]] = [newStages[idx + 1], newStages[idx]]
}
// Renumber stages
newStages.forEach((s, i) => s.stage = i + 1)
setStages(newStages)
}
const addPromptToStage = (stageNum) => {
setStages(stages.map(s => {
if (s.stage === stageNum) {
return {
...s,
prompts: [...s.prompts, {
source: 'inline',
template: '',
output_key: `output_${Date.now()}`,
output_format: 'text'
}]
}
}
return s
}))
}
const removePromptFromStage = (stageNum, promptIdx) => {
setStages(stages.map(s => {
if (s.stage === stageNum) {
return {
...s,
prompts: s.prompts.filter((_, i) => i !== promptIdx)
}
}
return s
}))
}
const updateStagePrompt = (stageNum, promptIdx, field, value) => {
setStages(stages.map(s => {
if (s.stage === stageNum) {
const newPrompts = [...s.prompts]
newPrompts[promptIdx] = { ...newPrompts[promptIdx], [field]: value }
return { ...s, prompts: newPrompts }
}
return s
}))
}
const handleSave = async () => {
// Validation
if (!name.trim() || !slug.trim()) {
setError('Name und Slug sind Pflichtfelder')
return
}
if (type === 'base' && !template.trim()) {
setError('Basis-Prompts benötigen ein Template')
return
}
if (type === 'pipeline' && stages.length === 0) {
setError('Pipeline-Prompts benötigen mindestens eine Stage')
return
}
if (type === 'pipeline') {
// Validate all stages have at least one prompt
const emptyStages = stages.filter(s => s.prompts.length === 0)
if (emptyStages.length > 0) {
setError(`Stage ${emptyStages[0].stage} hat keine Prompts`)
return
}
// Validate all prompts have required fields
for (const stage of stages) {
for (const p of stage.prompts) {
if (!p.output_key) {
setError(`Stage ${stage.stage}: Output-Key fehlt`)
return
}
if (p.source === 'inline' && !p.template) {
setError(`Stage ${stage.stage}: Inline-Prompt ohne Template`)
return
}
if (p.source === 'reference' && !p.slug) {
setError(`Stage ${stage.stage}: Referenz-Prompt ohne Slug`)
return
}
}
}
}
setLoading(true)
setError(null)
try {
const data = {
name,
slug,
display_name: displayName,
description,
type,
category,
active,
sort_order: sortOrder,
output_format: outputFormat
}
if (type === 'base') {
data.template = template
data.stages = null
} else {
data.template = null
data.stages = stages
}
if (prompt?.id) {
await api.updateUnifiedPrompt(prompt.id, data)
} else {
await api.createUnifiedPrompt(data)
}
onSave()
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000, padding: 20, overflow: 'auto'
}}>
<div style={{
background: 'var(--bg)', borderRadius: 12, maxWidth: 1000, width: '100%',
maxHeight: '90vh', overflow: 'auto', padding: 24
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>
{prompt ? 'Prompt bearbeiten' : 'Neuer Prompt'}
</h2>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={24} color="var(--text3)" />
</button>
</div>
{error && (
<div style={{
padding: 12, background: '#fee', color: '#c00', borderRadius: 8, marginBottom: 16, fontSize: 13
}}>
{error}
</div>
)}
{/* Basic Info */}
<div style={{ display: 'grid', gap: 16, marginBottom: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Name *</label>
<input
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Interner Name"
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<label className="form-label">Slug *</label>
<input
className="form-input"
value={slug}
onChange={e => setSlug(e.target.value)}
placeholder="technischer_name"
style={{ width: '100%', textAlign: 'left' }}
disabled={!!prompt}
/>
</div>
</div>
<div>
<label className="form-label">Anzeigename</label>
<input
className="form-input"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
placeholder="Name für Benutzer (optional)"
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Kurze Beschreibung des Prompts"
style={{ width: '100%', textAlign: 'left', resize: 'vertical' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label className="form-label">Typ *</label>
<select
className="form-select"
value={type}
onChange={e => setType(e.target.value)}
style={{ width: '100%' }}
disabled={!!prompt}
>
<option value="base">Basis-Prompt</option>
<option value="pipeline">Pipeline</option>
</select>
</div>
<div>
<label className="form-label">Kategorie</label>
<select
className="form-select"
value={category}
onChange={e => setCategory(e.target.value)}
style={{ width: '100%' }}
>
<option value="ganzheitlich">Ganzheitlich</option>
<option value="körper">Körper</option>
<option value="ernährung">Ernährung</option>
<option value="training">Training</option>
<option value="schlaf">Schlaf</option>
<option value="pipeline">Pipeline</option>
</select>
</div>
<div>
<label className="form-label">Output-Format</label>
<select
className="form-select"
value={outputFormat}
onChange={e => setOutputFormat(e.target.value)}
style={{ width: '100%' }}
>
<option value="text">Text</option>
<option value="json">JSON</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input
type="checkbox"
checked={active}
onChange={e => setActive(e.target.checked)}
/>
<span style={{ fontSize: 13 }}>Aktiv</span>
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label className="form-label" style={{ margin: 0 }}>Sortierung:</label>
<input
type="number"
className="form-input"
value={sortOrder}
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
style={{ width: 80, padding: '4px 8px' }}
/>
</div>
</div>
</div>
{/* Type-specific editor */}
{type === 'base' && (
<div style={{ marginBottom: 24 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Template</h3>
<textarea
className="form-input"
value={template}
onChange={e => setTemplate(e.target.value)}
rows={12}
placeholder="Prompt-Template mit {{placeholders}}..."
style={{ width: '100%', textAlign: 'left', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Verwende {{'{{'}}platzhalter{{'}}'}} für dynamische Werte (z.B. {{'{{'}}weight_data{{'}}'}}, {{'{{'}}nutrition_data{{'}}'}})
</div>
</div>
)}
{type === 'pipeline' && (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>Stages ({stages.length})</h3>
<button className="btn" onClick={addStage} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Plus size={16} /> Stage hinzufügen
</button>
</div>
{stages.map((stage, sIdx) => (
<div key={stage.stage} style={{
background: 'var(--surface)', padding: 16, borderRadius: 8,
border: '1px solid var(--border)', marginBottom: 12
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Stage {stage.stage}</h4>
<div style={{ display: 'flex', gap: 4 }}>
{sIdx > 0 && (
<button
className="btn"
onClick={() => moveStage(stage.stage, 'up')}
style={{ padding: '4px 8px' }}
>
<MoveUp size={14} />
</button>
)}
{sIdx < stages.length - 1 && (
<button
className="btn"
onClick={() => moveStage(stage.stage, 'down')}
style={{ padding: '4px 8px' }}
>
<MoveDown size={14} />
</button>
)}
<button
className="btn"
onClick={() => removeStage(stage.stage)}
style={{ padding: '4px 8px', color: 'var(--danger)' }}
disabled={stages.length === 1}
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Prompts in this stage */}
{stage.prompts.map((p, pIdx) => (
<div key={pIdx} style={{
background: 'var(--bg)', padding: 12, borderRadius: 6, marginBottom: 8,
border: '1px solid var(--border)'
}}>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr 120px auto', gap: 8, alignItems: 'center' }}>
<select
className="form-select"
value={p.source || 'inline'}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'source', e.target.value)}
style={{ fontSize: 12 }}
>
<option value="inline">Inline</option>
<option value="reference">Referenz</option>
</select>
<input
className="form-input"
value={p.output_key || ''}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_key', e.target.value)}
placeholder="output_key"
style={{ fontSize: 12, textAlign: 'left' }}
/>
<select
className="form-select"
value={p.output_format || 'text'}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_format', e.target.value)}
style={{ fontSize: 12 }}
>
<option value="text">Text</option>
<option value="json">JSON</option>
</select>
<button
onClick={() => removePromptFromStage(stage.stage, pIdx)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<Trash2 size={16} color="var(--danger)" />
</button>
</div>
{p.source === 'reference' ? (
<select
className="form-select"
value={p.slug || ''}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'slug', e.target.value)}
style={{ fontSize: 12 }}
>
<option value="">-- Prompt wählen --</option>
{availablePrompts
.filter(ap => ap.type === 'base' || !ap.type)
.map(ap => (
<option key={ap.slug} value={ap.slug}>
{ap.display_name || ap.name} ({ap.slug})
</option>
))}
</select>
) : (
<textarea
className="form-input"
value={p.template || ''}
onChange={e => updateStagePrompt(stage.stage, pIdx, 'template', e.target.value)}
rows={3}
placeholder="Inline-Template mit {{placeholders}}..."
style={{ fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
/>
)}
</div>
</div>
))}
<button
className="btn"
onClick={() => addPromptToStage(stage.stage)}
style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}
>
<Plus size={14} /> Prompt hinzufügen
</button>
</div>
))}
</div>
)}
{/* Actions */}
<div style={{
display: 'flex', gap: 12, justifyContent: 'flex-end',
paddingTop: 16, borderTop: '1px solid var(--border)'
}}>
<button className="btn" onClick={onClose}>
Abbrechen
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={loading}
>
{loading ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@ -1,27 +1,23 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import PromptEditModal from '../components/PromptEditModal'
import PipelineConfigModal from '../components/PipelineConfigModal'
import { Star, Trash2, Edit, Copy } from 'lucide-react'
import UnifiedPromptModal from '../components/UnifiedPromptModal'
import { Star, Trash2, Edit, Copy, Filter } from 'lucide-react'
/**
* Admin Prompts Page - Unified System (Issue #28 Phase 3)
*
* Manages both base and pipeline-type prompts in one interface.
*/
export default function AdminPromptsPage() {
const [activeTab, setActiveTab] = useState('prompts') // 'prompts' | 'pipelines'
// Prompts state
const [prompts, setPrompts] = useState([])
const [filteredPrompts, setFilteredPrompts] = useState([])
const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline'
const [category, setCategory] = useState('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [editingPrompt, setEditingPrompt] = useState(null)
const [showNewPrompt, setShowNewPrompt] = useState(false)
// Pipeline configs state
const [pipelineConfigs, setPipelineConfigs] = useState([])
const [pipelineLoading, setPipelineLoading] = useState(false)
const [editingPipeline, setEditingPipeline] = useState(null)
const [showNewPipeline, setShowNewPipeline] = useState(false)
const categories = [
{ id: 'all', label: 'Alle Kategorien' },
{ id: 'körper', label: 'Körper' },
@ -30,21 +26,31 @@ export default function AdminPromptsPage() {
{ id: 'schlaf', label: 'Schlaf' },
{ id: 'vitalwerte', label: 'Vitalwerte' },
{ id: 'ziele', label: 'Ziele' },
{ id: 'ganzheitlich', label: 'Ganzheitlich' }
{ id: 'ganzheitlich', label: 'Ganzheitlich' },
{ id: 'pipeline', label: 'Pipeline' }
]
useEffect(() => {
loadPrompts()
loadPipelineConfigs()
}, [])
useEffect(() => {
if (category === 'all') {
setFilteredPrompts(prompts)
} else {
setFilteredPrompts(prompts.filter(p => p.category === category))
let filtered = prompts
// Filter by type
if (typeFilter === 'base') {
filtered = filtered.filter(p => p.type === 'base')
} else if (typeFilter === 'pipeline') {
filtered = filtered.filter(p => p.type === 'pipeline')
}
}, [category, prompts])
// Filter by category
if (category !== 'all') {
filtered = filtered.filter(p => p.category === category)
}
setFilteredPrompts(filtered)
}, [typeFilter, category, prompts])
const loadPrompts = async () => {
try {
@ -61,7 +67,7 @@ export default function AdminPromptsPage() {
const handleToggleActive = async (prompt) => {
try {
await api.updatePrompt(prompt.id, { active: !prompt.active })
await api.updateUnifiedPrompt(prompt.id, { active: !prompt.active })
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
@ -88,408 +94,345 @@ export default function AdminPromptsPage() {
}
}
const handleMoveUp = async (prompt) => {
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
if (idx === 0) return // Already at top
const above = filteredPrompts[idx - 1]
const newOrder = filteredPrompts.map(p => p.id)
newOrder[idx] = above.id
newOrder[idx - 1] = prompt.id
try {
await api.reorderPrompts(newOrder)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleMoveDown = async (prompt) => {
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
if (idx === filteredPrompts.length - 1) return // Already at bottom
const below = filteredPrompts[idx + 1]
const newOrder = filteredPrompts.map(p => p.id)
newOrder[idx] = below.id
newOrder[idx + 1] = prompt.id
try {
await api.reorderPrompts(newOrder)
await loadPrompts()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleSavePrompt = async () => {
await loadPrompts()
const handleSave = async () => {
setEditingPrompt(null)
setShowNewPrompt(false)
await loadPrompts()
}
// Pipeline Config handlers
const loadPipelineConfigs = async () => {
const getStageCount = (prompt) => {
if (prompt.type !== 'pipeline' || !prompt.stages) return 0
try {
setPipelineLoading(true)
const data = await api.listPipelineConfigs()
setPipelineConfigs(data)
const stages = typeof prompt.stages === 'string'
? JSON.parse(prompt.stages)
: prompt.stages
return stages.length
} catch (e) {
setError(e.message)
} finally {
setPipelineLoading(false)
return 0
}
}
const handleDeletePipeline = async (config) => {
if (!confirm(`Pipeline-Config "${config.name}" wirklich löschen?`)) return
try {
await api.deletePipelineConfig(config.id)
await loadPipelineConfigs()
} catch (e) {
alert('Fehler: ' + e.message)
}
const getTypeLabel = (type) => {
if (type === 'base') return 'Basis'
if (type === 'pipeline') return 'Pipeline'
return type || 'Pipeline' // Default for old prompts
}
const handleSetDefaultPipeline = async (config) => {
try {
await api.setDefaultPipelineConfig(config.id)
await loadPipelineConfigs()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleSavePipeline = async () => {
await loadPipelineConfigs()
setEditingPipeline(null)
setShowNewPipeline(false)
}
if (loading) {
return (
<div className="page">
<div style={{textAlign:'center', padding:40}}>
<div className="spinner"/>
</div>
</div>
)
const getTypeColor = (type) => {
if (type === 'base') return 'var(--accent)'
if (type === 'pipeline') return '#6366f1'
return 'var(--text3)'
}
return (
<div className="page">
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:24}}>
<h1 className="page-title">KI-Prompts & Pipelines</h1>
<div style={{
padding: 20,
maxWidth: 1400,
margin: '0 auto',
paddingBottom: 80
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24
}}>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>
KI-Prompts ({filteredPrompts.length})
</h1>
<button
className="btn btn-primary"
onClick={() => activeTab === 'prompts' ? setShowNewPrompt(true) : setShowNewPipeline(true)}
onClick={() => setShowNewPrompt(true)}
>
{activeTab === 'prompts' ? '+ Neuer Prompt' : '+ Neue Pipeline'}
</button>
</div>
{/* Tab Switcher */}
<div style={{display:'flex', gap:12, marginBottom:24, borderBottom:'2px solid var(--border)'}}>
<button
onClick={() => setActiveTab('prompts')}
style={{
padding:'12px 24px', background:'none', border:'none',
borderBottom: activeTab === 'prompts' ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom:-2, cursor:'pointer', fontSize:14, fontWeight:activeTab === 'prompts' ? 600 : 400,
color: activeTab === 'prompts' ? 'var(--accent)' : 'var(--text2)'
}}
>
Prompts ({prompts.length})
</button>
<button
onClick={() => setActiveTab('pipelines')}
style={{
padding:'12px 24px', background:'none', border:'none',
borderBottom: activeTab === 'pipelines' ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom:-2, cursor:'pointer', fontSize:14, fontWeight:activeTab === 'pipelines' ? 600 : 400,
color: activeTab === 'pipelines' ? 'var(--accent)' : 'var(--text2)'
}}
>
Pipeline-Konfigurationen ({pipelineConfigs.length})
+ Neuer Prompt
</button>
</div>
{error && (
<div style={{padding:12, background:'#fee', color:'#c00', borderRadius:8, marginBottom:16}}>
<div style={{
padding: 16,
background: '#fee',
color: '#c00',
borderRadius: 8,
marginBottom: 16
}}>
{error}
</div>
)}
{/* Prompts Tab */}
{activeTab === 'prompts' && (
<>
{/* Category Filter */}
<div style={{marginBottom:24}}>
<label style={{fontSize:13, fontWeight:600, marginBottom:8, display:'block'}}>
Kategorie filtern:
</label>
{/* Filters */}
<div style={{
display: 'flex',
gap: 12,
marginBottom: 24,
flexWrap: 'wrap',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Filter size={16} color="var(--text3)" />
<span style={{ fontSize: 13, color: 'var(--text3)' }}>Typ:</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button
className={typeFilter === 'all' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('all')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
Alle ({prompts.length})
</button>
<button
className={typeFilter === 'base' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('base')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
Basis-Prompts ({prompts.filter(p => p.type === 'base').length})
</button>
<button
className={typeFilter === 'pipeline' ? 'btn btn-primary' : 'btn'}
onClick={() => setTypeFilter('pipeline')}
style={{ fontSize: 13, padding: '6px 12px' }}
>
Pipelines ({prompts.filter(p => p.type === 'pipeline' || !p.type).length})
</button>
</div>
<div style={{
width: 1,
height: 24,
background: 'var(--border)',
margin: '0 8px'
}} />
<select
className="form-select"
value={category}
onChange={e => setCategory(e.target.value)}
style={{maxWidth:300}}
style={{ fontSize: 13, padding: '6px 12px' }}
>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.label}</option>
<option key={cat.id} value={cat.id}>
{cat.label}
</option>
))}
</select>
</div>
{/* Prompts Table */}
<div className="card">
<table style={{width:'100%', borderCollapse:'collapse'}}>
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Lädt...
</div>
) : (
<div style={{
background: 'var(--surface)',
borderRadius: 12,
border: '1px solid var(--border)',
overflow: 'hidden'
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{borderBottom:'2px solid var(--border)', textAlign:'left'}}>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Titel
<tr style={{
background: 'var(--surface2)',
borderBottom: '1px solid var(--border)'
}}>
<th style={{
padding: 12,
textAlign: 'left',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 80
}}>
Typ
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
<th style={{
padding: 12,
textAlign: 'left',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)'
}}>
Name
</th>
<th style={{
padding: 12,
textAlign: 'left',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 120
}}>
Kategorie
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Aktiv
<th style={{
padding: 12,
textAlign: 'center',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 100
}}>
Stages
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
Reihenfolge
<th style={{
padding: 12,
textAlign: 'center',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 80
}}>
Status
</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
<th style={{
padding: 12,
textAlign: 'right',
fontSize: 12,
fontWeight: 600,
color: 'var(--text3)',
width: 120
}}>
Aktionen
</th>
</tr>
</thead>
<tbody>
{filteredPrompts.map((prompt, idx) => (
<tr key={prompt.id} style={{borderBottom:'1px solid var(--border)'}}>
<td style={{padding:'12px 8px'}}>
<div style={{fontWeight:500, fontSize:14}}>{prompt.name}</div>
{prompt.description && (
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
{prompt.description}
{filteredPrompts.length === 0 ? (
<tr>
<td colSpan="6" style={{
padding: 40,
textAlign: 'center',
color: 'var(--text3)'
}}>
Keine Prompts gefunden
</td>
</tr>
) : (
filteredPrompts.map(prompt => (
<tr
key={prompt.id}
style={{
borderBottom: '1px solid var(--border)',
opacity: prompt.active ? 1 : 0.5
}}
>
<td style={{ padding: 12 }}>
<div style={{
display: 'inline-block',
padding: '2px 8px',
background: getTypeColor(prompt.type) + '20',
color: getTypeColor(prompt.type),
borderRadius: 6,
fontSize: 11,
fontWeight: 600
}}>
{getTypeLabel(prompt.type)}
</div>
)}
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
</td>
<td style={{ padding: 12 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>
{prompt.display_name || prompt.name}
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{prompt.slug}
</div>
</div>
</td>
<td style={{padding:'12px 8px'}}>
<span style={{
padding:'4px 8px', borderRadius:4, fontSize:11, fontWeight:500,
background:'var(--surface2)', color:'var(--text2)'
}}>
<td style={{ padding: 12, fontSize: 13 }}>
{prompt.category || 'ganzheitlich'}
</span>
</td>
<td style={{padding:'12px 8px'}}>
<td style={{ padding: 12, textAlign: 'center', fontSize: 13 }}>
{prompt.type === 'pipeline' ? (
<span style={{
background: 'var(--surface2)',
padding: '2px 6px',
borderRadius: 4,
fontSize: 11
}}>
{getStageCount(prompt)} Stages
</span>
) : (
<span style={{ color: 'var(--text3)', fontSize: 11 }}></span>
)}
</td>
<td style={{ padding: 12, textAlign: 'center' }}>
<label style={{
display: 'inline-flex',
alignItems: 'center',
cursor: 'pointer'
}}>
<input
type="checkbox"
checked={prompt.active}
onChange={() => handleToggleActive(prompt)}
style={{cursor:'pointer'}}
style={{ margin: 0 }}
/>
</label>
</td>
<td style={{padding:'12px 8px'}}>
<div style={{display:'flex', gap:4}}>
<td style={{ padding: 12 }}>
<div style={{
display: 'flex',
gap: 6,
justifyContent: 'flex-end'
}}>
<button
onClick={() => handleMoveUp(prompt)}
disabled={idx === 0}
style={{
padding:'4px 8px', fontSize:12, cursor: idx === 0 ? 'not-allowed' : 'pointer',
opacity: idx === 0 ? 0.5 : 1
}}
title="Nach oben"
>
</button>
<button
onClick={() => handleMoveDown(prompt)}
disabled={idx === filteredPrompts.length - 1}
style={{
padding:'4px 8px', fontSize:12,
cursor: idx === filteredPrompts.length - 1 ? 'not-allowed' : 'pointer',
opacity: idx === filteredPrompts.length - 1 ? 0.5 : 1
}}
title="Nach unten"
>
</button>
</div>
</td>
<td style={{padding:'12px 8px'}}>
<div style={{display:'flex', gap:6}}>
<button
className="btn"
onClick={() => setEditingPrompt(prompt)}
style={{fontSize:12, padding:'6px 12px'}}
>
Bearbeiten
</button>
<button
className="btn"
onClick={() => handleDuplicate(prompt)}
style={{fontSize:12, padding:'6px 12px'}}
>
Duplizieren
</button>
<button
className="btn"
onClick={() => handleDelete(prompt)}
style={{fontSize:12, padding:'6px 12px', color:'#D85A30'}}
>
Löschen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredPrompts.length === 0 && (
<div style={{padding:40, textAlign:'center', color:'var(--text3)'}}>
Keine Prompts in dieser Kategorie
</div>
)}
</div>
</>
)}
{/* Pipelines Tab */}
{activeTab === 'pipelines' && (
<>
{pipelineLoading ? (
<div style={{textAlign:'center', padding:40}}>
<div className="spinner"/>
</div>
) : (
<div className="card">
<table style={{width:'100%', borderCollapse:'collapse'}}>
<thead>
<tr style={{borderBottom:'2px solid var(--border)', textAlign:'left'}}>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Name</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Module</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Stages</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Status</th>
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Aktionen</th>
</tr>
</thead>
<tbody>
{pipelineConfigs.map(config => {
const activeModules = Object.entries(config.modules || {}).filter(([_, active]) => active)
return (
<tr key={config.id} style={{borderBottom:'1px solid var(--border)'}}>
<td style={{padding:'12px 8px'}}>
<div style={{fontWeight:500, fontSize:14}}>{config.name}</div>
{config.is_default && (
<span style={{
fontSize:10, padding:'2px 6px', background:'var(--accent)',
color:'white', borderRadius:3, marginTop:4, display:'inline-block'
}}>
Standard
</span>
)}
{config.description && (
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
{config.description}
</div>
)}
</td>
<td style={{padding:'12px 8px'}}>
<div style={{fontSize:11}}>
{activeModules.map(([name]) => name).join(', ')}
</div>
<div style={{fontSize:10, color:'var(--text3)', marginTop:2}}>
{activeModules.length} Module
</div>
</td>
<td style={{padding:'12px 8px'}}>
<div style={{fontSize:11}}>
S1: {config.stage1_prompts?.length || 0} Prompts
</div>
<div style={{fontSize:10, color:'var(--text3)'}}>
S2: {config.stage2_prompt ? '✓' : '-'} | S3: {config.stage3_prompt ? '✓' : '-'}
</div>
</td>
<td style={{padding:'12px 8px'}}>
<span style={{
fontSize:11, padding:'3px 8px', borderRadius:4,
background: config.active ? 'var(--accent-light)' : 'var(--surface2)',
color: config.active ? 'var(--accent)' : 'var(--text3)'
}}>
{config.active ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td style={{padding:'12px 8px'}}>
<div style={{display:'flex', gap:6}}>
{!config.is_default && (
<button
onClick={() => handleSetDefaultPipeline(config)}
style={{padding:'4px 8px', fontSize:11}}
title="Als Standard setzen"
>
<Star size={14} />
</button>
)}
<button
onClick={() => setEditingPipeline(config)}
style={{padding:'4px 8px', fontSize:11}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4
}}
title="Bearbeiten"
>
<Edit size={14} />
<Edit size={16} color="var(--accent)" />
</button>
<button
onClick={() => handleDeletePipeline(config)}
style={{padding:'4px 8px', fontSize:11, color:'var(--danger)'}}
onClick={() => handleDuplicate(prompt)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4
}}
title="Duplizieren"
>
<Copy size={16} color="var(--text3)" />
</button>
<button
onClick={() => handleDelete(prompt)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4
}}
title="Löschen"
>
<Trash2 size={14} />
<Trash2 size={16} color="var(--danger)" />
</button>
</div>
</td>
</tr>
)
})}
))
)}
</tbody>
</table>
{pipelineConfigs.length === 0 && (
<div style={{padding:40, textAlign:'center', color:'var(--text3)'}}>
Noch keine Pipeline-Konfigurationen vorhanden
</div>
)}
</div>
)}
</>
)}
{/* Edit Modals */}
{/* Unified Prompt Modal */}
{(editingPrompt || showNewPrompt) && (
<PromptEditModal
<UnifiedPromptModal
prompt={editingPrompt}
onSave={handleSavePrompt}
onSave={handleSave}
onClose={() => {
setEditingPrompt(null)
setShowNewPrompt(false)
}}
/>
)}
{(editingPipeline || showNewPipeline) && (
<PipelineConfigModal
config={editingPipeline}
onSave={handleSavePipeline}
onClose={() => {
setEditingPipeline(null)
setShowNewPipeline(false)
}}
/>
)}
</div>
)
}