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.
This commit is contained in:
parent
e4e2f23d7f
commit
ff95ef63c7
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user