From 88ff04aa011888cfc072f8b2da40d98db5f817f6 Mon Sep 17 00:00:00 2001 From: Troy Grunt Date: Wed, 11 Feb 2026 21:59:13 +0100 Subject: [PATCH 1/8] =?UTF-8?q?l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUGS.md | 1 - app/index.php | 10 +++---- app/modules/devices/delete.php | 50 ++++++++++++++++++++++++++++++++++ app/modules/devices/edit.php | 3 +- app/modules/devices/list.php | 3 +- 5 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 app/modules/devices/delete.php diff --git a/BUGS.md b/BUGS.md index 249a5c2..703d0ba 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,5 +1,4 @@ # gefundene bugs -- device-webconfig link ist nicht korrekt - device löschen geht nicht - device_types svg modul malen - ports drag n drop funktioniert nicht \ No newline at end of file diff --git a/app/index.php b/app/index.php index c663dee..78c3bbc 100644 --- a/app/index.php +++ b/app/index.php @@ -30,7 +30,7 @@ $action = $_GET['action'] ?? 'list'; $validModules = ['dashboard', 'locations', 'buildings', 'device_types', 'devices', 'racks', 'floors', 'connections']; // Whitelist der Aktionen -$validActions = ['list', 'edit', 'save', 'ports']; +$validActions = ['list', 'edit', 'save', 'ports', 'delete']; // Prüfen auf gültige Werte if (!in_array($module, $validModules)) { @@ -44,9 +44,9 @@ if (!in_array($action, $validActions)) { } /* ========================= - * Template-Header laden (nur für non-save Aktionen) + * Template-Header laden (nur für View-Aktionen) * ========================= */ -if ($action !== 'save') { +if (!in_array($action, ['save', 'delete'], true)) { require_once __DIR__ . '/templates/header.php'; } @@ -65,8 +65,8 @@ if (file_exists($modulePath)) { } /* ========================= - * Template-Footer laden (nur für non-save Aktionen) + * Template-Footer laden (nur für View-Aktionen) * ========================= */ -if ($action !== 'save') { +if (!in_array($action, ['save', 'delete'], true)) { require_once __DIR__ . '/templates/footer.php'; } diff --git a/app/modules/devices/delete.php b/app/modules/devices/delete.php new file mode 100644 index 0000000..c5b8b0b --- /dev/null +++ b/app/modules/devices/delete.php @@ -0,0 +1,50 @@ +single( + "SELECT id, name FROM devices WHERE id = ?", + "i", + [$deviceId] +); + +if (!$device) { + $_SESSION['error'] = "Gerät nicht gefunden"; + header('Location: ?module=devices&action=list'); + exit; +} + +// Verbindungen auf Ports dieses Geräts entfernen (keine FK auf device_ports in connections). +$sql->set( + "DELETE FROM connections + WHERE (port_a_type = 'device' AND port_a_id IN (SELECT id FROM device_ports WHERE device_id = ?)) + OR (port_b_type = 'device' AND port_b_id IN (SELECT id FROM device_ports WHERE device_id = ?))", + "ii", + [$deviceId, $deviceId] +); + +$deleted = $sql->set( + "DELETE FROM devices WHERE id = ?", + "i", + [$deviceId] +); + +if ($deleted > 0) { + $_SESSION['success'] = "Gerät gelöscht: " . $device['name']; +} else { + $_SESSION['error'] = "Gerät konnte nicht gelöscht werden"; +} + +header('Location: ?module=devices&action=list'); +exit; diff --git a/app/modules/devices/edit.php b/app/modules/devices/edit.php index 34665bb..e903f88 100644 --- a/app/modules/devices/edit.php +++ b/app/modules/devices/edit.php @@ -235,8 +235,7 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []); diff --git a/app/modules/devices/list.php b/app/modules/devices/list.php index adb454d..fb4512e 100644 --- a/app/modules/devices/list.php +++ b/app/modules/devices/list.php @@ -331,8 +331,7 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []); From c31e1a308d5210cf9f71db75af896ed08d1a0f22 Mon Sep 17 00:00:00 2001 From: Troy Grunt Date: Wed, 11 Feb 2026 22:09:47 +0100 Subject: [PATCH 2/8] WIP svg editor --- app/assets/js/device-type-shape-editor.js | 491 ++++++++++++++++++++++ app/modules/device_types/edit.php | 437 ++++++++----------- 2 files changed, 660 insertions(+), 268 deletions(-) create mode 100644 app/assets/js/device-type-shape-editor.js diff --git a/app/assets/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js new file mode 100644 index 0000000..c1e5d4e --- /dev/null +++ b/app/assets/js/device-type-shape-editor.js @@ -0,0 +1,491 @@ +(() => { + const SVG_NS = 'http://www.w3.org/2000/svg'; + + function initEditor() { + const editor = document.getElementById('device-type-shape-editor'); + const svg = document.getElementById('shape-canvas'); + const hiddenInput = document.getElementById('shape-definition'); + + if (!editor || !svg || !hiddenInput) { + return; + } + + const overlay = { + empty: document.getElementById('shape-overlay-empty'), + form: document.getElementById('shape-overlay-form'), + type: document.getElementById('shape-param-type'), + x: document.getElementById('shape-param-x'), + y: document.getElementById('shape-param-y'), + width: document.getElementById('shape-param-width'), + height: document.getElementById('shape-param-height'), + radius: document.getElementById('shape-param-radius'), + fontSize: document.getElementById('shape-param-font-size'), + text: document.getElementById('shape-param-text'), + fill: document.getElementById('shape-param-fill'), + stroke: document.getElementById('shape-param-stroke'), + strokeWidth: document.getElementById('shape-param-stroke-width'), + isPort: document.getElementById('shape-param-is-port'), + portName: document.getElementById('shape-param-port-name'), + portNameLabel: document.getElementById('shape-port-name-label'), + deleteButton: document.getElementById('shape-delete') + }; + + const fieldVisibility = { + width: editor.querySelector('[data-field="width"]'), + height: editor.querySelector('[data-field="height"]'), + radius: editor.querySelector('[data-field="radius"]'), + text: editor.querySelector('[data-field="text"]'), + font_size: editor.querySelector('[data-field="font_size"]') + }; + + let draggedTemplate = null; + let dragState = { + active: false, + shapeId: null, + offsetX: 0, + offsetY: 0 + }; + let selectedShapeId = null; + let shapes = normalizeShapeList(readJson(hiddenInput.value)); + + bindToolbarDragEvents(editor); + bindCanvasDropEvents(svg); + bindCanvasPointerEvents(svg); + bindOverlayEvents(overlay); + render(); + + function bindToolbarDragEvents(root) { + const tools = root.querySelectorAll('.shape-tool[data-shape-template]'); + tools.forEach((tool) => { + tool.addEventListener('dragstart', (event) => { + draggedTemplate = String(tool.dataset.shapeTemplate || '').trim(); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('text/plain', draggedTemplate); + } + }); + + tool.addEventListener('dragend', () => { + draggedTemplate = null; + }); + }); + } + + function bindCanvasDropEvents(canvas) { + const canvasWrap = canvas.closest('.shape-editor-canvas'); + if (!canvasWrap) { + return; + } + + canvasWrap.addEventListener('dragover', (event) => { + if (!draggedTemplate) { + return; + } + event.preventDefault(); + canvasWrap.classList.add('drag-over'); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + }); + + canvasWrap.addEventListener('dragleave', () => { + canvasWrap.classList.remove('drag-over'); + }); + + canvasWrap.addEventListener('drop', (event) => { + if (!draggedTemplate) { + return; + } + event.preventDefault(); + canvasWrap.classList.remove('drag-over'); + + const point = toSvgPoint(event, canvas); + const shape = createDefaultShape(draggedTemplate, point.x, point.y); + shapes.push(shape); + selectedShapeId = shape.id; + persist(); + render(); + }); + } + + function bindCanvasPointerEvents(canvas) { + canvas.addEventListener('pointerdown', (event) => { + const target = event.target; + if (!(target instanceof SVGElement)) { + return; + } + + const shapeElement = target.closest('[data-shape-id]'); + if (!shapeElement) { + selectedShapeId = null; + renderOverlay(); + return; + } + + const shapeId = shapeElement.getAttribute('data-shape-id'); + const shape = findShape(shapeId); + if (!shape) { + return; + } + + selectedShapeId = shape.id; + renderOverlay(); + + const point = toSvgPoint(event, canvas); + const anchor = getShapeAnchor(shape); + dragState = { + active: true, + shapeId: shape.id, + offsetX: anchor.x - point.x, + offsetY: anchor.y - point.y + }; + + shapeElement.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener('pointermove', (event) => { + if (!dragState.active || !dragState.shapeId) { + return; + } + + const shape = findShape(dragState.shapeId); + if (!shape) { + return; + } + + const point = toSvgPoint(event, canvas); + const x = Math.round(point.x + dragState.offsetX); + const y = Math.round(point.y + dragState.offsetY); + setShapeAnchor(shape, x, y); + persist(); + render(); + }); + + canvas.addEventListener('pointerup', () => { + dragState.active = false; + dragState.shapeId = null; + }); + + canvas.addEventListener('pointercancel', () => { + dragState.active = false; + dragState.shapeId = null; + }); + } + + function bindOverlayEvents(o) { + const inputs = [ + o.x, o.y, o.width, o.height, o.radius, o.fontSize, o.text, o.fill, o.stroke, o.strokeWidth, o.isPort, o.portName + ]; + + inputs.forEach((input) => { + if (!input) { + return; + } + const eventName = input.type === 'checkbox' ? 'change' : 'input'; + input.addEventListener(eventName, applyOverlayToSelectedShape); + }); + + o.deleteButton.addEventListener('click', () => { + if (!selectedShapeId) { + return; + } + shapes = shapes.filter((shape) => shape.id !== selectedShapeId); + selectedShapeId = null; + persist(); + render(); + }); + } + + function applyOverlayToSelectedShape() { + const shape = findShape(selectedShapeId); + if (!shape) { + return; + } + + shape.x = toNumberOrDefault(overlay.x.value, shape.x); + shape.y = toNumberOrDefault(overlay.y.value, shape.y); + shape.width = toNumberOrDefault(overlay.width.value, shape.width); + shape.height = toNumberOrDefault(overlay.height.value, shape.height); + shape.radius = toNumberOrDefault(overlay.radius.value, shape.radius); + shape.fontSize = toNumberOrDefault(overlay.fontSize.value, shape.fontSize); + shape.text = String(overlay.text.value || shape.text || 'Text'); + shape.fill = normalizeColor(overlay.fill.value, shape.fill); + shape.stroke = normalizeColor(overlay.stroke.value, shape.stroke); + shape.strokeWidth = toNumberOrDefault(overlay.strokeWidth.value, shape.strokeWidth); + shape.isPort = overlay.isPort.checked; + + if (shape.isPort) { + shape.portName = String(overlay.portName.value || '').trim(); + } else { + shape.portName = ''; + } + + persist(); + render(); + } + + function render() { + renderCanvas(); + renderOverlay(); + } + + function renderCanvas() { + svg.querySelectorAll('.shape-object').forEach((el) => el.remove()); + + shapes.forEach((shape) => { + const element = createSvgShapeElement(shape); + if (!element) { + return; + } + element.classList.add('shape-object'); + if (shape.id === selectedShapeId) { + element.classList.add('is-selected'); + } + if (shape.isPort) { + element.classList.add('is-port'); + } + element.setAttribute('data-shape-id', shape.id); + svg.appendChild(element); + }); + } + + function renderOverlay() { + const selected = findShape(selectedShapeId); + const hasSelection = !!selected; + + overlay.empty.hidden = hasSelection; + overlay.form.hidden = !hasSelection; + + if (!selected) { + return; + } + + overlay.type.value = selected.type; + overlay.x.value = selected.x; + overlay.y.value = selected.y; + overlay.width.value = selected.width; + overlay.height.value = selected.height; + overlay.radius.value = selected.radius; + overlay.fontSize.value = selected.fontSize; + overlay.text.value = selected.text || ''; + overlay.fill.value = normalizeColor(selected.fill, '#cccccc'); + overlay.stroke.value = normalizeColor(selected.stroke, '#333333'); + overlay.strokeWidth.value = selected.strokeWidth; + overlay.isPort.checked = !!selected.isPort; + overlay.portName.value = selected.portName || ''; + + const isRect = selected.type === 'rect'; + const isCircle = selected.type === 'circle'; + const isText = selected.type === 'text'; + + setFieldVisible(fieldVisibility.width, isRect); + setFieldVisible(fieldVisibility.height, isRect); + setFieldVisible(fieldVisibility.radius, isCircle); + setFieldVisible(fieldVisibility.text, isText); + setFieldVisible(fieldVisibility.font_size, isText); + + overlay.portNameLabel.hidden = !selected.isPort; + } + + function persist() { + hiddenInput.value = JSON.stringify(shapes); + } + + function findShape(id) { + if (!id) { + return null; + } + return shapes.find((shape) => shape.id === id) || null; + } + } + + function createSvgShapeElement(shape) { + const fill = normalizeColor(shape.fill, '#cccccc'); + const stroke = normalizeColor(shape.stroke, '#333333'); + const strokeWidth = shape.strokeWidth > 0 ? shape.strokeWidth : 1; + + if (shape.type === 'rect') { + const rect = document.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(shape.x)); + rect.setAttribute('y', String(shape.y)); + rect.setAttribute('width', String(shape.width)); + rect.setAttribute('height', String(shape.height)); + rect.setAttribute('fill', fill); + rect.setAttribute('stroke', stroke); + rect.setAttribute('stroke-width', String(strokeWidth)); + return rect; + } + + if (shape.type === 'circle') { + const circle = document.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('cx', String(shape.x)); + circle.setAttribute('cy', String(shape.y)); + circle.setAttribute('r', String(shape.radius)); + circle.setAttribute('fill', fill); + circle.setAttribute('stroke', stroke); + circle.setAttribute('stroke-width', String(strokeWidth)); + return circle; + } + + if (shape.type === 'text') { + const text = document.createElementNS(SVG_NS, 'text'); + text.setAttribute('x', String(shape.x)); + text.setAttribute('y', String(shape.y)); + text.setAttribute('fill', fill); + text.setAttribute('font-size', String(shape.fontSize)); + text.setAttribute('text-anchor', 'start'); + text.setAttribute('dominant-baseline', 'hanging'); + text.textContent = shape.text || 'Text'; + return text; + } + + return null; + } + + function normalizeShapeList(value) { + if (!Array.isArray(value)) { + return []; + } + + return value.map((shape, index) => { + const normalizedType = ['rect', 'circle', 'text'].includes(shape.type) ? shape.type : 'rect'; + const legacyRadius = toNumberOrDefault(shape.r, 26); + + return { + id: String(shape.id || `shape_${Date.now()}_${index}`), + type: normalizedType, + x: toNumberOrDefault(shape.x, 20), + y: toNumberOrDefault(shape.y, 20), + width: toNumberOrDefault(shape.width, 120), + height: toNumberOrDefault(shape.height, 60), + radius: toNumberOrDefault(shape.radius, legacyRadius), + text: String(shape.text || 'Text'), + fontSize: toNumberOrDefault(shape.fontSize, 16), + fill: normalizeColor(shape.fill, '#cccccc'), + stroke: normalizeColor(shape.stroke, '#333333'), + strokeWidth: toNumberOrDefault(shape.strokeWidth, 1), + isPort: !!shape.isPort, + portName: String(shape.portName || '') + }; + }); + } + + function createDefaultShape(type, x, y) { + const uid = `shape_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + + if (type === 'circle') { + return { + id: uid, + type: 'circle', + x: Math.round(x), + y: Math.round(y), + width: 0, + height: 0, + radius: 26, + text: '', + fontSize: 16, + fill: '#cfe6ff', + stroke: '#1f5f99', + strokeWidth: 1, + isPort: false, + portName: '' + }; + } + + if (type === 'text') { + return { + id: uid, + type: 'text', + x: Math.round(x), + y: Math.round(y), + width: 0, + height: 0, + radius: 0, + text: 'Text', + fontSize: 16, + fill: '#2a2a2a', + stroke: '#2a2a2a', + strokeWidth: 0, + isPort: false, + portName: '' + }; + } + + return { + id: uid, + type: 'rect', + x: Math.round(x), + y: Math.round(y), + width: 120, + height: 60, + radius: 0, + text: '', + fontSize: 16, + fill: '#d9e8b3', + stroke: '#4d5f27', + strokeWidth: 1, + isPort: false, + portName: '' + }; + } + + function getShapeAnchor(shape) { + if (shape.type === 'circle') { + return { x: shape.x, y: shape.y }; + } + return { x: shape.x, y: shape.y }; + } + + function setShapeAnchor(shape, x, y) { + shape.x = x; + shape.y = y; + } + + function setFieldVisible(field, visible) { + if (!field) { + return; + } + field.hidden = !visible; + } + + function toSvgPoint(event, svg) { + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + + const ctm = svg.getScreenCTM(); + if (!ctm) { + return { x: 0, y: 0 }; + } + + const p = pt.matrixTransform(ctm.inverse()); + return { x: p.x, y: p.y }; + } + + function toNumberOrDefault(value, fallback) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; + } + + function readJson(raw) { + if (!raw || !String(raw).trim()) { + return []; + } + + try { + return JSON.parse(raw); + } catch (error) { + return []; + } + } + + function normalizeColor(value, fallback) { + const v = String(value || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(v)) { + return v; + } + return fallback; + } + + document.addEventListener('DOMContentLoaded', initEditor); +})(); diff --git a/app/modules/device_types/edit.php b/app/modules/device_types/edit.php index 7ac02f0..3c1b1c6 100644 --- a/app/modules/device_types/edit.php +++ b/app/modules/device_types/edit.php @@ -110,74 +110,97 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
- Gerätedesign (Rechtecke, Kreise, Text) + Gerätedesign (SVG-Editor) -
+
+
+

