- 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.
753 lines
29 KiB
JavaScript
753 lines
29 KiB
JavaScript
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>
|
||
)
|
||
}
|