feat: dynamic placeholder picker with categories and search (Issue #28)
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:
parent
b058b0fd6f
commit
8036c99883
258
frontend/src/components/PlaceholderPicker.jsx
Normal file
258
frontend/src/components/PlaceholderPicker.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user