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 { 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 }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
Verwende {'{{'} und {'}}'} für Platzhalter:
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{['weight_data', 'nutrition_data', 'activity_data', 'sleep_data', 'vitals_baseline', 'blood_pressure', 'profile_id', 'today'].map(ph => (
|
||||
<code
|
||||
key={ph}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const placeholder = `{{${ph}}}`
|
||||
setTemplate(template + placeholder)
|
||||
setPickerTarget('base')
|
||||
setShowPlaceholderPicker(true)
|
||||
}}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border)'
|
||||
fontSize: 12,
|
||||
padding: '6px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6
|
||||
}}
|
||||
title="Klicken zum Einfügen"
|
||||
>
|
||||
{'{{'}{ph}{'}}'}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<Code size={14} />
|
||||
Platzhalter einfügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -536,29 +531,23 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
style={{ width: '100%', fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
|
||||
/>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{['weight_data', 'nutrition_data', 'activity_data', 'sleep_data', 'vitals_baseline', 'blood_pressure', 'profile_id', 'today'].map(ph => (
|
||||
<code
|
||||
key={ph}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
const placeholder = `{{${ph}}}`
|
||||
const currentValue = p.template || ''
|
||||
updateStagePrompt(stage.stage, pIdx, 'template', currentValue + placeholder)
|
||||
setPickerTarget({ stage: stage.stage, promptIdx: pIdx })
|
||||
setShowPlaceholderPicker(true)
|
||||
}}
|
||||
style={{
|
||||
padding: '2px 4px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 4,
|
||||
fontSize: 9,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--border)'
|
||||
fontSize: 11,
|
||||
padding: '4px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
title="Klicken zum Einfügen"
|
||||
>
|
||||
{'{{'}{ph}{'}}'}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<Code size={12} />
|
||||
Platzhalter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -595,6 +584,37 @@ export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
|||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user