diff --git a/frontend/src/components/PlaceholderPicker.jsx b/frontend/src/components/PlaceholderPicker.jsx
new file mode 100644
index 0000000..d143966
--- /dev/null
+++ b/frontend/src/components/PlaceholderPicker.jsx
@@ -0,0 +1,258 @@
+import { useState, useEffect } from 'react'
+import { api } from '../utils/api'
+import { Search, X } from 'lucide-react'
+
+/**
+ * Placeholder Picker with grouped categories and search
+ *
+ * Loads placeholders dynamically from backend catalog.
+ * Grouped by category (Profil, Körper, Ernährung, Training, etc.)
+ */
+export default function PlaceholderPicker({ onSelect, onClose }) {
+ const [catalog, setCatalog] = useState({})
+ const [search, setSearch] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [expandedCategories, setExpandedCategories] = useState(new Set())
+
+ useEffect(() => {
+ loadCatalog()
+ }, [])
+
+ const loadCatalog = async () => {
+ try {
+ setLoading(true)
+ const data = await api.listPlaceholders()
+ setCatalog(data)
+ // Expand all categories by default
+ setExpandedCategories(new Set(Object.keys(data)))
+ } catch (e) {
+ console.error('Failed to load placeholders:', e)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const toggleCategory = (category) => {
+ const newExpanded = new Set(expandedCategories)
+ if (newExpanded.has(category)) {
+ newExpanded.delete(category)
+ } else {
+ newExpanded.add(category)
+ }
+ setExpandedCategories(newExpanded)
+ }
+
+ const handleSelect = (key) => {
+ onSelect(`{{${key}}}`)
+ onClose()
+ }
+
+ // Filter placeholders by search
+ const filteredCatalog = {}
+ const searchLower = search.toLowerCase()
+
+ Object.entries(catalog).forEach(([category, items]) => {
+ const filtered = items.filter(item =>
+ item.key.toLowerCase().includes(searchLower) ||
+ item.description.toLowerCase().includes(searchLower)
+ )
+ if (filtered.length > 0) {
+ filteredCatalog[category] = filtered
+ }
+ })
+
+ return (
+
+
e.stopPropagation()}
+ style={{
+ background: 'var(--bg)',
+ borderRadius: 12,
+ maxWidth: 800,
+ width: '100%',
+ maxHeight: '80vh',
+ display: 'flex',
+ flexDirection: 'column',
+ overflow: 'hidden'
+ }}
+ >
+ {/* Header */}
+
+
+ Platzhalter auswählen
+
+
+
+
+ {/* Search */}
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Platzhalter suchen..."
+ style={{
+ width: '100%',
+ paddingLeft: 40,
+ textAlign: 'left'
+ }}
+ />
+
+
+
+ {/* Categories */}
+
+ {loading ? (
+
+ Lädt Platzhalter...
+
+ ) : Object.keys(filteredCatalog).length === 0 ? (
+
+ Keine Platzhalter gefunden
+
+ ) : (
+ Object.entries(filteredCatalog).map(([category, items]) => (
+
+
toggleCategory(category)}
+ style={{
+ padding: '8px 12px',
+ background: 'var(--surface)',
+ borderRadius: 8,
+ cursor: 'pointer',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 8
+ }}
+ >
+
+ {category} ({items.length})
+
+
+ {expandedCategories.has(category) ? '▼' : '▶'}
+
+
+
+ {expandedCategories.has(category) && (
+
+ {items.map(item => (
+
handleSelect(item.key)}
+ style={{
+ padding: '8px 12px',
+ background: 'var(--surface2)',
+ borderRadius: 6,
+ cursor: 'pointer',
+ border: '1px solid transparent',
+ transition: 'all 0.2s'
+ }}
+ onMouseEnter={e => {
+ e.currentTarget.style.borderColor = 'var(--accent)'
+ e.currentTarget.style.background = 'var(--surface)'
+ }}
+ onMouseLeave={e => {
+ e.currentTarget.style.borderColor = 'transparent'
+ e.currentTarget.style.background = 'var(--surface2)'
+ }}
+ >
+
+
+
+ {`{{${item.key}}}`}
+
+
+ {item.description}
+
+
+ {item.example && (
+
+ {item.example}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ ))
+ )}
+
+
+ {/* Footer */}
+
+ Klicke auf einen Platzhalter zum Einfügen
+
+
+
+ )
+}
diff --git a/frontend/src/components/UnifiedPromptModal.jsx b/frontend/src/components/UnifiedPromptModal.jsx
index d9d51bd..74b0926 100644
--- a/frontend/src/components/UnifiedPromptModal.jsx
+++ b/frontend/src/components/UnifiedPromptModal.jsx
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
-import { X, Plus, Trash2, MoveUp, MoveDown } from 'lucide-react'
+import { X, Plus, Trash2, MoveUp, MoveDown, Code } from 'lucide-react'
+import PlaceholderPicker from './PlaceholderPicker'
/**
* Unified Prompt Editor Modal (Issue #28 Phase 3)
@@ -36,6 +37,8 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
+ const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
+ const [pickerTarget, setPickerTarget] = useState(null) // 'base' or {stage, promptIdx}
useEffect(() => {
loadAvailablePrompts()
@@ -390,31 +393,23 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
style={{ width: '100%', textAlign: 'left', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
/>
-
- Verwende {'{{'} und {'}}'} für Platzhalter:
-
-
- {['weight_data', 'nutrition_data', 'activity_data', 'sleep_data', 'vitals_baseline', 'blood_pressure', 'profile_id', 'today'].map(ph => (
- {
- const placeholder = `{{${ph}}}`
- setTemplate(template + placeholder)
- }}
- style={{
- padding: '2px 6px',
- background: 'var(--surface2)',
- borderRadius: 4,
- fontSize: 10,
- cursor: 'pointer',
- border: '1px solid var(--border)'
- }}
- title="Klicken zum Einfügen"
- >
- {'{{'}{ph}{'}}'}
-
- ))}
-
+
)}
@@ -536,29 +531,23 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
style={{ width: '100%', fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
/>
-
- {['weight_data', 'nutrition_data', 'activity_data', 'sleep_data', 'vitals_baseline', 'blood_pressure', 'profile_id', 'today'].map(ph => (
- {
- const placeholder = `{{${ph}}}`
- const currentValue = p.template || ''
- updateStagePrompt(stage.stage, pIdx, 'template', currentValue + placeholder)
- }}
- style={{
- padding: '2px 4px',
- background: 'var(--surface2)',
- borderRadius: 4,
- fontSize: 9,
- cursor: 'pointer',
- border: '1px solid var(--border)'
- }}
- title="Klicken zum Einfügen"
- >
- {'{{'}{ph}{'}}'}
-
- ))}
-
+
)}
@@ -595,6 +584,37 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
+
+ {/* Placeholder Picker */}
+ {showPlaceholderPicker && (
+ {
+ if (pickerTarget === 'base') {
+ // Insert into base template
+ setTemplate(template + placeholder)
+ } else if (pickerTarget && typeof pickerTarget === 'object') {
+ // Insert into pipeline stage template
+ const { stage: stageNum, promptIdx } = pickerTarget
+ setStages(stages.map(s => {
+ if (s.stage === stageNum) {
+ const newPrompts = [...s.prompts]
+ const currentTemplate = newPrompts[promptIdx].template || ''
+ newPrompts[promptIdx] = {
+ ...newPrompts[promptIdx],
+ template: currentTemplate + placeholder
+ }
+ return { ...s, prompts: newPrompts }
+ }
+ return s
+ }))
+ }
+ }}
+ onClose={() => {
+ setShowPlaceholderPicker(false)
+ setPickerTarget(null)
+ }}
+ />
+ )}
)
}