mitai-jinkendo/frontend/src/pages/DashboardConfigurePage.jsx
Lars ddc87ba5ae
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: remove deprecated demo route and enhance dashboard widget registration
- Removed outdated visualization demo route and fixed demo layout in the frontend.
- Updated widget registration logic in `frontend/src/widgetSystem/registerDashboardWidgets.js` to ensure proper integration of core widgets.
- Adjusted documentation in `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` and comments in `backend/widget_catalog.py` to reflect changes.
- Added new dashboard widgets for activity and body overview, enhancing user experience and data visualization capabilities.
- Bumped application version to reflect these changes.
2026-04-23 15:24:13 +02:00

753 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronDown, ChevronUp, GripVertical, LayoutDashboard, Plus, Search, X } from 'lucide-react'
import { api, formatFastApiDetail } from '../utils/api'
import { ensureDashboardWidgetsRegistered } from '../widgetSystem/registerDashboardWidgets'
import {
BODY_CHART_DAYS_DEFAULT,
BODY_CHART_DAYS_MAX,
BODY_CHART_DAYS_MIN,
normalizeBodyChartDays,
} from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
import {
moveWidget,
moveWidgetToIndex,
normalizeLayoutForEditor,
toggleWidget,
} from '../widgetSystem/layoutEditor'
const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview',
'body_history_viz',
'activity_overview',
'nutrition_detail_charts',
'nutrition_history_viz',
'fitness_history_viz',
'recovery_history_viz',
'history_overview_viz',
'recovery_charts_panel',
])
const DESKTOP_DND_MQ = '(min-width: 768px)'
function catalogMetaById(catalog) {
if (!catalog?.widgets?.length) return {}
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
}
/**
* @param {{ adminMode?: boolean }} [props]
*/
export default function DashboardConfigurePage({ adminMode = false } = {}) {
ensureDashboardWidgetsRegistered()
const [bundle, setBundle] = useState(null)
const [adminFromDatabase, setAdminFromDatabase] = useState(null)
const [catalog, setCatalog] = useState(null)
const [layout, setLayout] = useState(null)
const [addPanelOpen, setAddPanelOpen] = useState(false)
const [pickerSearch, setPickerSearch] = useState('')
const [viewportDesktop, setViewportDesktop] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(DESKTOP_DND_MQ).matches : false
)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const [err, setErr] = useState(null)
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
const [dragOverFullIndex, setDragOverFullIndex] = useState(null)
const pickerSearchRef = useRef(null)
const dndEnabled = viewportDesktop
useEffect(() => {
const mq = window.matchMedia(DESKTOP_DND_MQ)
const fn = () => setViewportDesktop(mq.matches)
fn()
mq.addEventListener('change', fn)
return () => mq.removeEventListener('change', fn)
}, [])
useEffect(() => {
if (!addPanelOpen) return
const t = window.setTimeout(() => pickerSearchRef.current?.focus(), 50)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e) => {
if (e.key === 'Escape') setAddPanelOpen(false)
}
window.addEventListener('keydown', onKey)
return () => {
window.clearTimeout(t)
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [addPanelOpen])
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
const isWidgetCatalogAllowed = useCallback(
(widgetId) => {
const m = metaById[widgetId]
if (m == null) return true
return m.allowed !== false
},
[metaById],
)
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
const clamped = normalizeBodyChartDays(
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
)
return {
...baseLayout,
widgets: baseLayout.widgets.map((x) =>
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
),
}
}, [])
const load = useCallback(async () => {
setErr(null)
try {
if (adminMode) {
const [cat, d] = await Promise.all([
api.adminGetWidgetsCatalogFull(),
api.adminGetDashboardProductDefault(),
])
setCatalog(cat)
setBundle({ custom: false, product_default_layout: d.layout })
setAdminFromDatabase(!!d.from_database)
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(structuredClone(d.layout)))
} else {
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
setCatalog(cat)
setBundle(b)
setAdminFromDatabase(null)
setChartDaysDraftByWidgetId({})
const base = b.custom ? b.layout : structuredClone(b.product_default_layout)
setLayout(normalizeLayoutForEditor(base))
}
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
}
}, [adminMode])
useEffect(() => {
load()
}, [load])
const openAddPanel = () => {
setPickerSearch('')
setAddPanelOpen(true)
}
const save = async () => {
if (!layout) return
let toSave = layout
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
if (draftEntries.length) {
for (const [wid, val] of draftEntries) {
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
}
setLayout(toSave)
setChartDaysDraftByWidgetId({})
}
setBusy(true)
setMsg(null)
setErr(null)
try {
if (adminMode) {
await api.adminPutDashboardProductDefault(toSave)
setMsg('System-Standard für die Übersicht wurde gespeichert.')
} else {
await api.putAppDashboardLayout(toSave)
setMsg('Dein Dashboard wurde gespeichert.')
}
await load()
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const resetToSystem = async () => {
const ok = adminMode
? window.confirm(
'Eintrag in der Datenbank löschen und Layout aus dem Code (widget_catalog) wiederherstellen?'
)
: window.confirm('Dein individuelles Layout löschen und System-Standard wiederherstellen?')
if (!ok) return
setBusy(true)
setMsg(null)
setErr(null)
try {
if (adminMode) {
const r = await api.adminDeleteDashboardProductDefault()
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(r.layout))
setMsg('Code-Standard wiederhergestellt (kein DB-Override mehr).')
} else {
const r = await api.resetAppDashboardLayout()
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(r.layout))
setMsg('Auf System-Standard zurückgesetzt.')
}
await load()
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const pickerLower = pickerSearch.trim().toLowerCase()
const libraryIndices = useMemo(() => {
if (!layout?.widgets) return []
return layout.widgets
.map((w, i) => i)
.filter((i) => {
const w = layout.widgets[i]
if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false
if (!pickerLower) return true
const m = metaById[w.id]
const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase()
return hay.includes(pickerLower)
})
}, [layout, pickerLower, metaById, isWidgetCatalogAllowed])
const activeIndices = useMemo(() => {
if (!layout?.widgets) return []
return layout.widgets
.map((w, i) => i)
.filter((i) => layout.widgets[i].enabled && isWidgetCatalogAllowed(layout.widgets[i].id))
}, [layout, isWidgetCatalogAllowed])
const addableCount = useMemo(() => {
if (!layout?.widgets) return 0
return layout.widgets.filter((w) => !w.enabled && isWidgetCatalogAllowed(w.id)).length
}, [layout, isWidgetCatalogAllowed])
const onDragStartRow = (e, fullIndex) => {
if (!dndEnabled) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(fullIndex))
try {
e.dataTransfer.setDragImage(e.currentTarget, 0, 0)
} catch {
/* ok */
}
}
const onDragOverRow = (e, fullIndex) => {
if (!dndEnabled) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverFullIndex(fullIndex)
}
const onDragLeaveRow = () => {
setDragOverFullIndex(null)
}
const onDropRow = (e, dropFullIndex) => {
if (!dndEnabled) return
e.preventDefault()
setDragOverFullIndex(null)
const raw = e.dataTransfer.getData('text/plain')
const from = Number.parseInt(raw, 10)
if (!Number.isFinite(from)) return
setLayout((L) => normalizeLayoutForEditor(moveWidgetToIndex(L, from, dropFullIndex)))
}
const onDragEndRow = () => {
setDragOverFullIndex(null)
}
if (err && !layout) {
return (
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
<p style={{ color: '#D85A30' }}>{err}</p>
<button type="button" className="btn btn-secondary" onClick={load}>
Erneut laden
</button>
</div>
)
}
if (!layout) {
return (
<div style={{ padding: 48, textAlign: 'center' }}>
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
</div>
)
}
return (
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 720, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<Link
to={adminMode ? '/admin/g/system' : '/settings'}
className="btn btn-secondary"
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
>
{adminMode ? '← Basiseinstellungen (Admin)' : '← Einstellungen'}
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<LayoutDashboard size={26} color="var(--accent)" />
{adminMode ? 'Produkt-Übersicht: Systemstandard' : 'Übersicht anpassen'}
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
{adminMode
? 'Globales Standard-Dashboard für alle Nutzer ohne eigenes Layout. Gespeichert in der Datenbank; mit „Code-Standard wiederherstellen“ wird der Eintrag entfernt und der Fallback aus dem Code genutzt.'
: 'Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über „Kachel hinzufügen“ mit Suche direkt im eigenen Fenster, ohne langes Scrollen.'}
</p>
{adminMode && adminFromDatabase != null && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
{adminFromDatabase ? (
<>
Aktuell gilt ein <strong>gespeicherter Systemstandard</strong> (Datenbank).
</>
) : (
<>
Es liegt <strong>kein DB-Override</strong> vor es wird der Code-Standard aus dem Widget-Katalog
verwendet.
</>
)}
</p>
)}
{!adminMode && !bundle?.custom && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
Du bearbeitest gerade das <strong>System-Standardlayout</strong>. Mit Speichern legst du deine persönliche
Version ab.
</p>
)}
</div>
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div className="card-title" style={{ fontSize: 15, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span>
Aktive Kacheln · {activeIndices.length}
{dndEnabled && (
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text3)', marginLeft: 8 }}>
(ab Desktop: ziehen oder Pfeile)
</span>
)}
</span>
<button type="button" className="btn btn-primary" style={{ fontSize: 13 }} onClick={openAddPanel} disabled={addableCount === 0}>
<Plus size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
Kachel hinzufügen{addableCount > 0 ? ` (${addableCount})` : ''}
</button>
</div>
{addableCount === 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 0 }}>
Alle freigeschalteten Kacheln sind aktiv.
</p>
)}
{err && <p style={{ fontSize: 12, color: '#D85A30', marginTop: 12, marginBottom: 0 }}>{err}</p>}
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 12, marginBottom: 0 }}>{msg}</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0' }}>
{activeIndices.map((i) => {
const w = layout.widgets[i]
const label = metaById[w.id]?.title || w.id
const chartDaysVal =
w.config?.chart_days != null
? normalizeBodyChartDays(w.config.chart_days)
: BODY_CHART_DAYS_DEFAULT
const dragOver = dragOverFullIndex === i
return (
<li
key={w.id}
onDragOver={(e) => onDragOverRow(e, i)}
onDragLeave={onDragLeaveRow}
onDrop={(e) => onDropRow(e, i)}
style={{
padding: '10px 0',
borderBottom: '1px solid var(--border)',
background: dragOver ? 'var(--surface2)' : undefined,
borderRadius: dragOver ? 8 : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{dndEnabled && (
<span
draggable
role="button"
tabIndex={0}
aria-label={`${label} verschieben`}
style={{ cursor: 'grab', color: 'var(--text3)', display: 'flex', touchAction: 'none' }}
onDragStart={(e) => onDragStartRow(e, i)}
onDragEnd={onDragEndRow}
>
<GripVertical size={18} />
</span>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px' }}>
<input type="checkbox" checked={w.enabled} onChange={() => setLayout((L) => toggleWidget(L, i))} />
<span style={{ fontSize: 14 }}>{label}</span>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
aria-label="Nach oben"
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
>
<ChevronUp size={18} />
</button>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
aria-label="Nach unten"
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
>
<ChevronDown size={18} />
</button>
</div>
</div>
{w.id === 'quick_capture' && (
<QuickCaptureConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
const cfg = { ...(x.config || {}) }
for (const k of ['show_weight', 'show_resting_hr', 'show_hrv', 'show_vo2_max']) {
delete cfg[k]
}
Object.assign(cfg, next)
return { ...x, config: cfg }
}),
})
)
}
/>
)}
{w.id === 'kpi_board' && (
<KpiBoardConfigEditor
tiles={Object.prototype.hasOwnProperty.call(w.config || {}, 'tiles') ? w.config.tiles : undefined}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
const cfg = { ...(x.config || {}) }
if (next === undefined) {
delete cfg.tiles
} else {
cfg.tiles = next
}
return { ...x, config: cfg }
}),
})
)
}
/>
)}
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label>
<input
type="text"
inputMode="numeric"
autoComplete="off"
className="form-input"
style={{ maxWidth: 120 }}
value={
chartDaysDraftByWidgetId[w.id] !== undefined
? chartDaysDraftByWidgetId[w.id]
: String(chartDaysVal)
}
onFocus={() =>
setChartDaysDraftByWidgetId((prev) => ({
...prev,
[w.id]: String(chartDaysVal),
}))
}
onChange={(e) =>
setChartDaysDraftByWidgetId((prev) => ({
...prev,
[w.id]: e.target.value,
}))
}
onBlur={(e) => {
const raw = e.target.value
setLayout((L) =>
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
)
setChartDaysDraftByWidgetId((prev) => {
const next = { ...prev }
delete next[w.id]
return next
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur()
}}
/>
</div>
)}
{w.id === 'body_history_viz' && (
<BodyHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'nutrition_history_viz' && (
<NutritionHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'fitness_history_viz' && (
<FitnessHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'recovery_history_viz' && (
<RecoveryHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'history_overview_viz' && (
<HistoryOverviewVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
</li>
)
})}
</ul>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
Speichern
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={resetToSystem}>
{adminMode ? 'Code-Standard wiederherstellen' : 'System-Standard wiederherstellen'}
</button>
<Link
to={adminMode ? '/admin' : '/'}
className="btn btn-secondary"
style={{ textDecoration: 'none', textAlign: 'center' }}
>
{adminMode ? 'Admin-Übersicht' : 'Zur Übersicht'}
</Link>
</div>
{addPanelOpen && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 300,
display: 'flex',
alignItems: viewportDesktop ? 'center' : 'flex-end',
justifyContent: 'center',
padding: viewportDesktop ? 16 : 0,
paddingBottom: viewportDesktop ? 16 : undefined,
}}
>
<button
type="button"
aria-label="Schließen"
style={{
position: 'absolute',
inset: 0,
border: 'none',
padding: 0,
margin: 0,
background: 'rgba(0,0,0,0.45)',
cursor: 'pointer',
}}
onClick={() => setAddPanelOpen(false)}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="dashboard-add-widget-title"
onClick={(e) => e.stopPropagation()}
style={{
position: 'relative',
width: '100%',
maxWidth: viewportDesktop ? 520 : '100%',
maxHeight: viewportDesktop ? 'min(85vh, 640px)' : 'min(92vh, 100%)',
background: 'var(--surface)',
borderRadius: viewportDesktop ? 12 : '16px 16px 0 0',
boxShadow: viewportDesktop ? '0 12px 40px rgba(0,0,0,0.2)' : '0 -4px 24px rgba(0,0,0,0.12)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
paddingBottom: 'max(12px, env(safe-area-inset-bottom))',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
<h2 id="dashboard-add-widget-title" className="card-title" style={{ fontSize: 16, margin: 0 }}>
Kachel hinzufügen
</h2>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '8px 10px' }}
aria-label="Schließen"
onClick={() => setAddPanelOpen(false)}
>
<X size={20} />
</button>
</div>
<div style={{ padding: '12px 16px', flexShrink: 0, borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Search size={18} color="var(--text3)" />
<input
ref={pickerSearchRef}
type="search"
className="form-input"
placeholder="Suchen nach Titel, Beschreibung, ID …"
value={pickerSearch}
onChange={(e) => setPickerSearch(e.target.value)}
aria-label="Widgets durchsuchen"
style={{ flex: 1 }}
/>
</div>
</div>
<div style={{ overflowY: 'auto', flex: 1, padding: '8px 16px 16px' }}>
{libraryIndices.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 12 }}>
{pickerLower ? 'Keine Treffer.' : 'Keine weiteren Kacheln verfügbar.'}
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{libraryIndices.map((i) => {
const w = layout.widgets[i]
const m = metaById[w.id]
return (
<li
key={w.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '12px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{m?.title || w.id}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>{m?.description || ''}</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{ flexShrink: 0, fontSize: 12, padding: '8px 14px' }}
onClick={() => {
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => (j === i ? { ...x, enabled: true } : x)),
})
)
}}
>
Hinzufügen
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}