feat: Add EmojiIconPicker component and integrate it into Admin pages for icon selection
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s

This commit is contained in:
Lars 2026-04-04 14:07:54 +02:00
parent dc87e7f3b8
commit 5aae999a65
4 changed files with 237 additions and 24 deletions

View File

@ -0,0 +1,206 @@
import { useState, useId } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
/**
* Kuratierte Emoji-Gruppen für Sport, Körper, Ernährung usw.
* Kann bei Bedarf erweitert werden (z. B. prop `extraGroups`).
*/
export const EMOJI_ICON_GROUPS = [
{
label: 'Training & Sport',
emojis: [
'🏃', '🚴', '🏊', '🧘', '🏋️', '⛷️', '🤸', '🥊', '🎾', '⚽', '🏀', '🤺',
'🚶', '🧗', '⛰️', '🏄', '🤿', '🥋', '🤼', '⛹️', '🤾', '🏌️', '🎿', '🧗‍♂️'
]
},
{
label: 'Körper & Gesundheit',
emojis: [
'💪', '❤️', '🫀', '🦵', '🧠', '👁️', '⚖️', '📏', '🩺', '💊', '🌡️', '🫁'
]
},
{
label: 'Ernährung',
emojis: [
'🍎', '🥩', '🥗', '🍽️', '💧', '☕', '🥤', '🥛', '🍌', '🥑', '🍞', '🐟'
]
},
{
label: 'Schlaf & Erholung',
emojis: ['😴', '🌙', '🛌', '💤', '🧖', '☀️', '🌿']
},
{
label: 'Allgemein',
emojis: [
'🎯', '📊', '🔥', '⭐', '✨', '🎵', '📌', '🧭', '📝', '✅', '💡', '🪄'
]
}
]
/**
* Wiederverwendbare Emoji-/Icon-Auswahl: Vorschau, Freitext (inkl. System-Emoji-Picker),
* optional ausklappbare Vorschläge.
*
* @param {string} value aktueller Icon-String (meist ein Emoji)
* @param {(next: string) => void} onChange
* @param {string} [placeholder]
* @param {number} [maxLength=10]
* @param {boolean} [disabled]
* @param {string} [id] optionale ID für das Textfeld (Label for=)
* @param {boolean} [defaultExpanded=false] Vorschlags-Bereich initial offen
*/
export default function EmojiIconPicker({
value,
onChange,
placeholder = '📝',
maxLength = 10,
disabled = false,
id: idProp,
defaultExpanded = false
}) {
const uid = useId()
const inputId = idProp || `emoji-icon-${uid}`
const [open, setOpen] = useState(defaultExpanded)
const handleInput = (e) => {
onChange(e.target.value.slice(0, maxLength))
}
const pick = (em) => {
onChange(em.slice(0, maxLength))
}
return (
<div className="emoji-icon-picker" style={{ width: '100%' }}>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'wrap'
}}
>
<span
style={{
fontSize: 28,
lineHeight: 1,
minWidth: 40,
textAlign: 'center',
opacity: value ? 1 : 0.35
}}
aria-hidden
>
{value || '·'}
</span>
<input
id={inputId}
type="text"
className="form-input"
style={{ flex: 1, minWidth: 120 }}
value={value}
onChange={handleInput}
placeholder={placeholder}
maxLength={maxLength}
disabled={disabled}
autoComplete="off"
spellCheck={false}
inputMode="text"
/>
<button
type="button"
className="btn btn-secondary"
disabled={disabled}
onClick={() => setOpen((o) => !o)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '8px 12px',
fontSize: 13
}}
aria-expanded={open}
>
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
Vorschläge
</button>
{!!value && !disabled && (
<button
type="button"
className="btn btn-secondary"
onClick={() => onChange('')}
style={{ padding: '8px 12px', fontSize: 13 }}
>
Leeren
</button>
)}
</div>
{open && (
<div
role="listbox"
aria-label="Emoji-Vorschläge"
style={{
marginTop: 10,
padding: 12,
background: 'var(--surface2)',
borderRadius: 12,
border: '1px solid var(--border)',
maxHeight: 280,
overflowY: 'auto'
}}
>
{EMOJI_ICON_GROUPS.map((group) => (
<div key={group.label} style={{ marginBottom: 14 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text3)',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: '0.04em'
}}
>
{group.label}
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6
}}
>
{group.emojis.map((em) => (
<button
key={`${group.label}-${em}`}
type="button"
onClick={() => pick(em)}
disabled={disabled}
title={em}
aria-label={`Icon wählen: ${em}`}
style={{
fontSize: 22,
lineHeight: 1,
padding: '8px 10px',
border: `1px solid ${value === em ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background:
value === em ? 'var(--accent-light)' : 'var(--surface)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1
}}
>
{em}
</button>
))}
</div>
</div>
))}
<p style={{ fontSize: 11, color: 'var(--text3)', margin: 0, lineHeight: 1.5 }}>
Du kannst auch direkt in das Feld tippen oder das Betriebssystem-Emoji-Menü nutzen
(z.&nbsp;B. Win + . unter Windows).
</p>
</div>
)}
</div>
)
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'
import { api } from '../utils/api'
import EmojiIconPicker from '../components/EmojiIconPicker'
const CATEGORIES = [
{ value: 'body_composition', label: 'Körperzusammensetzung' },
@ -220,15 +221,18 @@ export default function AdminFocusAreasPage() {
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}>
<label
htmlFor="admin-focus-area-new-icon"
style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 4 }}
>
Icon (Emoji)
</label>
<input
className="form-input"
<EmojiIconPicker
id="admin-focus-area-new-icon"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="💥"
style={{ width: '100%' }}
maxLength={10}
/>
</div>
@ -332,14 +336,18 @@ export default function AdminFocusAreasPage() {
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}>
<label
htmlFor={`admin-focus-area-icon-${area.id}`}
style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 4 }}
>
Icon
</label>
<input
className="form-input"
<EmojiIconPicker
id={`admin-focus-area-icon-${area.id}`}
value={area.icon || ''}
onChange={(e) => updateField(area.id, 'icon', e.target.value)}
style={{ width: '100%' }}
onChange={(icon) => updateField(area.id, 'icon', icon)}
placeholder="💥"
maxLength={10}
/>
</div>

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Settings, Plus, Pencil, Trash2, Database } from 'lucide-react'
import { api } from '../utils/api'
import EmojiIconPicker from '../components/EmojiIconPicker'
export default function AdminGoalTypesPage() {
const [goalTypes, setGoalTypes] = useState([])
@ -367,14 +368,15 @@ export default function AdminGoalTypesPage() {
/>
</div>
<div>
<label className="form-label">Icon (Emoji)</label>
<input
type="text"
className="form-input"
style={{ width: '100%' }}
<label className="form-label" htmlFor="admin-goal-type-icon">
Icon (Emoji)
</label>
<EmojiIconPicker
id="admin-goal-type-icon"
value={formData.icon}
onChange={e => setFormData(f => ({ ...f, icon: e.target.value }))}
onChange={(icon) => setFormData((f) => ({ ...f, icon }))}
placeholder="🧘"
maxLength={10}
/>
</div>
</div>

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Pencil, Trash2, Plus, Save, X, ArrowLeft, Settings } from 'lucide-react'
import { api } from '../utils/api'
import ProfileBuilder from '../components/ProfileBuilder'
import EmojiIconPicker from '../components/EmojiIconPicker'
/**
* AdminTrainingTypesPage - CRUD for training types
@ -254,13 +255,11 @@ export default function AdminTrainingTypesPage() {
<div>
<div className="form-label">Icon (Emoji)</div>
<input
className="form-input"
<EmojiIconPicker
value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })}
onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="🏃"
maxLength={10}
style={{ width: '100%' }}
/>
</div>
@ -495,13 +494,11 @@ export default function AdminTrainingTypesPage() {
<div>
<div className="form-label">Icon (Emoji)</div>
<input
className="form-input"
<EmojiIconPicker
value={formData.icon}
onChange={e => setFormData({ ...formData, icon: e.target.value })}
onChange={(icon) => setFormData({ ...formData, icon })}
placeholder="🏃"
maxLength={10}
style={{ width: '100%' }}
/>
</div>