feat: Add EmojiIconPicker component and integrate it into Admin pages for icon selection
This commit is contained in:
parent
dc87e7f3b8
commit
5aae999a65
206
frontend/src/components/EmojiIconPicker.jsx
Normal file
206
frontend/src/components/EmojiIconPicker.jsx
Normal 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. B. Win + . unter Windows).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'
|
import { Plus, Pencil, Trash2, Save, X, Eye, EyeOff } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import EmojiIconPicker from '../components/EmojiIconPicker'
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ value: 'body_composition', label: 'Körperzusammensetzung' },
|
{ value: 'body_composition', label: 'Körperzusammensetzung' },
|
||||||
|
|
@ -220,15 +221,18 @@ export default function AdminFocusAreasPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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)
|
Icon (Emoji)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<EmojiIconPicker
|
||||||
className="form-input"
|
id="admin-focus-area-new-icon"
|
||||||
value={formData.icon}
|
value={formData.icon}
|
||||||
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
|
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||||
placeholder="💥"
|
placeholder="💥"
|
||||||
style={{ width: '100%' }}
|
maxLength={10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -332,14 +336,18 @@ export default function AdminFocusAreasPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Icon
|
||||||
</label>
|
</label>
|
||||||
<input
|
<EmojiIconPicker
|
||||||
className="form-input"
|
id={`admin-focus-area-icon-${area.id}`}
|
||||||
value={area.icon || ''}
|
value={area.icon || ''}
|
||||||
onChange={(e) => updateField(area.id, 'icon', e.target.value)}
|
onChange={(icon) => updateField(area.id, 'icon', icon)}
|
||||||
style={{ width: '100%' }}
|
placeholder="💥"
|
||||||
|
maxLength={10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Settings, Plus, Pencil, Trash2, Database } from 'lucide-react'
|
import { Settings, Plus, Pencil, Trash2, Database } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import EmojiIconPicker from '../components/EmojiIconPicker'
|
||||||
|
|
||||||
export default function AdminGoalTypesPage() {
|
export default function AdminGoalTypesPage() {
|
||||||
const [goalTypes, setGoalTypes] = useState([])
|
const [goalTypes, setGoalTypes] = useState([])
|
||||||
|
|
@ -367,14 +368,15 @@ export default function AdminGoalTypesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Icon (Emoji)</label>
|
<label className="form-label" htmlFor="admin-goal-type-icon">
|
||||||
<input
|
Icon (Emoji)
|
||||||
type="text"
|
</label>
|
||||||
className="form-input"
|
<EmojiIconPicker
|
||||||
style={{ width: '100%' }}
|
id="admin-goal-type-icon"
|
||||||
value={formData.icon}
|
value={formData.icon}
|
||||||
onChange={e => setFormData(f => ({ ...f, icon: e.target.value }))}
|
onChange={(icon) => setFormData((f) => ({ ...f, icon }))}
|
||||||
placeholder="🧘"
|
placeholder="🧘"
|
||||||
|
maxLength={10}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import { Pencil, Trash2, Plus, Save, X, ArrowLeft, Settings } from 'lucide-react'
|
import { Pencil, Trash2, Plus, Save, X, ArrowLeft, Settings } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import ProfileBuilder from '../components/ProfileBuilder'
|
import ProfileBuilder from '../components/ProfileBuilder'
|
||||||
|
import EmojiIconPicker from '../components/EmojiIconPicker'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AdminTrainingTypesPage - CRUD for training types
|
* AdminTrainingTypesPage - CRUD for training types
|
||||||
|
|
@ -254,13 +255,11 @@ export default function AdminTrainingTypesPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="form-label">Icon (Emoji)</div>
|
<div className="form-label">Icon (Emoji)</div>
|
||||||
<input
|
<EmojiIconPicker
|
||||||
className="form-input"
|
|
||||||
value={formData.icon}
|
value={formData.icon}
|
||||||
onChange={e => setFormData({ ...formData, icon: e.target.value })}
|
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||||
placeholder="🏃"
|
placeholder="🏃"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -495,13 +494,11 @@ export default function AdminTrainingTypesPage() {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="form-label">Icon (Emoji)</div>
|
<div className="form-label">Icon (Emoji)</div>
|
||||||
<input
|
<EmojiIconPicker
|
||||||
className="form-input"
|
|
||||||
value={formData.icon}
|
value={formData.icon}
|
||||||
onChange={e => setFormData({ ...formData, icon: e.target.value })}
|
onChange={(icon) => setFormData({ ...formData, icon })}
|
||||||
placeholder="🏃"
|
placeholder="🏃"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user