Werkzeuge

+

Element in die Zeichenfläche ziehen, dann per Klick auswählen und bearbeiten.

+
+ + + +
+
+
- + +

Bestehende SVG-Objekte sind anklickbar und per Drag-and-Drop verschiebbar.

-
-
- - -
-
- - - - - - - - - -
+
+

Objekt-Parameter

+

Kein Objekt ausgewählt.

- -

Shapes werden als JSON gespeichert und können jederzeit angepasst werden.

+
- -
-

Shapes

-
    -
    Allgemein @@ -58,8 +55,7 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []); - Dient zur Sortierung + placeholder="z.B. 0 für EG, 1 für 1. OG">
    @@ -69,19 +65,15 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
    -
    Standort -
    - Optionales Floorplan-SVG. Kann später im Editor bearbeitet werden. + Optional. Wenn ein Zeichnungsinhalt im Editor erstellt wird, wird dieser beim Speichern bevorzugt.
    - -
    - -

    + + +
    +
    +

    Zeichenwerkzeug

    + + + + + + + +
    + +
    + +

    Linien müssen nicht geschlossen sein. Klick auf freie Fläche fügt Punkte hinzu, Punkte sind per Drag verschiebbar.

    +
    + +
    +

    Hilfslinien

    +
    + + + +
    +
      +
      -
      -
      Abbrechen - - Löschen -
      - - - ========================= --> - -
      - Grundriss / Floorplan - -
      - - - -
      - -

      - Räume und Netzwerkdosen per Drag & Drop platzieren. Nummerierung und Bezeichnungen editierbar. -

      -
      - - - -
      - - - -
      - - - - - - + diff --git a/app/modules/floors/save.php b/app/modules/floors/save.php index 604e926..a5bf8dd 100644 --- a/app/modules/floors/save.php +++ b/app/modules/floors/save.php @@ -19,6 +19,7 @@ $name = trim($_POST['name'] ?? ''); $buildingId = (int)($_POST['building_id'] ?? 0); $level = isset($_POST['level']) ? (int)$_POST['level'] : null; $comment = trim($_POST['comment'] ?? ''); +$floorSvgContent = trim($_POST['floor_svg_content'] ?? ''); // ========================= // Validierung @@ -79,6 +80,17 @@ if (!empty($_FILES['svg_file']['name'])) { } } +if ($floorSvgContent !== '') { + $storedSvgPath = storeSvgEditorContent($sql, $floorId, $floorSvgContent); + if ($storedSvgPath === false) { + $_SESSION['error'] = "SVG aus dem Editor konnte nicht gespeichert werden"; + $redirectUrl = $floorId ? "?module=floors&action=edit&id=$floorId" : "?module=floors&action=edit"; + header("Location: $redirectUrl"); + exit; + } + $svgPath = $storedSvgPath; +} + // ========================= // In DB speichern // ========================= @@ -113,3 +125,41 @@ $_SESSION['success'] = "Stockwerk gespeichert"; // ========================= header('Location: ?module=floors&action=list'); exit; + +function storeSvgEditorContent($sql, $floorId, $content) +{ + $normalized = trim($content); + if ($normalized === '' || stripos($normalized, ' 0) { + $existing = $sql->single( + "SELECT svg_path FROM floors WHERE id = ?", + "i", + [$floorId] + ); + $candidate = trim((string)($existing['svg_path'] ?? '')); + if ($candidate !== '') { + $relativePath = ltrim($candidate, "/\\"); + } + } + + if (!$relativePath) { + $relativePath = 'uploads/floorplans/' . uniqid('floor_') . '.svg'; + } + + $absolutePath = __DIR__ . '/../../' . $relativePath; + $targetDir = dirname($absolutePath); + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + $written = file_put_contents($absolutePath, $normalized); + if ($written === false) { + return false; + } + + return $relativePath; +} From b469a7ab33111d6f662876c2ddbbacfaa479ef60 Mon Sep 17 00:00:00 2001 From: fixclean Date: Thu, 12 Feb 2026 08:35:53 +0100 Subject: [PATCH 4/8] =?UTF-8?q?css=20aufger=C3=A4umt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/assets/css/device-type-edit.css | 301 ++++++++++++++++++++++ app/assets/js/device-type-shape-editor.js | 179 ++++++++++++- app/modules/device_types/edit.php | 289 ++------------------- app/templates/header.php | 1 + doc/DATABASE.md | 2 +- 5 files changed, 507 insertions(+), 265 deletions(-) create mode 100644 app/assets/css/device-type-edit.css diff --git a/app/assets/css/device-type-edit.css b/app/assets/css/device-type-edit.css new file mode 100644 index 0000000..4330203 --- /dev/null +++ b/app/assets/css/device-type-edit.css @@ -0,0 +1,301 @@ +.device-type-edit { + max-width: 800px; + margin: 20px auto; + padding: 20px; +} + +.device-type-edit .edit-form { + background: white; + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; +} + +.device-type-edit .edit-form fieldset { + margin: 20px 0; + padding: 15px; + border: 1px solid #e0e0e0; + border-radius: 4px; +} + +.device-type-edit .edit-form legend { + padding: 0 10px; + font-weight: bold; + font-size: 1.1em; +} + +.device-type-edit .form-group { + margin: 15px 0; +} + +.device-type-edit .form-group label { + display: block; + margin-bottom: 5px; + font-weight: bold; +} + +.device-type-edit .form-group input[type="text"], +.device-type-edit .form-group input[type="file"], +.device-type-edit .form-group select, +.device-type-edit .form-group textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .form-group textarea { + resize: vertical; +} + +.device-type-edit .form-group small { + display: block; + margin-top: 5px; + color: #666; +} + +.device-type-edit .required { + color: red; +} + +.device-type-edit .form-file-preview { + margin-top: 10px; +} + +.device-type-edit .device-type-current-image { + max-width: 300px; + border: 1px solid #ddd; + padding: 10px; + display: block; +} + +.device-type-edit .port-definition-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; +} + +.device-type-edit .port-definition-table th, +.device-type-edit .port-definition-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #ddd; + vertical-align: middle; +} + +.device-type-edit .port-definition-table th { + background: #f5f5f5; +} + +.device-type-edit .port-definition-table input[type="text"], +.device-type-edit .port-definition-table select { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .form-actions { + display: flex; + gap: 10px; + margin-top: 30px; +} + +.device-type-edit .shape-editor { + display: grid; + grid-template-columns: 180px 1fr 320px; + gap: 16px; + margin-top: 16px; +} + +.device-type-edit .shape-meta-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.device-type-edit .shape-meta-column { + display: flex; + flex-direction: column; + gap: 4px; +} + +.device-type-edit .shape-meta-column label { + font-weight: 600; + font-size: 0.9em; +} + +.device-type-edit .shape-meta-column input, +.device-type-edit .shape-meta-column select { + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; + font-size: 0.95em; +} + +.device-type-edit .port-actions { + margin-top: 15px; + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.device-type-edit .shape-editor-canvas { + border: 1px solid #ddd; + border-radius: 6px; + background: white; + padding: 12px; +} + +.device-type-edit .shape-editor-canvas svg { + width: 100%; + min-height: 320px; + display: block; + font-family: inherit; + cursor: crosshair; +} + +.device-type-edit .shape-toolbox, +.device-type-edit .shape-overlay { + border: 1px solid #ddd; + border-radius: 6px; + padding: 12px; + background: #fff; +} + +.device-type-edit .shape-toolbox h4, +.device-type-edit .shape-overlay h4 { + margin: 0 0 8px; +} + +.device-type-edit .shape-tool-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.device-type-edit .shape-tool { + text-align: left; + border: 1px solid #bbb; + background: #f7f7f7; + color: #222; + padding: 10px; + border-radius: 4px; + cursor: grab; +} + +.device-type-edit .shape-tool:active { + cursor: grabbing; +} + +.device-type-edit .shape-tool.is-active { + background: #007bff; + color: #fff; + border-color: #0056b3; +} + +.device-type-edit .shape-editor-canvas.drag-over { + outline: 2px dashed #007bff; + outline-offset: 2px; +} + +.device-type-edit .shape-object { + cursor: move; +} + +.device-type-edit #shape-canvas.shape-tool-active { + cursor: crosshair; +} + +.device-type-edit .shape-object.is-selected { + filter: drop-shadow(0 0 5px rgba(0, 123, 255, 0.7)); +} + +.device-type-edit .shape-object.is-port { + stroke-dasharray: 4 2; +} + +.device-type-edit .shape-overlay-form .shape-control-grid { + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 8px; +} + +.device-type-edit .shape-overlay-form label { + font-size: 0.82rem; + display: flex; + flex-direction: column; + gap: 4px; +} + +.device-type-edit .shape-overlay-form input[type="number"], +.device-type-edit .shape-overlay-form input[type="text"], +.device-type-edit .shape-overlay-form input[type="color"] { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: inherit; +} + +.device-type-edit .shape-port-settings { + margin-top: 10px; +} + +.device-type-edit .inline-checkbox { + flex-direction: row !important; + align-items: center; + gap: 6px !important; +} + +.device-type-edit .shape-overlay-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + +.device-type-edit .shape-overlay-empty { + margin: 6px 0 0; + color: #666; + font-size: 0.9rem; +} + +.device-type-edit .hint { + font-size: 0.8rem; + color: #666; + margin: 8px 0 0; +} + +.device-type-edit .button { + padding: 10px 15px; + background: #007bff; + color: white; + text-decoration: none; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 0.95em; +} + +.device-type-edit .button-primary { + background: #28a745; +} + +.device-type-edit .button-danger { + background: #dc3545; +} + +.device-type-edit .button:hover { + opacity: 0.8; +} + +@media (max-width: 1100px) { + .device-type-edit .shape-editor { + grid-template-columns: 1fr; + } +} diff --git a/app/assets/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js index ffe42de..4515ce6 100644 --- a/app/assets/js/device-type-shape-editor.js +++ b/app/assets/js/device-type-shape-editor.js @@ -1,6 +1,12 @@ (() => { const SVG_NS = 'http://www.w3.org/2000/svg'; const MIN_DRAW_SIZE = 4; + const MIN_CANVAS_WIDTH = 200; + const MIN_CANVAS_HEIGHT = 120; + const FORM_FACTOR_PRESETS = { + '19': { width: 760, height: 80 }, + '10': { width: 420, height: 80 } + }; function initEditor() { const editor = document.getElementById('device-type-shape-editor'); @@ -31,6 +37,13 @@ deleteButton: document.getElementById('shape-delete') }; + const metaInputs = { + formFactor: document.getElementById('shape-meta-form-factor'), + rackHeight: document.getElementById('shape-meta-rack-height'), + canvasWidth: document.getElementById('shape-meta-canvas-width'), + canvasHeight: document.getElementById('shape-meta-canvas-height') + }; + const fieldVisibility = { width: editor.querySelector('[data-field="width"]'), height: editor.querySelector('[data-field="height"]'), @@ -53,11 +66,20 @@ offsetY: 0 }; let selectedShapeId = null; - let shapes = normalizeShapeList(readJson(hiddenInput.value)); + let shapes = []; + let meta = getDefaultMeta(); + + const definition = readDefinition(hiddenInput.value); + shapes = normalizeShapeList(definition.shapes); + meta = definition.meta; bindToolbarEvents(editor); bindCanvasPointerEvents(svg); bindOverlayEvents(overlay); + bindMetaEvents(); + applyFormFactorPreset(); + applyMetaToInputs(); + persist(); render(); function bindToolbarEvents(root) { @@ -218,6 +240,63 @@ }); } + function bindMetaEvents() { + Object.values(metaInputs).forEach((input) => { + if (!input) { + return; + } + const eventName = input.tagName === 'SELECT' ? 'change' : 'input'; + input.addEventListener(eventName, handleMetaInputChange); + }); + } + + function handleMetaInputChange(event) { + const targetId = event?.target?.id; + applyMetaFromInputs(); + applyFormFactorPreset(targetId); + applyMetaToInputs(); + persist(); + render(); + } + + function applyMetaFromInputs() { + if (!metaInputs.formFactor) { + return; + } + meta.formFactor = normalizeFormFactor(metaInputs.formFactor.value); + meta.heightHe = Math.max(1, toNumberOrDefault(metaInputs.rackHeight.value, meta.heightHe)); + meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, toNumberOrDefault(metaInputs.canvasWidth.value, meta.canvasWidth)); + meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, toNumberOrDefault(metaInputs.canvasHeight.value, meta.canvasHeight)); + } + + function applyMetaToInputs() { + if (!metaInputs.formFactor) { + return; + } + metaInputs.formFactor.value = meta.formFactor; + metaInputs.rackHeight.value = meta.heightHe; + metaInputs.canvasWidth.value = meta.canvasWidth; + metaInputs.canvasHeight.value = meta.canvasHeight; + } + + function applyFormFactorPreset(triggerId) { + if (!['shape-meta-form-factor', 'shape-meta-rack-height'].includes(triggerId) && triggerId !== undefined) { + return; + } + + if (meta.heightHe !== 1) { + return; + } + + const preset = FORM_FACTOR_PRESETS[meta.formFactor]; + if (!preset) { + return; + } + + meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, preset.width); + meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, preset.height); + } + function applyOverlayToSelectedShape() { const shape = findShape(selectedShapeId); if (!shape) { @@ -248,6 +327,7 @@ function render() { renderToolState(); + updateCanvasDimensions(); renderCanvas(); renderOverlay(); } @@ -261,7 +341,8 @@ } function renderCanvas() { - svg.querySelectorAll('.shape-object').forEach((el) => el.remove()); + svg.querySelectorAll('.shape-object, .shape-auto-frame').forEach((el) => el.remove()); + renderAutoFrame(); shapes.forEach((shape) => { const element = createSvgShapeElement(shape); @@ -280,6 +361,39 @@ }); } + function renderAutoFrame() { + if (!['19', '10'].includes(meta.formFactor)) { + return; + } + + const padding = 4; + const frameWidth = Math.max(0, meta.canvasWidth - padding * 2); + const frameHeight = Math.max(0, meta.canvasHeight - padding * 2); + if (frameWidth <= 0 || frameHeight <= 0) { + return; + } + + const frame = document.createElementNS(SVG_NS, 'rect'); + frame.setAttribute('x', String(padding)); + frame.setAttribute('y', String(padding)); + frame.setAttribute('width', String(frameWidth)); + frame.setAttribute('height', String(frameHeight)); + frame.setAttribute('fill', '#ffffff'); + frame.setAttribute('stroke', '#000000'); + frame.setAttribute('stroke-width', '1'); + frame.setAttribute('class', 'shape-auto-frame'); + frame.setAttribute('pointer-events', 'none'); + svg.appendChild(frame); + } + + function updateCanvasDimensions() { + const width = Math.max(MIN_CANVAS_WIDTH, meta.canvasWidth); + const height = Math.max(MIN_CANVAS_HEIGHT, meta.canvasHeight); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.style.height = `${height}px`; + svg.style.minHeight = `${height}px`; + } + function renderOverlay() { const selected = findShape(selectedShapeId); const hasSelection = !!selected; @@ -319,7 +433,16 @@ } function persist() { - hiddenInput.value = JSON.stringify(shapes); + const payload = { + shapes, + meta: { + formFactor: meta.formFactor, + heightHe: meta.heightHe, + canvasWidth: meta.canvasWidth, + canvasHeight: meta.canvasHeight + } + }; + hiddenInput.value = JSON.stringify(payload); } function findShape(id) { @@ -542,6 +665,56 @@ } } + function readDefinition(raw) { + const parsed = readJson(raw); + if (Array.isArray(parsed)) { + return { + shapes: parsed, + meta: getDefaultMeta() + }; + } + + if (parsed && typeof parsed === 'object') { + return { + shapes: Array.isArray(parsed.shapes) ? parsed.shapes : [], + meta: normalizeMeta(parsed.meta ?? parsed) + }; + } + + return { + shapes: [], + meta: getDefaultMeta() + }; + } + + function normalizeMeta(source) { + const base = getDefaultMeta(); + if (!source || typeof source !== 'object') { + return base; + } + + return { + formFactor: normalizeFormFactor(source.formFactor ?? source.rackFormFactor ?? base.formFactor), + heightHe: Math.max(1, toNumberOrDefault(source.heightHe ?? source.rackHeight ?? base.heightHe, base.heightHe)), + canvasWidth: Math.max(MIN_CANVAS_WIDTH, toNumberOrDefault(source.canvasWidth ?? source.width ?? base.canvasWidth, base.canvasWidth)), + canvasHeight: Math.max(MIN_CANVAS_HEIGHT, toNumberOrDefault(source.canvasHeight ?? source.height ?? base.canvasHeight, base.canvasHeight)) + }; + } + + function getDefaultMeta() { + return { + formFactor: 'other', + heightHe: 1, + canvasWidth: 800, + canvasHeight: 360 + }; + } + + function normalizeFormFactor(value) { + const normalized = String(value || '').trim(); + return ['10', '19'].includes(normalized) ? normalized : 'other'; + } + function normalizeColor(value, fallback) { const v = String(value || '').trim(); if (/^#[0-9a-fA-F]{6}$/.test(v)) { diff --git a/app/modules/device_types/edit.php b/app/modules/device_types/edit.php index d69e41f..f8d413b 100644 --- a/app/modules/device_types/edit.php +++ b/app/modules/device_types/edit.php @@ -120,10 +120,11 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[ -
      +
      - Gerätetyp-Bild + Gerätetyp-Bild
      @@ -131,6 +132,29 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
      Gerätedesign (SVG-Editor) +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      @@ -276,7 +300,7 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[ -
      +
      Ports können nach dem ersten Speichern jederzeit einzeln bearbeitet und gelöscht werden.
      @@ -297,263 +321,6 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
      - diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 0bf31ad..de0e4f7 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -120,7 +120,7 @@ Definiert eine Gerätevorlage. - Grundlage für alle grafischen Ansichten **Technische Attribute** -- `shape_definition`: JSON-Array mit einfachen Formen (rect/circle/text) für die integrierte Zeichenfläche. +- `shape_definition`: JSON-Objekt mit `meta` und `shapes`. `meta` enthält den Formfaktor (`'10'`, `'19'`, `'other'`), die Rack-Höhe in HE (`heightHe`) sowie die Zeichenflächen-Abmessungen (`canvasWidth`, `canvasHeight`). Bei 1HE-Geräten mit 10" oder 19" Frontpanel wird der Zeichenbereich automatisch auf die dort üblichen Breiten/Höhen (420×80px bzw. 760×80px) skaliert und zusätzlich von einem weißen Rahmen mit schwarzer Linie umgeben, damit die Darstellung den realen Formfaktor besser widerspiegelt. Der `shapes`-Array enthält wie bisher die einzelnen Formen (`rect`, `circle`, `text`), die im Editor platziert wurden. --- From d9abde7baceba8f5e67534251db35962d29a7bd0 Mon Sep 17 00:00:00 2001 From: fixclean Date: Thu, 12 Feb 2026 08:44:49 +0100 Subject: [PATCH 5/8] closes #1 --- app/modules/connections/save.php | 53 -------------------------------- 1 file changed, 53 deletions(-) diff --git a/app/modules/connections/save.php b/app/modules/connections/save.php index fc76a94..dbad99f 100644 --- a/app/modules/connections/save.php +++ b/app/modules/connections/save.php @@ -67,56 +67,3 @@ $_SESSION['success'] = "Verbindung gespeichert"; // ========================= header('Location: ?module=connections&action=list'); exit; - "type": "device_position" | "port_position" | "network_layout" | ... - "entity_id": 123, - "payload": { ... } -} -*/ - -// TODO: Pflichtfelder prüfen -// $type = $data['type'] ?? null; -// $entityId = $data['entity_id'] ?? null; -// $payload = $data['payload'] ?? null; - -// ========================= -// Routing nach Typ -// ========================= - -switch ($type ?? null) { - - case 'device_position': - // TODO: - // - Gerät-ID validieren - // - SVG-Koordinaten speichern - // - ggf. Zoom / Rotation - break; - - case 'port_position': - // TODO: - // - Device-Type-Port-ID - // - Koordinaten relativ zum SVG - break; - - case 'network_layout': - // TODO: - // - Kontext (Standort / Rack) - // - Gerätepositionen - // - Verbindungskurven - break; - - default: - http_response_code(400); - echo json_encode([ - 'error' => 'Unknown save type' - ]); - exit; -} - -// ========================= -// Antwort -// ========================= - -// TODO: Erfolg / Fehler zurückgeben -echo json_encode([ - 'status' => 'ok' -]); From ff2024df9f49be8761670f71a69bcd743d287502 Mon Sep 17 00:00:00 2001 From: fixclean Date: Thu, 12 Feb 2026 08:51:33 +0100 Subject: [PATCH 6/8] design breiter --- app/assets/js/device-type-shape-editor.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/assets/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js index 4515ce6..a4b75c9 100644 --- a/app/assets/js/device-type-shape-editor.js +++ b/app/assets/js/device-type-shape-editor.js @@ -284,17 +284,14 @@ return; } - if (meta.heightHe !== 1) { - return; - } - const preset = FORM_FACTOR_PRESETS[meta.formFactor]; if (!preset) { return; } meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, preset.width); - meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, preset.height); + const targetHeight = Math.round(preset.height * Math.max(1, meta.heightHe)); + meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, targetHeight); } function applyOverlayToSelectedShape() { From 78455ca1e61938ec40290635afac95584c640961 Mon Sep 17 00:00:00 2001 From: fixclean Date: Thu, 12 Feb 2026 08:51:39 +0100 Subject: [PATCH 7/8] . --- app/assets/css/device-type-edit.css | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/assets/css/device-type-edit.css b/app/assets/css/device-type-edit.css index 4330203..3238da9 100644 --- a/app/assets/css/device-type-edit.css +++ b/app/assets/css/device-type-edit.css @@ -1,5 +1,5 @@ .device-type-edit { - max-width: 800px; + max-width: 1200px; margin: 20px auto; padding: 20px; } @@ -105,7 +105,7 @@ .device-type-edit .shape-editor { display: grid; - grid-template-columns: 180px 1fr 320px; + grid-template-columns: 1fr; gap: 16px; margin-top: 16px; } @@ -145,13 +145,20 @@ flex-wrap: wrap; } -.device-type-edit .shape-editor-canvas { +.device-type-edit .shape-editor-canvas, +.device-type-edit .shape-toolbox, +.device-type-edit .shape-overlay { border: 1px solid #ddd; border-radius: 6px; background: white; padding: 12px; } +.device-type-edit .shape-toolbox, +.device-type-edit .shape-overlay { + width: 100%; +} + .device-type-edit .shape-editor-canvas svg { width: 100%; min-height: 320px; From 98fac55ffd1db0b15a4da061353d1d66b4e33422 Mon Sep 17 00:00:00 2001 From: fixclean Date: Thu, 12 Feb 2026 09:05:36 +0100 Subject: [PATCH 8/8] svg editor gut --- app/assets/js/device-type-shape-editor.js | 42 +++++++++++++++++++++++ app/assets/js/network-view.js | 2 ++ app/assets/js/svg-editor.js | 2 ++ app/modules/device_types/edit.php | 5 ++- 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/app/assets/js/device-type-shape-editor.js b/app/assets/js/device-type-shape-editor.js index a4b75c9..e82e014 100644 --- a/app/assets/js/device-type-shape-editor.js +++ b/app/assets/js/device-type-shape-editor.js @@ -8,6 +8,8 @@ '10': { width: 420, height: 80 } }; + let portOptionsInput = null; + function initEditor() { const editor = document.getElementById('device-type-shape-editor'); const svg = document.getElementById('shape-canvas'); @@ -36,6 +38,7 @@ portNameLabel: document.getElementById('shape-port-name-label'), deleteButton: document.getElementById('shape-delete') }; + portOptionsInput = document.getElementById('shape-port-options'); const metaInputs = { formFactor: document.getElementById('shape-meta-form-factor'), @@ -68,6 +71,7 @@ let selectedShapeId = null; let shapes = []; let meta = getDefaultMeta(); + let portNameOptions = readPortOptions(); const definition = readDefinition(hiddenInput.value); shapes = normalizeShapeList(definition.shapes); @@ -78,6 +82,7 @@ bindOverlayEvents(overlay); bindMetaEvents(); applyFormFactorPreset(); + populatePortSelect(); applyMetaToInputs(); persist(); render(); @@ -250,6 +255,20 @@ }); } + function populatePortSelect() { + if (!overlay.portName) { + return; + } + portNameOptions = readPortOptions(); + overlay.portName.innerHTML = ''; + portNameOptions.forEach((name) => { + const option = document.createElement('option'); + option.value = name; + option.textContent = name; + overlay.portName.appendChild(option); + }); + } + function handleMetaInputChange(event) { const targetId = event?.target?.id; applyMetaFromInputs(); @@ -402,6 +421,8 @@ return; } + populatePortSelect(); + overlay.type.value = selected.type; overlay.x.value = selected.x; overlay.y.value = selected.y; @@ -662,6 +683,27 @@ } } + function readPortOptions() { + if (!portOptionsInput) { + return []; + } + + const raw = String(portOptionsInput.value || '').trim(); + if (!raw) { + return []; + } + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((value) => typeof value === 'string'); + } catch (error) { + return []; + } + } + function readDefinition(raw) { const parsed = readJson(raw); if (Array.isArray(parsed)) { diff --git a/app/assets/js/network-view.js b/app/assets/js/network-view.js index ae7c1c1..e5fd5d6 100644 --- a/app/assets/js/network-view.js +++ b/app/assets/js/network-view.js @@ -12,6 +12,7 @@ * -> bewusst simpel & erweiterbar */ +(() => { /* ========================= * Konfiguration * ========================= */ @@ -287,3 +288,4 @@ document.addEventListener('keydown', (e) => { // TODO: Delete -> Gerät entfernen? }); +})(); diff --git a/app/assets/js/svg-editor.js b/app/assets/js/svg-editor.js index 709fb4c..e94e4a3 100644 --- a/app/assets/js/svg-editor.js +++ b/app/assets/js/svg-editor.js @@ -12,6 +12,7 @@ * Abhängigkeiten: keine (Vanilla JS) */ +(() => { /* ========================= * Konfiguration * ========================= */ @@ -256,3 +257,4 @@ document.addEventListener('keydown', (e) => { deleteSelectedPort(); } }); +})(); diff --git a/app/modules/device_types/edit.php b/app/modules/device_types/edit.php index f8d413b..e642bee 100644 --- a/app/modules/device_types/edit.php +++ b/app/modules/device_types/edit.php @@ -234,7 +234,9 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
      @@ -299,6 +301,7 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[ +