feat: dynamic placeholder picker with categories and search (Issue #28)
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s

Major improvements:
1. PlaceholderPicker component (new)
   - Loads placeholders dynamically from backend catalog
   - Grouped by categories: Profil, Körper, Ernährung, Training, etc.
   - Search/filter functionality
   - Shows live example values from user data
   - Popup modal with expand/collapse categories

2. Replaced hardcoded placeholder chips
   - 'Platzhalter einfügen' button opens picker
   - Works in both base templates and pipeline inline templates
   - Auto-closes after selection

3. Uses existing backend system
   - GET /api/prompts/placeholders
   - placeholder_resolver.py with PLACEHOLDER_MAP
   - Dynamic, module-based placeholder system
   - No manual updates needed when modules add new placeholders

Benefits:
- Scalable: New modules can add placeholders without frontend changes
- User-friendly: Search and categorization
- Context-aware: Shows real example values
- Future-proof: Backend-driven catalog
This commit is contained in:
Lars 2026-03-25 22:08:14 +01:00
parent b058b0fd6f
commit 8036c99883
2 changed files with 327 additions and 49 deletions

View File

@ -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 (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
padding: 20
}}
onClick={onClose}
>
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--bg)',
borderRadius: 12,
maxWidth: 800,
width: '100%',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
{/* Header */}
<div style={{
padding: 20,
borderBottom: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h3 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
Platzhalter auswählen
</h3>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<X size={24} color="var(--text3)" />
</button>
</div>
{/* Search */}
<div style={{ padding: '12px 20px', borderBottom: '1px solid var(--border)' }}>
<div style={{ position: 'relative' }}>
<Search
size={16}
style={{
position: 'absolute',
left: 12,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--text3)'
}}
/>
<input
type="text"
className="form-input"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Platzhalter suchen..."
style={{
width: '100%',
paddingLeft: 40,
textAlign: 'left'
}}
/>
</div>
</div>
{/* Categories */}
<div style={{
flex: 1,
overflow: 'auto',
padding: 20
}}>
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Lädt Platzhalter...
</div>
) : Object.keys(filteredCatalog).length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Keine Platzhalter gefunden
</div>
) : (
Object.entries(filteredCatalog).map(([category, items]) => (
<div key={category} style={{ marginBottom: 16 }}>
<div
onClick={() => toggleCategory(category)}
style={{
padding: '8px 12px',
background: 'var(--surface)',
borderRadius: 8,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8
}}
>
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>
{category} ({items.length})
</h4>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{expandedCategories.has(category) ? '▼' : '▶'}
</span>
</div>
{expandedCategories.has(category) && (
<div style={{ display: 'grid', gap: 6, paddingLeft: 12 }}>
{items.map(item => (
<div
key={item.key}
onClick={() => 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)'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12
}}>
<div style={{ flex: 1 }}>
<code style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--accent)',
fontFamily: 'monospace'
}}>
{`{{${item.key}}}`}
</code>
<div style={{
fontSize: 11,
color: 'var(--text2)',
marginTop: 2
}}>
{item.description}
</div>
</div>
{item.example && (
<div style={{
fontSize: 10,
color: 'var(--text3)',
fontFamily: 'monospace',
padding: '2px 6px',
background: 'var(--bg)',
borderRadius: 4,
whiteSpace: 'nowrap'
}}>
{item.example}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
))
)}
</div>
{/* Footer */}
<div style={{
padding: 16,
borderTop: '1px solid var(--border)',
textAlign: 'center',
fontSize: 11,
color: 'var(--text3)'
}}>
Klicke auf einen Platzhalter zum Einfügen
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { api } from '../utils/api' 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) * 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 [loading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [showPlaceholderPicker, setShowPlaceholderPicker] = useState(false)
const [pickerTarget, setPickerTarget] = useState(null) // 'base' or {stage, promptIdx}
useEffect(() => { useEffect(() => {
loadAvailablePrompts() loadAvailablePrompts()
@ -390,31 +393,23 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
style={{ width: '100%', textAlign: 'left', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }} style={{ width: '100%', textAlign: 'left', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
/> />
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}> <div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
<div style={{ marginBottom: 4 }}> <button
Verwende {'{{'} und {'}}'} für Platzhalter: className="btn"
</div> onClick={() => {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}> setPickerTarget('base')
{['weight_data', 'nutrition_data', 'activity_data', 'sleep_data', 'vitals_baseline', 'blood_pressure', 'profile_id', 'today'].map(ph => ( setShowPlaceholderPicker(true)
<code }}
key={ph} style={{
onClick={() => { fontSize: 12,
const placeholder = `{{${ph}}}` padding: '6px 12px',
setTemplate(template + placeholder) display: 'flex',
}} alignItems: 'center',
style={{ gap: 6
padding: '2px 6px', }}
background: 'var(--surface2)', >
borderRadius: 4, <Code size={14} />
fontSize: 10, Platzhalter einfügen
cursor: 'pointer', </button>
border: '1px solid var(--border)'
}}
title="Klicken zum Einfügen"
>
{'{{'}{ph}{'}}'}
</code>
))}
</div>
</div> </div>
</div> </div>
)} )}
@ -536,29 +531,23 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
style={{ width: '100%', fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }} style={{ width: '100%', fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
/> />
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}> <div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}> <button
{['weight_data', 'nutrition_data', 'activity_data', 'sleep_data', 'vitals_baseline', 'blood_pressure', 'profile_id', 'today'].map(ph => ( className="btn"
<code onClick={() => {
key={ph} setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
onClick={() => { setShowPlaceholderPicker(true)
const placeholder = `{{${ph}}}` }}
const currentValue = p.template || '' style={{
updateStagePrompt(stage.stage, pIdx, 'template', currentValue + placeholder) fontSize: 11,
}} padding: '4px 8px',
style={{ display: 'flex',
padding: '2px 4px', alignItems: 'center',
background: 'var(--surface2)', gap: 4
borderRadius: 4, }}
fontSize: 9, >
cursor: 'pointer', <Code size={12} />
border: '1px solid var(--border)' Platzhalter
}} </button>
title="Klicken zum Einfügen"
>
{'{{'}{ph}{'}}'}
</code>
))}
</div>
</div> </div>
</div> </div>
)} )}
@ -595,6 +584,37 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
</button> </button>
</div> </div>
</div> </div>
{/* Placeholder Picker */}
{showPlaceholderPicker && (
<PlaceholderPicker
onSelect={(placeholder) => {
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)
}}
/>
)}
</div> </div>
) )
} }