From ff95ef63c73908a4284858723cc6460f4d5a2a62 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 8 Apr 2026 07:58:52 +0200 Subject: [PATCH] feat: Enhance DashboardConfigurePage with drag-and-drop functionality and improved widget search - 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. --- frontend/src/pages/DashboardConfigurePage.jsx | 356 +++++++++++++----- frontend/src/widgetSystem/layoutEditor.js | 14 + 2 files changed, 286 insertions(+), 84 deletions(-) diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index ed81efb..acbc81f 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -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 { 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 { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets' import { @@ -11,7 +11,12 @@ import { } from '../widgetSystem/bodyChartDays' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' 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([ 'body_overview', @@ -20,6 +25,8 @@ const CHART_DAYS_WIDGET_IDS = new Set([ '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])) @@ -31,11 +38,44 @@ export default function DashboardConfigurePage() { const [bundle, setBundle] = useState(null) const [catalog, setCatalog] = 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 [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]) @@ -78,6 +118,11 @@ export default function DashboardConfigurePage() { load() }, [load]) + const openAddPanel = () => { + setPickerSearch('') + setAddPanelOpen(true) + } + const save = async () => { if (!layout) return let toSave = layout @@ -121,7 +166,7 @@ export default function DashboardConfigurePage() { } } - const searchLower = search.trim().toLowerCase() + const pickerLower = pickerSearch.trim().toLowerCase() const libraryIndices = useMemo(() => { if (!layout?.widgets) return [] @@ -130,12 +175,12 @@ export default function DashboardConfigurePage() { .filter((i) => { const w = layout.widgets[i] if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false - if (!searchLower) return true + if (!pickerLower) return true const m = metaById[w.id] 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(() => { if (!layout?.widgets) return [] @@ -144,6 +189,47 @@ export default function DashboardConfigurePage() { .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 (
@@ -178,8 +264,8 @@ export default function DashboardConfigurePage() { Übersicht anpassen

- Wähle Kacheln für deine Startseite. Änderungen gelten nur für dein Profil – der System-Standard bleibt erhalten, - bis du speicherst. Gesperrte Kacheln (Abonnement) erscheinen nicht in der Auswahl. + Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über „Kachel hinzufügen“ – mit Suche direkt + im eigenen Fenster, ohne langes Scrollen.

{!bundle?.custom && (

@@ -190,30 +276,28 @@ export default function DashboardConfigurePage() {

- -
- - setSearch(e.target.value)} - aria-label="Widgets durchsuchen" - style={{ flex: 1 }} - /> +
+ + Aktive Kacheln · {activeIndices.length} + {dndEnabled && ( + + (ab Desktop: ziehen oder Pfeile) + + )} + +
-
- -
-
- Auf der Übersicht aktiv · {activeIndices.length} -
- {err &&

{err}

} - {msg &&

{msg}

} -
    + {addableCount === 0 && ( +

    + Alle freigeschalteten Kacheln sind aktiv. +

    + )} + {err &&

    {err}

    } + {msg &&

    {msg}

    } +
      {activeIndices.map((i) => { const w = layout.widgets[i] const label = metaById[w.id]?.title || w.id @@ -221,15 +305,34 @@ export default function DashboardConfigurePage() { w.config?.chart_days != null ? normalizeBodyChartDays(w.config.chart_days) : BODY_CHART_DAYS_DEFAULT + const dragOver = dragOverFullIndex === i return (
    • 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, }} >
      + {dndEnabled && ( + onDragStartRow(e, i)} + onDragEnd={onDragEndRow} + > + + + )}
-
-
- Bibliothek · hinzufügen -
- {libraryIndices.length === 0 ? ( -

- {searchLower ? 'Keine passenden inaktiven Widgets.' : 'Alle verfügbaren Kacheln sind schon aktiv.'} -

- ) : ( -
    - {libraryIndices.map((i) => { - const w = layout.widgets[i] - const m = metaById[w.id] - return ( -
  • -
    -
    {m?.title || w.id}
    -
    {m?.description || ''}
    -
    - -
  • - ) - })} -
- )} -
-
+ + {addPanelOpen && ( +
+ +
+
+
+ + setPickerSearch(e.target.value)} + aria-label="Widgets durchsuchen" + style={{ flex: 1 }} + /> +
+
+
+ {libraryIndices.length === 0 ? ( +

+ {pickerLower ? 'Keine Treffer.' : 'Keine weiteren Kacheln verfügbar.'} +

+ ) : ( +
    + {libraryIndices.map((i) => { + const w = layout.widgets[i] + const m = metaById[w.id] + return ( +
  • +
    +
    {m?.title || w.id}
    +
    {m?.description || ''}
    +
    + +
  • + ) + })} +
+ )} +
+
+ + )} ) } diff --git a/frontend/src/widgetSystem/layoutEditor.js b/frontend/src/widgetSystem/layoutEditor.js index d866480..8a4d00a 100644 --- a/frontend/src/widgetSystem/layoutEditor.js +++ b/frontend/src/widgetSystem/layoutEditor.js @@ -25,3 +25,17 @@ export function toggleWidget(layout, index) { if (!anyOn) return layout 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 } +}