feat: Enhance DashboardConfigurePage with drag-and-drop functionality and improved widget search
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Added drag-and-drop support for widget reordering in the dashboard configuration.
- Introduced a new search input for filtering widgets, enhancing user experience.
- Updated layout editor with a new function to move widgets between indices.
- Improved responsiveness by implementing viewport detection for drag-and-drop features.
- Refactored state management for better handling of widget visibility and search functionality.
This commit is contained in:
Lars 2026-04-08 07:58:52 +02:00
parent e4e2f23d7f
commit ff95ef63c7
2 changed files with 286 additions and 84 deletions

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ChevronDown, ChevronUp, LayoutDashboard, Search } from 'lucide-react' import { ChevronDown, ChevronUp, GripVertical, LayoutDashboard, Plus, Search, X } from 'lucide-react'
import { api, formatFastApiDetail } from '../utils/api' import { api, formatFastApiDetail } from '../utils/api'
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets' import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
import { import {
@ -11,7 +11,12 @@ import {
} from '../widgetSystem/bodyChartDays' } from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' import {
moveWidget,
moveWidgetToIndex,
normalizeLayoutForEditor,
toggleWidget,
} from '../widgetSystem/layoutEditor'
const CHART_DAYS_WIDGET_IDS = new Set([ const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview', 'body_overview',
@ -20,6 +25,8 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'recovery_charts_panel', 'recovery_charts_panel',
]) ])
const DESKTOP_DND_MQ = '(min-width: 768px)'
function catalogMetaById(catalog) { function catalogMetaById(catalog) {
if (!catalog?.widgets?.length) return {} if (!catalog?.widgets?.length) return {}
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w])) return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
@ -31,11 +38,44 @@ export default function DashboardConfigurePage() {
const [bundle, setBundle] = useState(null) const [bundle, setBundle] = useState(null)
const [catalog, setCatalog] = useState(null) const [catalog, setCatalog] = useState(null)
const [layout, setLayout] = useState(null) const [layout, setLayout] = useState(null)
const [search, setSearch] = useState('') 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 [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null) const [msg, setMsg] = useState(null)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({}) 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 metaById = useMemo(() => catalogMetaById(catalog), [catalog])
@ -78,6 +118,11 @@ export default function DashboardConfigurePage() {
load() load()
}, [load]) }, [load])
const openAddPanel = () => {
setPickerSearch('')
setAddPanelOpen(true)
}
const save = async () => { const save = async () => {
if (!layout) return if (!layout) return
let toSave = layout let toSave = layout
@ -121,7 +166,7 @@ export default function DashboardConfigurePage() {
} }
} }
const searchLower = search.trim().toLowerCase() const pickerLower = pickerSearch.trim().toLowerCase()
const libraryIndices = useMemo(() => { const libraryIndices = useMemo(() => {
if (!layout?.widgets) return [] if (!layout?.widgets) return []
@ -130,12 +175,12 @@ export default function DashboardConfigurePage() {
.filter((i) => { .filter((i) => {
const w = layout.widgets[i] const w = layout.widgets[i]
if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false
if (!searchLower) return true if (!pickerLower) return true
const m = metaById[w.id] const m = metaById[w.id]
const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase() const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase()
return hay.includes(searchLower) return hay.includes(pickerLower)
}) })
}, [layout, searchLower, metaById, isWidgetCatalogAllowed]) }, [layout, pickerLower, metaById, isWidgetCatalogAllowed])
const activeIndices = useMemo(() => { const activeIndices = useMemo(() => {
if (!layout?.widgets) return [] if (!layout?.widgets) return []
@ -144,6 +189,47 @@ export default function DashboardConfigurePage() {
.filter((i) => layout.widgets[i].enabled && isWidgetCatalogAllowed(layout.widgets[i].id)) .filter((i) => layout.widgets[i].enabled && isWidgetCatalogAllowed(layout.widgets[i].id))
}, [layout, isWidgetCatalogAllowed]) }, [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) { if (err && !layout) {
return ( return (
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}> <div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
@ -178,8 +264,8 @@ export default function DashboardConfigurePage() {
Übersicht anpassen Übersicht anpassen
</h1> </h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}> <p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
Wähle Kacheln für deine Startseite. Änderungen gelten nur für dein Profil der System-Standard bleibt erhalten, Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über Kachel hinzufügen mit Suche direkt
bis du speicherst. Gesperrte Kacheln (Abonnement) erscheinen nicht in der Auswahl. im eigenen Fenster, ohne langes Scrollen.
</p> </p>
{!bundle?.custom && ( {!bundle?.custom && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}> <p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
@ -190,30 +276,28 @@ export default function DashboardConfigurePage() {
</div> </div>
<div className="card section-gap" style={{ marginBottom: 16 }}> <div className="card section-gap" style={{ marginBottom: 16 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 6 }}> <div className="card-title" style={{ fontSize: 15, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
Widgets durchsuchen (Bibliothek unten) <span>
</label> Aktive Kacheln · {activeIndices.length}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> {dndEnabled && (
<Search size={18} color="var(--text3)" style={{ flexShrink: 0 }} /> <span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text3)', marginLeft: 8 }}>
<input (ab Desktop: ziehen oder Pfeile)
type="search" </span>
className="form-input" )}
placeholder="Titel, Beschreibung oder ID …" </span>
value={search} <button type="button" className="btn btn-primary" style={{ fontSize: 13 }} onClick={openAddPanel} disabled={addableCount === 0}>
onChange={(e) => setSearch(e.target.value)} <Plus size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
aria-label="Widgets durchsuchen" Kachel hinzufügen{addableCount > 0 ? ` (${addableCount})` : ''}
style={{ flex: 1 }} </button>
/>
</div> </div>
</div> {addableCount === 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 0 }}>
<div className="card section-gap" style={{ marginBottom: 16 }}> Alle freigeschalteten Kacheln sind aktiv.
<div className="card-title" style={{ fontSize: 15 }}> </p>
Auf der Übersicht aktiv · {activeIndices.length} )}
</div> {err && <p style={{ fontSize: 12, color: '#D85A30', marginTop: 12, marginBottom: 0 }}>{err}</p>}
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>} {msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 12, marginBottom: 0 }}>{msg}</p>}
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>} <ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0' }}>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{activeIndices.map((i) => { {activeIndices.map((i) => {
const w = layout.widgets[i] const w = layout.widgets[i]
const label = metaById[w.id]?.title || w.id const label = metaById[w.id]?.title || w.id
@ -221,15 +305,34 @@ export default function DashboardConfigurePage() {
w.config?.chart_days != null w.config?.chart_days != null
? normalizeBodyChartDays(w.config.chart_days) ? normalizeBodyChartDays(w.config.chart_days)
: BODY_CHART_DAYS_DEFAULT : BODY_CHART_DAYS_DEFAULT
const dragOver = dragOverFullIndex === i
return ( return (
<li <li
key={w.id} key={w.id}
onDragOver={(e) => onDragOverRow(e, i)}
onDragLeave={onDragLeaveRow}
onDrop={(e) => onDropRow(e, i)}
style={{ style={{
padding: '10px 0', padding: '10px 0',
borderBottom: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
background: dragOver ? 'var(--surface2)' : undefined,
borderRadius: dragOver ? 8 : undefined,
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <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' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px' }}>
<input type="checkbox" checked={w.enabled} onChange={() => setLayout((L) => toggleWidget(L, i))} /> <input type="checkbox" checked={w.enabled} onChange={() => setLayout((L) => toggleWidget(L, i))} />
<span style={{ fontSize: 14 }}>{label}</span> <span style={{ fontSize: 14 }}>{label}</span>
@ -349,57 +452,6 @@ export default function DashboardConfigurePage() {
</ul> </ul>
</div> </div>
<div className="card section-gap" style={{ marginBottom: 20 }}>
<div className="card-title" style={{ fontSize: 15 }}>
Bibliothek · hinzufügen
</div>
{libraryIndices.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>
{searchLower ? 'Keine passenden inaktiven Widgets.' : 'Alle verfügbaren Kacheln sind schon aktiv.'}
</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: '10px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div>
<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 style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}> <button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
Speichern Speichern
@ -411,6 +463,142 @@ export default function DashboardConfigurePage() {
Zur Übersicht Zur Übersicht
</Link> </Link>
</div> </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> </div>
) )
} }

View File

@ -25,3 +25,17 @@ export function toggleWidget(layout, index) {
if (!anyOn) return layout if (!anyOn) return layout
return { ...layout, widgets: next } return { ...layout, widgets: next }
} }
/**
* Verschiebt eine Zeile von fromIndex nach dropIndex (Indizes im vollen layout.widgets).
* Semantik wie üblich: Element landet an Position dropIndex (nach Entfernen an der alten Stelle).
*/
export function moveWidgetToIndex(layout, fromIndex, dropIndex) {
if (!layout?.widgets?.length) return layout
if (fromIndex < 0 || fromIndex >= layout.widgets.length) return layout
if (dropIndex < 0 || dropIndex > layout.widgets.length) return layout
if (fromIndex === dropIndex) return layout
const next = [...layout.widgets]
next.splice(dropIndex, 0, next.splice(fromIndex, 1)[0])
return { ...layout, widgets: next }
}