teg/frontend/src/pages/DashboardPage.tsx
2026-05-22 14:11:18 +02:00

1136 lines
32 KiB
TypeScript

// frontend/src/pages/DashboardPage.tsx
import { useEffect, useMemo, useRef, useState, type PointerEvent } from 'react'
import {
CheckIcon,
Cog6ToothIcon,
PencilSquareIcon,
TrashIcon,
} from '@heroicons/react/20/solid'
import { Squares2X2Icon } from '@heroicons/react/24/outline'
import WidgetSettingsModal from '../components/dashboard/WidgetSettingsModal'
import Button from '../components/Button'
import LoadingSpinner from '../components/LoadingSpinner'
import DashboardWidgetRenderer from '../components/dashboard/DashboardWidgetRenderer'
import WidgetPickerModal from '../components/dashboard/WidgetPickerModal'
import type {
DashboardWidget,
DashboardWidgetDefinition,
DashboardWidgetType,
} from '../components/dashboard/types'
import EmptyState from '../components/EmptyState'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
const GRID_COLUMNS = 12
const GRID_ROW_HEIGHT = 88
const GRID_GAP = 16
const GRID_PADDING = 16
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
type GridPlacement = {
x: number
y: number
w: number
h: number
}
type GridCell = {
x: number
y: number
}
type DashboardInteraction =
| {
type: 'move'
pointerId: number
widgetId: string
offsetX: number
offsetY: number
}
| {
type: 'resize'
pointerId: number
widgetId: string
direction: ResizeDirection
startClientX: number
startClientY: number
startWidget: DashboardWidget
}
| {
type: 'draw'
pointerId: number
startCell: GridCell
}
function getResizeHandleClass(direction: ResizeDirection) {
switch (direction) {
case 'nw':
return '-top-1.5 -left-1.5 cursor-nwse-resize'
case 'ne':
return '-top-1.5 -right-1.5 cursor-nesw-resize'
case 'sw':
return '-bottom-1.5 -left-1.5 cursor-nesw-resize'
case 'se':
return '-right-1.5 -bottom-1.5 cursor-nwse-resize'
}
}
function hasWidgetChanged(widget: DashboardWidget, nextWidget: DashboardWidget) {
return (
widget.x !== nextWidget.x ||
widget.y !== nextWidget.y ||
widget.w !== nextWidget.w ||
widget.h !== nextWidget.h
)
}
type DashboardPageProps = {
username: string
}
type DashboardSettings = {
isEditingDashboard?: boolean
}
const widgetTypes: DashboardWidgetDefinition[] = [
{
id: 'operationChanges',
label: 'Letzte Einsatzänderungen',
description: 'Zeigt die letzten Änderungen an Einsätzen ohne Journal-Einträge.',
defaultTitle: 'Letzte Einsatzänderungen',
defaultSize: {
w: 6,
h: 4,
},
defaultConfig: {
limit: 8,
},
},
{
id: 'operationMap',
label: 'Einsatzkarte',
description: 'Zeigt Einsatzorte auf einer Karte inklusive KW und Zielobjekt.',
defaultTitle: 'Einsatzkarte',
defaultSize: {
w: 8,
h: 5,
},
defaultConfig: {
defaultLayer: 'nrw-dop',
showLabelsOverlay: true,
showLocateControl: false,
showCurrentLocationMarker: true,
initialAddress: 'Tiefenbroicher Weg 32, 40472 Düsseldorf',
initialZoom: 14,
},
},
]
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function widgetsCollide(a: DashboardWidget, b: DashboardWidget) {
return !(
a.x + a.w <= b.x ||
b.x + b.w <= a.x ||
a.y + a.h <= b.y ||
b.y + b.h <= a.y
)
}
function findFreePosition(
widgets: DashboardWidget[],
candidate: DashboardWidget,
) {
let nextWidget = {
...candidate,
x: clamp(candidate.x, 0, GRID_COLUMNS - candidate.w),
y: Math.max(0, candidate.y),
}
while (
widgets.some(
(widget) =>
widget.id !== nextWidget.id && widgetsCollide(widget, nextWidget),
)
) {
nextWidget = {
...nextWidget,
y: nextWidget.y + 1,
}
}
return nextWidget
}
function getNextY(widgets: DashboardWidget[]) {
if (widgets.length === 0) {
return 0
}
return Math.max(...widgets.map((widget) => widget.y + widget.h))
}
function getWidgetTypeDefinition(type: DashboardWidgetType) {
return widgetTypes.find((widgetType) => widgetType.id === type) ?? widgetTypes[0]
}
export default function DashboardPage({ username }: DashboardPageProps) {
const [widgets, setWidgets] = useState<DashboardWidget[]>([])
const [isEditingDashboard, setIsEditingDashboard] = useState(false)
const [isWidgetPickerOpen, setIsWidgetPickerOpen] = useState(false)
const [pendingWidgetPlacement, setPendingWidgetPlacement] = useState<GridPlacement | null>(null)
const [drawPlacement, setDrawPlacement] = useState<GridPlacement | null>(null)
const [activeWidgetId, setActiveWidgetId] = useState<string | null>(null)
const [previewWidget, setPreviewWidget] = useState<DashboardWidget | null>(null)
const [settingsWidget, setSettingsWidget] = useState<DashboardWidget | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>({})
const gridRef = useRef<HTMLDivElement | null>(null)
const interactionRef = useRef<DashboardInteraction | null>(null)
const previewWidgetRef = useRef<DashboardWidget | null>(null)
const drawPlacementRef = useRef<GridPlacement | null>(null)
useEffect(() => {
drawPlacementRef.current = drawPlacement
}, [drawPlacement])
useEffect(() => {
previewWidgetRef.current = previewWidget
}, [previewWidget])
const dashboardRows = useMemo(() => {
return Math.max(
4,
...widgets.map((widget) => widget.y + widget.h),
drawPlacement ? drawPlacement.y + drawPlacement.h : 0,
previewWidget ? previewWidget.y + previewWidget.h : 0,
)
}, [drawPlacement, previewWidget, widgets])
const dashboardHeight = useMemo(() => {
return dashboardRows * GRID_ROW_HEIGHT + Math.max(0, dashboardRows - 1) * GRID_GAP
}, [dashboardRows])
useEffect(() => {
void loadDashboard()
}, [])
async function loadWidgets() {
setError(null)
try {
const response = await fetch(`${API_URL}/dashboard/widgets`, {
credentials: 'include',
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Dashboard konnte nicht geladen werden')
}
const data = await response.json()
setWidgets(data.widgets ?? [])
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Dashboard konnte nicht geladen werden',
)
}
}
async function loadDashboard() {
setIsLoading(true)
setError(null)
try {
await Promise.all([
loadWidgets(),
loadDashboardSettings(),
])
} finally {
setIsLoading(false)
}
}
async function loadDashboardSettings() {
try {
const response = await fetch(`${API_URL}/dashboard/settings`, {
credentials: 'include',
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(
errorData?.error ?? 'Dashboard-Einstellungen konnten nicht geladen werden',
)
}
const data = await response.json()
const settings = data.dashboardSettings?.settings ?? {}
setDashboardSettings(settings)
if (typeof settings.isEditingDashboard === 'boolean') {
setIsEditingDashboard(settings.isEditingDashboard)
}
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Dashboard-Einstellungen konnten nicht geladen werden',
)
}
}
async function saveDashboardSettings(nextSettings: DashboardSettings) {
setDashboardSettings(nextSettings)
const response = await fetch(`${API_URL}/dashboard/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
settings: nextSettings,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(
errorData?.error ?? 'Dashboard-Einstellungen konnten nicht gespeichert werden',
)
}
const data = await response.json()
setDashboardSettings(data.dashboardSettings?.settings ?? nextSettings)
}
async function saveWidget(widget: DashboardWidget) {
const response = await fetch(`${API_URL}/dashboard/widgets/${widget.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
type: widget.type,
title: widget.title,
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
config: widget.config,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Widget konnte nicht gespeichert werden')
}
const data = await response.json()
return data.widget as DashboardWidget
}
async function createWidget(widgetTypeId: DashboardWidgetType) {
const widgetType = getWidgetTypeDefinition(widgetTypeId)
const placement = pendingWidgetPlacement
setIsSaving(true)
setError(null)
try {
const response = await fetch(`${API_URL}/dashboard/widgets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
type: widgetType.id,
title: widgetType.defaultTitle,
x: placement?.x ?? 0,
y: placement?.y ?? getNextY(widgets),
w: placement?.w ?? widgetType.defaultSize.w,
h: placement?.h ?? widgetType.defaultSize.h,
config: widgetType.defaultConfig,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Widget konnte nicht erstellt werden')
}
const data = await response.json()
setWidgets((currentWidgets) => [...currentWidgets, data.widget])
setPendingWidgetPlacement(null)
setIsWidgetPickerOpen(false)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Widget konnte nicht erstellt werden',
)
} finally {
setIsSaving(false)
}
}
async function updateWidget(nextWidget: DashboardWidget) {
const adjustedWidget = findFreePosition(
widgets.filter((widget) => widget.id !== nextWidget.id),
nextWidget,
)
setWidgets((currentWidgets) =>
currentWidgets.map((widget) =>
widget.id === adjustedWidget.id ? adjustedWidget : widget,
),
)
try {
const savedWidget = await saveWidget(adjustedWidget)
setWidgets((currentWidgets) =>
currentWidgets.map((widget) =>
widget.id === savedWidget.id ? savedWidget : widget,
),
)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Widget konnte nicht gespeichert werden',
)
void loadWidgets()
}
}
async function updateWidgetSettings(
widget: DashboardWidget,
nextValues: {
title: string
config: Record<string, unknown>
},
) {
const nextWidget: DashboardWidget = {
...widget,
title: nextValues.title,
config: nextValues.config,
}
setIsSaving(true)
setError(null)
setWidgets((currentWidgets) =>
currentWidgets.map((currentWidget) =>
currentWidget.id === nextWidget.id ? nextWidget : currentWidget,
),
)
try {
const savedWidget = await saveWidget(nextWidget)
setWidgets((currentWidgets) =>
currentWidgets.map((currentWidget) =>
currentWidget.id === savedWidget.id ? savedWidget : currentWidget,
),
)
setSettingsWidget(null)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Widget-Einstellungen konnten nicht gespeichert werden',
)
void loadWidgets()
} finally {
setIsSaving(false)
}
}
async function deleteWidget(widgetId: string) {
setError(null)
try {
const response = await fetch(`${API_URL}/dashboard/widgets/${widgetId}`, {
method: 'DELETE',
credentials: 'include',
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error ?? 'Widget konnte nicht gelöscht werden')
}
setWidgets((currentWidgets) =>
currentWidgets.filter((widget) => widget.id !== widgetId),
)
} catch (error) {
setError(
error instanceof Error
? error.message
: 'Widget konnte nicht gelöscht werden',
)
}
}
function getGridMetrics() {
if (!gridRef.current) {
return null
}
const rect = gridRef.current.getBoundingClientRect()
const styles = window.getComputedStyle(gridRef.current)
const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0
const paddingTop = Number.parseFloat(styles.paddingTop) || 0
const paddingRight = Number.parseFloat(styles.paddingRight) || 0
const contentLeft = rect.left + paddingLeft
const contentTop = rect.top + paddingTop
const contentWidth = rect.width - paddingLeft - paddingRight
const columnWidth =
(contentWidth - (GRID_COLUMNS - 1) * GRID_GAP) / GRID_COLUMNS
return {
contentLeft,
contentTop,
columnWidth,
}
}
function getGridCellFromPointer(clientX: number, clientY: number): GridCell | null {
const metrics = getGridMetrics()
if (!metrics) {
return null
}
const relativeX = clientX - metrics.contentLeft
const relativeY = clientY - metrics.contentTop
return {
x: clamp(
Math.floor(relativeX / (metrics.columnWidth + GRID_GAP)),
0,
GRID_COLUMNS - 1,
),
y: Math.max(
0,
Math.floor(relativeY / (GRID_ROW_HEIGHT + GRID_GAP)),
),
}
}
function getDrawPlacement(startCell: GridCell, endCell: GridCell): GridPlacement {
const x = Math.min(startCell.x, endCell.x)
const y = Math.min(startCell.y, endCell.y)
const w = clamp(Math.abs(endCell.x - startCell.x) + 1, 2, GRID_COLUMNS - x)
const h = clamp(Math.abs(endCell.y - startCell.y) + 1, 1, 8)
return {
x,
y,
w,
h,
}
}
function handleGridPointerDown(event: PointerEvent<HTMLDivElement>) {
if (!isEditingDashboard) {
return
}
if (event.button !== 0) {
return
}
if (event.target !== event.currentTarget) {
return
}
const startCell = getGridCellFromPointer(event.clientX, event.clientY)
if (!startCell || !gridRef.current) {
return
}
interactionRef.current = {
type: 'draw',
pointerId: event.pointerId,
startCell,
}
const initialPlacement: GridPlacement = {
x: startCell.x,
y: startCell.y,
w: 2,
h: 1,
}
setDrawPlacement(initialPlacement)
gridRef.current.setPointerCapture(event.pointerId)
}
function openWidgetPickerForPlacement(placement: GridPlacement | null) {
setPendingWidgetPlacement(placement)
setIsWidgetPickerOpen(true)
}
function handleWidgetPickerOpenChange(open: boolean) {
setIsWidgetPickerOpen(open)
if (!open) {
setPendingWidgetPlacement(null)
}
}
function getMovedWidget(
widget: DashboardWidget,
clientX: number,
clientY: number,
offsetX: number,
offsetY: number,
) {
const metrics = getGridMetrics()
if (!metrics) {
return widget
}
const relativeX = clientX - metrics.contentLeft - offsetX
const relativeY = clientY - metrics.contentTop - offsetY
const nextX = clamp(
Math.round(relativeX / (metrics.columnWidth + GRID_GAP)),
0,
GRID_COLUMNS - widget.w,
)
const nextY = Math.max(
0,
Math.round(relativeY / (GRID_ROW_HEIGHT + GRID_GAP)),
)
return {
...widget,
x: nextX,
y: nextY,
}
}
function getResizedWidget(
interaction: Extract<DashboardInteraction, { type: 'resize' }>,
clientX: number,
clientY: number,
) {
const metrics = getGridMetrics()
if (!metrics) {
return interaction.startWidget
}
const columnDelta = Math.round(
(clientX - interaction.startClientX) / (metrics.columnWidth + GRID_GAP),
)
const rowDelta = Math.round(
(clientY - interaction.startClientY) / (GRID_ROW_HEIGHT + GRID_GAP),
)
const startWidget = interaction.startWidget
let nextX = startWidget.x
let nextY = startWidget.y
let nextW = startWidget.w
let nextH = startWidget.h
if (interaction.direction.includes('e')) {
nextW = clamp(startWidget.w + columnDelta, 2, GRID_COLUMNS - startWidget.x)
}
if (interaction.direction.includes('s')) {
nextH = clamp(startWidget.h + rowDelta, 1, 8)
}
if (interaction.direction.includes('w')) {
const maxX = startWidget.x + startWidget.w - 2
nextX = clamp(startWidget.x + columnDelta, 0, maxX)
nextW = startWidget.x + startWidget.w - nextX
}
if (interaction.direction.includes('n')) {
const maxY = startWidget.y + startWidget.h - 1
nextY = clamp(startWidget.y + rowDelta, 0, maxY)
nextH = startWidget.y + startWidget.h - nextY
}
return {
...startWidget,
x: nextX,
y: nextY,
w: nextW,
h: nextH,
}
}
function startMovingWidget(
event: PointerEvent<HTMLElement>,
widget: DashboardWidget,
) {
if (event.button !== 0) {
return
}
if (!isEditingDashboard) {
return
}
const widgetElement = event.currentTarget.closest('[data-dashboard-widget]')
if (!widgetElement || !gridRef.current) {
return
}
const rect = widgetElement.getBoundingClientRect()
interactionRef.current = {
type: 'move',
pointerId: event.pointerId,
widgetId: widget.id,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
}
setActiveWidgetId(widget.id)
setPreviewWidget(widget)
gridRef.current.setPointerCapture(event.pointerId)
}
function startResizingWidget(
event: PointerEvent<HTMLButtonElement>,
widget: DashboardWidget,
direction: ResizeDirection,
) {
event.preventDefault()
event.stopPropagation()
if (!isEditingDashboard) {
return
}
if (!gridRef.current) {
return
}
interactionRef.current = {
type: 'resize',
pointerId: event.pointerId,
widgetId: widget.id,
direction,
startClientX: event.clientX,
startClientY: event.clientY,
startWidget: widget,
}
setActiveWidgetId(widget.id)
setPreviewWidget(widget)
gridRef.current.setPointerCapture(event.pointerId)
}
function handleGridPointerMove(event: PointerEvent<HTMLDivElement>) {
const interaction = interactionRef.current
if (!interaction) {
return
}
if (interaction.type === 'draw') {
const currentCell = getGridCellFromPointer(event.clientX, event.clientY)
if (!currentCell) {
return
}
setDrawPlacement(getDrawPlacement(interaction.startCell, currentCell))
return
}
const widget =
interaction.type === 'resize'
? interaction.startWidget
: widgets.find((item) => item.id === interaction.widgetId)
if (!widget) {
return
}
const rawNextWidget =
interaction.type === 'move'
? getMovedWidget(
widget,
event.clientX,
event.clientY,
interaction.offsetX,
interaction.offsetY,
)
: getResizedWidget(interaction, event.clientX, event.clientY)
const adjustedWidget = findFreePosition(
widgets.filter((item) => item.id !== rawNextWidget.id),
rawNextWidget,
)
setPreviewWidget(adjustedWidget)
}
function handleGridPointerEnd() {
const interaction = interactionRef.current
const nextWidget = previewWidgetRef.current
const nextDrawPlacement = drawPlacementRef.current
if (interaction && gridRef.current?.hasPointerCapture(interaction.pointerId)) {
gridRef.current.releasePointerCapture(interaction.pointerId)
}
interactionRef.current = null
setActiveWidgetId(null)
setPreviewWidget(null)
setDrawPlacement(null)
if (interaction?.type === 'draw') {
if (nextDrawPlacement) {
openWidgetPickerForPlacement(nextDrawPlacement)
}
return
}
if (!nextWidget) {
return
}
const currentWidget = widgets.find((widget) => widget.id === nextWidget.id)
if (!currentWidget || !hasWidgetChanged(currentWidget, nextWidget)) {
return
}
void updateWidget(nextWidget)
}
function openEmptyDashboardWidgetPicker() {
setIsEditingDashboard(true)
openWidgetPickerForPlacement(null)
void saveDashboardSettings({
...dashboardSettings,
isEditingDashboard: true,
}).catch((error) => {
setError(
error instanceof Error
? error.message
: 'Dashboard-Einstellungen konnten nicht gespeichert werden',
)
})
}
if (isLoading) {
return (
<div className="px-4 py-10 sm:px-6 lg:px-8">
<LoadingSpinner
size="lg"
label="Dashboard wird geladen..."
className="text-indigo-600 dark:text-indigo-400"
center
/>
</div>
)
}
return (
<div className="px-4 py-10 sm:px-6 lg:px-8">
<div className="mb-8 sm:flex sm:items-start sm:justify-between sm:gap-x-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
Dashboard
</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Willkommen zurück, {username}. Du kannst Widgets hinzufügen, verschieben und skalieren.
</p>
</div>
{widgets.length > 0 && (
<div className="mt-4 flex items-center gap-x-2 sm:mt-0">
<Button
type="button"
variant={isEditingDashboard ? 'primary' : 'secondary'}
color={isEditingDashboard ? 'emerald' : 'gray'}
leadingIcon={
isEditingDashboard ? (
<CheckIcon aria-hidden="true" className="size-4" />
) : (
<PencilSquareIcon aria-hidden="true" className="size-4" />
)
}
onClick={() => {
const nextValue = !isEditingDashboard
setIsEditingDashboard(nextValue)
setPreviewWidget(null)
setDrawPlacement(null)
setPendingWidgetPlacement(null)
void saveDashboardSettings({
...dashboardSettings,
isEditingDashboard: nextValue,
}).catch((error) => {
setError(
error instanceof Error
? error.message
: 'Dashboard-Einstellungen konnten nicht gespeichert werden',
)
})
}}
>
{isEditingDashboard ? 'Fertig' : 'Dashboard bearbeiten'}
</Button>
</div>
)}
</div>
{error && (
<div className="mb-6 rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
{widgets.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white px-6 py-16 dark:border-white/10 dark:bg-gray-900">
<EmptyState
icon={Squares2X2Icon}
iconClassName="text-indigo-400 dark:text-indigo-500"
title="Noch keine Widgets"
description="Füge dein erstes Widget hinzu und baue dir dein persönliches Dashboard."
actionLabel="Widget hinzufügen"
onAction={openEmptyDashboardWidgetPicker}
/>
</div>
) : (
<div
ref={gridRef}
onPointerDown={handleGridPointerDown}
onPointerMove={handleGridPointerMove}
onPointerUp={handleGridPointerEnd}
onPointerCancel={handleGridPointerEnd}
className={classNames(
'group/dashboard relative grid rounded-lg border p-4 transition',
isEditingDashboard
? 'cursor-crosshair border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-gray-950/40'
: 'border-transparent bg-transparent',
)}
style={{
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
gap: `${GRID_GAP}px`,
minHeight: dashboardHeight + GRID_PADDING * 2,
}}
>
{isEditingDashboard && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-4 z-0 grid overflow-hidden rounded-lg"
style={{
gridTemplateColumns: `repeat(${GRID_COLUMNS}, minmax(0, 1fr))`,
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
gap: `${GRID_GAP}px`,
}}
>
{Array.from({ length: dashboardRows * GRID_COLUMNS }).map((_, index) => (
<div
key={index}
className="rounded-md border border-indigo-500/20 bg-indigo-500/[0.015]"
/>
))}
</div>
)}
{drawPlacement && (
<div
className="pointer-events-none z-10 rounded-lg border-2 border-dashed border-indigo-600 bg-indigo-500/10 ring-4 ring-indigo-500/10"
style={{
gridColumn: `${drawPlacement.x + 1} / span ${drawPlacement.w}`,
gridRow: `${drawPlacement.y + 1} / span ${drawPlacement.h}`,
}}
/>
)}
{previewWidget && (
<div
className="pointer-events-none z-10 rounded-lg border-2 border-dashed border-indigo-500 bg-indigo-500/10 ring-4 ring-indigo-500/10"
style={{
gridColumn: `${previewWidget.x + 1} / span ${previewWidget.w}`,
gridRow: `${previewWidget.y + 1} / span ${previewWidget.h}`,
}}
/>
)}
{widgets.map((widget) => (
<section
key={widget.id}
data-dashboard-widget
className={classNames(
'group relative z-20 flex min-h-0 flex-col overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-200 transition dark:bg-gray-900 dark:ring-white/10',
activeWidgetId === widget.id && 'opacity-60 ring-2 ring-indigo-300 dark:ring-indigo-500',
)}
style={{
gridColumn: `${widget.x + 1} / span ${widget.w}`,
gridRow: `${widget.y + 1} / span ${widget.h}`,
}}
>
<div
onPointerDown={(event) => startMovingWidget(event, widget)}
className={classNames(
'flex shrink-0 touch-none items-center justify-between gap-x-3 border-b border-gray-200 px-4 py-3 select-none dark:border-white/10',
isEditingDashboard ? 'cursor-move' : 'cursor-default',
)}
>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{widget.title}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{getWidgetTypeDefinition(widget.type).label}
</p>
</div>
<div className="flex shrink-0 items-center gap-x-1">
{isEditingDashboard && (
<>
<button
type="button"
title="Widget konfigurieren"
aria-label="Widget konfigurieren"
onPointerDown={(event) => {
event.stopPropagation()
}}
onPointerUp={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
setSettingsWidget(widget)
}}
className="inline-flex size-8 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
>
<Cog6ToothIcon aria-hidden="true" className="size-4" />
</button>
<button
type="button"
title="Widget löschen"
aria-label="Widget löschen"
onPointerDown={(event) => {
event.stopPropagation()
}}
onPointerUp={(event) => {
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
void deleteWidget(widget.id)
}}
className="inline-flex size-8 items-center justify-center rounded-md text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-500/10 dark:hover:text-red-300"
>
<TrashIcon aria-hidden="true" className="size-4" />
</button>
</>
)}
</div>
</div>
<div
className={classNames(
'min-h-0 flex-1',
widget.type === 'operationMap'
? 'overflow-hidden p-0'
: 'overflow-auto p-4',
)}
>
<DashboardWidgetRenderer
widget={widget}
username={username}
apiUrl={API_URL}
widgetCount={widgets.length}
/>
</div>
{isEditingDashboard &&
(['nw', 'ne', 'sw', 'se'] as ResizeDirection[]).map((direction) => (
<button
key={direction}
type="button"
aria-label="Widget skalieren"
onPointerDown={(event) => startResizingWidget(event, widget, direction)}
className={classNames(
'absolute z-20 size-4 rounded-full border-2 border-white bg-indigo-600 shadow-sm opacity-0 ring-1 ring-black/10 transition group-hover:opacity-100 focus:opacity-100 dark:border-gray-900',
'touch-none',
getResizeHandleClass(direction),
)}
/>
))}
</section>
))}
</div>
)}
<WidgetPickerModal
open={isWidgetPickerOpen}
setOpen={handleWidgetPickerOpenChange}
widgetTypes={widgetTypes}
isSaving={isSaving}
onCreateWidget={createWidget}
/>
<WidgetSettingsModal
open={settingsWidget !== null}
setOpen={(open) => {
if (!open) {
setSettingsWidget(null)
}
}}
widget={settingsWidget}
isSaving={isSaving}
onSave={updateWidgetSettings}
/>
</div>
)
}