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 { 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user