Compare commits
9 Commits
23b687c7a2
...
84720db13d
| Author | SHA1 | Date | |
|---|---|---|---|
| 84720db13d | |||
| 98fac55ffd | |||
| 78455ca1e6 | |||
| ff2024df9f | |||
| d9abde7bac | |||
| b469a7ab33 | |||
|
|
fb4ee93b17 | ||
|
|
c31e1a308d | ||
|
|
88ff04aa01 |
9
BUGS.md
9
BUGS.md
@@ -1,5 +1,6 @@
|
||||
# gefundene bugs
|
||||
- device-webconfig link ist nicht korrekt
|
||||
- device löschen geht nicht
|
||||
- device_types svg modul malen
|
||||
- ports drag n drop funktioniert nicht
|
||||
- [?] device löschen geht nicht
|
||||
- [?] device_types svg modul malen
|
||||
- [?] ports drag n drop funktioniert nicht
|
||||
- device _type soll schon aus dem 19zoll und he größe einen initialees rechteck erzeugen, welches als device grundgerüst funktionieren soll.
|
||||
- beim dev typ machen, klick auf obj typ button, dann durch drag and drop die diagonale ziehen mit loslassen fixieren
|
||||
308
app/assets/css/device-type-edit.css
Normal file
308
app/assets/css/device-type-edit.css
Normal file
@@ -0,0 +1,308 @@
|
||||
.device-type-edit {
|
||||
max-width: 1200px;
|
||||
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: 1fr;
|
||||
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,
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
766
app/assets/js/device-type-shape-editor.js
Normal file
766
app/assets/js/device-type-shape-editor.js
Normal file
@@ -0,0 +1,766 @@
|
||||
(() => {
|
||||
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 }
|
||||
};
|
||||
|
||||
let portOptionsInput = null;
|
||||
|
||||
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')
|
||||
};
|
||||
portOptionsInput = document.getElementById('shape-port-options');
|
||||
|
||||
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"]'),
|
||||
radius: editor.querySelector('[data-field="radius"]'),
|
||||
text: editor.querySelector('[data-field="text"]'),
|
||||
font_size: editor.querySelector('[data-field="font_size"]')
|
||||
};
|
||||
|
||||
let activeToolType = null;
|
||||
let drawState = {
|
||||
active: false,
|
||||
shapeId: null,
|
||||
startX: 0,
|
||||
startY: 0
|
||||
};
|
||||
let dragState = {
|
||||
active: false,
|
||||
shapeId: null,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
};
|
||||
let selectedShapeId = null;
|
||||
let shapes = [];
|
||||
let meta = getDefaultMeta();
|
||||
let portNameOptions = readPortOptions();
|
||||
|
||||
const definition = readDefinition(hiddenInput.value);
|
||||
shapes = normalizeShapeList(definition.shapes);
|
||||
meta = definition.meta;
|
||||
|
||||
bindToolbarEvents(editor);
|
||||
bindCanvasPointerEvents(svg);
|
||||
bindOverlayEvents(overlay);
|
||||
bindMetaEvents();
|
||||
applyFormFactorPreset();
|
||||
populatePortSelect();
|
||||
applyMetaToInputs();
|
||||
persist();
|
||||
render();
|
||||
|
||||
function bindToolbarEvents(root) {
|
||||
const tools = root.querySelectorAll('.shape-tool[data-shape-template]');
|
||||
tools.forEach((tool) => {
|
||||
tool.addEventListener('click', () => {
|
||||
const toolType = String(tool.dataset.shapeTemplate || '').trim();
|
||||
activeToolType = activeToolType === toolType ? null : toolType;
|
||||
drawState.active = false;
|
||||
drawState.shapeId = null;
|
||||
renderToolState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindCanvasPointerEvents(canvas) {
|
||||
canvas.addEventListener('pointerdown', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof SVGElement)) {
|
||||
return;
|
||||
}
|
||||
const point = toSvgPoint(event, canvas);
|
||||
|
||||
if (activeToolType) {
|
||||
const shape = createDefaultShape(activeToolType, point.x, point.y);
|
||||
shapes.push(shape);
|
||||
selectedShapeId = shape.id;
|
||||
|
||||
if (activeToolType === 'text') {
|
||||
drawState.active = false;
|
||||
drawState.shapeId = null;
|
||||
persist();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
drawState = {
|
||||
active: true,
|
||||
shapeId: shape.id,
|
||||
startX: Math.round(point.x),
|
||||
startY: Math.round(point.y)
|
||||
};
|
||||
|
||||
const shapeElement = target.closest('[data-shape-id]') || canvas;
|
||||
shapeElement.setPointerCapture(event.pointerId);
|
||||
persist();
|
||||
render();
|
||||
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 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 (drawState.active && drawState.shapeId) {
|
||||
const shape = findShape(drawState.shapeId);
|
||||
if (!shape) {
|
||||
return;
|
||||
}
|
||||
const point = toSvgPoint(event, canvas);
|
||||
updateShapeFromDiagonal(shape, drawState.startX, drawState.startY, point.x, point.y);
|
||||
persist();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
if (drawState.active && drawState.shapeId) {
|
||||
const shape = findShape(drawState.shapeId);
|
||||
if (shape && isBelowMinDrawSize(shape)) {
|
||||
shapes = shapes.filter((candidate) => candidate.id !== shape.id);
|
||||
if (selectedShapeId === shape.id) {
|
||||
selectedShapeId = null;
|
||||
}
|
||||
}
|
||||
drawState.active = false;
|
||||
drawState.shapeId = null;
|
||||
}
|
||||
|
||||
dragState.active = false;
|
||||
dragState.shapeId = null;
|
||||
persist();
|
||||
render();
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointercancel', () => {
|
||||
drawState.active = false;
|
||||
drawState.shapeId = null;
|
||||
dragState.active = false;
|
||||
dragState.shapeId = null;
|
||||
persist();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
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 bindMetaEvents() {
|
||||
Object.values(metaInputs).forEach((input) => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const eventName = input.tagName === 'SELECT' ? 'change' : 'input';
|
||||
input.addEventListener(eventName, handleMetaInputChange);
|
||||
});
|
||||
}
|
||||
|
||||
function populatePortSelect() {
|
||||
if (!overlay.portName) {
|
||||
return;
|
||||
}
|
||||
portNameOptions = readPortOptions();
|
||||
overlay.portName.innerHTML = '<option value="">- Port wählen -</option>';
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
const preset = FORM_FACTOR_PRESETS[meta.formFactor];
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
meta.canvasWidth = Math.max(MIN_CANVAS_WIDTH, preset.width);
|
||||
const targetHeight = Math.round(preset.height * Math.max(1, meta.heightHe));
|
||||
meta.canvasHeight = Math.max(MIN_CANVAS_HEIGHT, targetHeight);
|
||||
}
|
||||
|
||||
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() {
|
||||
renderToolState();
|
||||
updateCanvasDimensions();
|
||||
renderCanvas();
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function renderToolState() {
|
||||
editor.querySelectorAll('.shape-tool[data-shape-template]').forEach((tool) => {
|
||||
const type = String(tool.dataset.shapeTemplate || '').trim();
|
||||
tool.classList.toggle('is-active', activeToolType === type);
|
||||
});
|
||||
svg.classList.toggle('shape-tool-active', !!activeToolType);
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
svg.querySelectorAll('.shape-object, .shape-auto-frame').forEach((el) => el.remove());
|
||||
renderAutoFrame();
|
||||
|
||||
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 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;
|
||||
|
||||
overlay.empty.hidden = hasSelection;
|
||||
overlay.form.hidden = !hasSelection;
|
||||
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
populatePortSelect();
|
||||
|
||||
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() {
|
||||
const payload = {
|
||||
shapes,
|
||||
meta: {
|
||||
formFactor: meta.formFactor,
|
||||
heightHe: meta.heightHe,
|
||||
canvasWidth: meta.canvasWidth,
|
||||
canvasHeight: meta.canvasHeight
|
||||
}
|
||||
};
|
||||
hiddenInput.value = JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function findShape(id) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return shapes.find((shape) => shape.id === id) || null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateShapeFromDiagonal(shape, x1, y1, x2, y2) {
|
||||
const left = Math.min(x1, x2);
|
||||
const top = Math.min(y1, y2);
|
||||
const width = Math.abs(x2 - x1);
|
||||
const height = Math.abs(y2 - y1);
|
||||
|
||||
if (shape.type === 'rect') {
|
||||
shape.x = Math.round(left);
|
||||
shape.y = Math.round(top);
|
||||
shape.width = Math.round(width);
|
||||
shape.height = Math.round(height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shape.type === 'circle') {
|
||||
const radius = Math.round(Math.min(width, height) / 2);
|
||||
shape.radius = radius;
|
||||
shape.x = Math.round(left + radius);
|
||||
shape.y = Math.round(top + radius);
|
||||
}
|
||||
}
|
||||
|
||||
function isBelowMinDrawSize(shape) {
|
||||
if (shape.type === 'rect') {
|
||||
return (shape.width ?? 0) < MIN_DRAW_SIZE || (shape.height ?? 0) < MIN_DRAW_SIZE;
|
||||
}
|
||||
if (shape.type === 'circle') {
|
||||
return (shape.radius ?? 0) < (MIN_DRAW_SIZE / 2);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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: 1,
|
||||
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: 1,
|
||||
height: 1,
|
||||
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 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)) {
|
||||
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)) {
|
||||
return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initEditor);
|
||||
})();
|
||||
471
app/assets/js/floor-svg-editor.js
Normal file
471
app/assets/js/floor-svg-editor.js
Normal file
@@ -0,0 +1,471 @@
|
||||
(() => {
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
const VIEWBOX_WIDTH = 2000;
|
||||
const VIEWBOX_HEIGHT = 1000;
|
||||
const SNAP_TOLERANCE = 12;
|
||||
|
||||
function initFloorSvgEditor() {
|
||||
const editor = document.getElementById('floor-svg-editor');
|
||||
const svg = document.getElementById('floor-svg-canvas');
|
||||
const hiddenInput = document.getElementById('floor-svg-content');
|
||||
if (!editor || !svg || !hiddenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = {
|
||||
startPolyline: document.getElementById('floor-start-polyline'),
|
||||
finishPolyline: document.getElementById('floor-finish-polyline'),
|
||||
deletePolyline: document.getElementById('floor-delete-polyline'),
|
||||
clearDrawing: document.getElementById('floor-clear-drawing'),
|
||||
lock45: document.getElementById('floor-lock-45'),
|
||||
snapGuides: document.getElementById('floor-snap-guides'),
|
||||
addGuide: document.getElementById('floor-add-guide'),
|
||||
guideOrientation: document.getElementById('floor-guide-orientation'),
|
||||
guidePosition: document.getElementById('floor-guide-position'),
|
||||
guideList: document.getElementById('floor-guide-list')
|
||||
};
|
||||
|
||||
const state = {
|
||||
polylines: [],
|
||||
guides: [],
|
||||
selectedPolylineId: null,
|
||||
activePolylineId: null,
|
||||
draggingVertex: null
|
||||
};
|
||||
|
||||
loadFromExistingSvg(hiddenInput.value, state);
|
||||
bindControlEvents(controls, state, svg, hiddenInput);
|
||||
bindCanvasEvents(svg, controls, state, hiddenInput);
|
||||
render(svg, controls, state, hiddenInput);
|
||||
}
|
||||
|
||||
function bindControlEvents(controls, state, svg, hiddenInput) {
|
||||
controls.startPolyline.addEventListener('click', () => {
|
||||
const id = createId('poly');
|
||||
state.polylines.push({
|
||||
id,
|
||||
points: []
|
||||
});
|
||||
state.activePolylineId = id;
|
||||
state.selectedPolylineId = id;
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
|
||||
controls.finishPolyline.addEventListener('click', () => {
|
||||
finishActivePolyline(state);
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
|
||||
controls.deletePolyline.addEventListener('click', () => {
|
||||
if (!state.selectedPolylineId) {
|
||||
return;
|
||||
}
|
||||
state.polylines = state.polylines.filter((line) => line.id !== state.selectedPolylineId);
|
||||
if (state.activePolylineId === state.selectedPolylineId) {
|
||||
state.activePolylineId = null;
|
||||
}
|
||||
state.selectedPolylineId = null;
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
|
||||
controls.clearDrawing.addEventListener('click', () => {
|
||||
state.polylines = [];
|
||||
state.guides = [];
|
||||
state.selectedPolylineId = null;
|
||||
state.activePolylineId = null;
|
||||
state.draggingVertex = null;
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
|
||||
controls.addGuide.addEventListener('click', () => {
|
||||
const orientation = controls.guideOrientation.value === 'horizontal' ? 'horizontal' : 'vertical';
|
||||
const position = Number(controls.guidePosition.value);
|
||||
if (!Number.isFinite(position)) {
|
||||
return;
|
||||
}
|
||||
state.guides.push({
|
||||
id: createId('guide'),
|
||||
orientation,
|
||||
position: Math.round(position)
|
||||
});
|
||||
controls.guidePosition.value = '';
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
|
||||
controls.guideList.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const id = target.getAttribute('data-remove-guide');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
state.guides = state.guides.filter((guide) => guide.id !== id);
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
}
|
||||
|
||||
function bindCanvasEvents(svg, controls, state, hiddenInput) {
|
||||
svg.addEventListener('pointerdown', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof SVGElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const vertex = target.closest('[data-vertex-index]');
|
||||
if (vertex) {
|
||||
const polylineId = vertex.getAttribute('data-polyline-id');
|
||||
const vertexIndex = Number(vertex.getAttribute('data-vertex-index'));
|
||||
if (polylineId && Number.isInteger(vertexIndex)) {
|
||||
state.selectedPolylineId = polylineId;
|
||||
state.draggingVertex = { polylineId, vertexIndex };
|
||||
vertex.setPointerCapture(event.pointerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const polylineEl = target.closest('[data-polyline-id]');
|
||||
if (polylineEl) {
|
||||
const polylineId = polylineEl.getAttribute('data-polyline-id');
|
||||
if (polylineId) {
|
||||
state.selectedPolylineId = polylineId;
|
||||
if (!state.activePolylineId) {
|
||||
state.activePolylineId = polylineId;
|
||||
}
|
||||
render(svg, controls, state, hiddenInput);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const point = toSvgPoint(svg, event);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.activePolylineId) {
|
||||
const id = createId('poly');
|
||||
state.polylines.push({ id, points: [] });
|
||||
state.activePolylineId = id;
|
||||
state.selectedPolylineId = id;
|
||||
}
|
||||
|
||||
const activeLine = state.polylines.find((line) => line.id === state.activePolylineId);
|
||||
if (!activeLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextPoint = point;
|
||||
if (controls.lock45.checked && activeLine.points.length > 0) {
|
||||
nextPoint = lockTo45(activeLine.points[activeLine.points.length - 1], nextPoint);
|
||||
}
|
||||
if (controls.snapGuides.checked) {
|
||||
nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE);
|
||||
}
|
||||
|
||||
activeLine.points.push({
|
||||
x: Math.round(nextPoint.x),
|
||||
y: Math.round(nextPoint.y)
|
||||
});
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
|
||||
svg.addEventListener('pointermove', (event) => {
|
||||
if (!state.draggingVertex) {
|
||||
return;
|
||||
}
|
||||
const point = toSvgPoint(svg, event);
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
const line = state.polylines.find((item) => item.id === state.draggingVertex.polylineId);
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
const index = state.draggingVertex.vertexIndex;
|
||||
if (!line.points[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextPoint = point;
|
||||
if (controls.lock45.checked && index > 0 && line.points[index - 1]) {
|
||||
nextPoint = lockTo45(line.points[index - 1], nextPoint);
|
||||
}
|
||||
if (controls.snapGuides.checked) {
|
||||
nextPoint = snapPointToGuides(nextPoint, state.guides, SNAP_TOLERANCE);
|
||||
}
|
||||
|
||||
line.points[index] = {
|
||||
x: Math.round(nextPoint.x),
|
||||
y: Math.round(nextPoint.y)
|
||||
};
|
||||
render(svg, controls, state, hiddenInput);
|
||||
});
|
||||
|
||||
svg.addEventListener('pointerup', () => {
|
||||
state.draggingVertex = null;
|
||||
});
|
||||
svg.addEventListener('pointercancel', () => {
|
||||
state.draggingVertex = null;
|
||||
});
|
||||
}
|
||||
|
||||
function render(svg, controls, state, hiddenInput) {
|
||||
const selected = state.polylines.find((line) => line.id === state.selectedPolylineId) || null;
|
||||
|
||||
svg.innerHTML = '';
|
||||
svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`);
|
||||
|
||||
const background = createSvgElement('rect');
|
||||
background.setAttribute('x', '0');
|
||||
background.setAttribute('y', '0');
|
||||
background.setAttribute('width', String(VIEWBOX_WIDTH));
|
||||
background.setAttribute('height', String(VIEWBOX_HEIGHT));
|
||||
background.setAttribute('fill', '#fafafa');
|
||||
background.setAttribute('stroke', '#e1e1e1');
|
||||
background.setAttribute('stroke-width', '1');
|
||||
svg.appendChild(background);
|
||||
|
||||
state.guides.forEach((guide) => {
|
||||
const line = createSvgElement('line');
|
||||
if (guide.orientation === 'horizontal') {
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', String(guide.position));
|
||||
line.setAttribute('x2', String(VIEWBOX_WIDTH));
|
||||
line.setAttribute('y2', String(guide.position));
|
||||
} else {
|
||||
line.setAttribute('x1', String(guide.position));
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', String(guide.position));
|
||||
line.setAttribute('y2', String(VIEWBOX_HEIGHT));
|
||||
}
|
||||
line.setAttribute('stroke', '#8f8f8f');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
line.setAttribute('stroke-dasharray', '8 6');
|
||||
line.setAttribute('opacity', '0.8');
|
||||
line.setAttribute('data-guide-id', guide.id);
|
||||
svg.appendChild(line);
|
||||
});
|
||||
|
||||
state.polylines.forEach((polyline) => {
|
||||
const line = createSvgElement('polyline');
|
||||
line.setAttribute('fill', 'none');
|
||||
line.setAttribute('stroke', polyline.id === state.selectedPolylineId ? '#007bff' : '#1f2937');
|
||||
line.setAttribute('stroke-width', polyline.id === state.selectedPolylineId ? '5' : '3');
|
||||
line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' '));
|
||||
line.setAttribute('data-polyline-id', polyline.id);
|
||||
svg.appendChild(line);
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
selected.points.forEach((point, index) => {
|
||||
const vertex = createSvgElement('circle');
|
||||
vertex.setAttribute('cx', String(point.x));
|
||||
vertex.setAttribute('cy', String(point.y));
|
||||
vertex.setAttribute('r', '8');
|
||||
vertex.setAttribute('fill', '#ffffff');
|
||||
vertex.setAttribute('stroke', '#dc3545');
|
||||
vertex.setAttribute('stroke-width', '3');
|
||||
vertex.setAttribute('data-polyline-id', selected.id);
|
||||
vertex.setAttribute('data-vertex-index', String(index));
|
||||
svg.appendChild(vertex);
|
||||
});
|
||||
}
|
||||
|
||||
controls.guideList.innerHTML = '';
|
||||
state.guides.forEach((guide) => {
|
||||
const li = document.createElement('li');
|
||||
const label = guide.orientation === 'horizontal' ? 'Horizontal' : 'Vertikal';
|
||||
li.innerHTML = `
|
||||
<span>${label}: ${Math.round(guide.position)}</span>
|
||||
<button type="button" class="button button-danger" data-remove-guide="${guide.id}">X</button>
|
||||
`;
|
||||
controls.guideList.appendChild(li);
|
||||
});
|
||||
|
||||
hiddenInput.value = buildSvgMarkup(state.polylines, state.guides);
|
||||
}
|
||||
|
||||
function finishActivePolyline(state) {
|
||||
const active = state.polylines.find((line) => line.id === state.activePolylineId);
|
||||
if (active && active.points.length < 2) {
|
||||
state.polylines = state.polylines.filter((line) => line.id !== active.id);
|
||||
if (state.selectedPolylineId === active.id) {
|
||||
state.selectedPolylineId = null;
|
||||
}
|
||||
}
|
||||
state.activePolylineId = null;
|
||||
}
|
||||
|
||||
function loadFromExistingSvg(raw, state) {
|
||||
const content = String(raw || '').trim();
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(content, 'image/svg+xml');
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.nodeName.toLowerCase() === 'parsererror') {
|
||||
return;
|
||||
}
|
||||
|
||||
root.querySelectorAll('line[data-guide="1"], line.floor-guide').forEach((line) => {
|
||||
const orientation = line.getAttribute('data-orientation') === 'horizontal' ? 'horizontal' : 'vertical';
|
||||
const position = orientation === 'horizontal'
|
||||
? Number(line.getAttribute('y1'))
|
||||
: Number(line.getAttribute('x1'));
|
||||
if (Number.isFinite(position)) {
|
||||
state.guides.push({
|
||||
id: createId('guide'),
|
||||
orientation,
|
||||
position: Math.round(position)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelectorAll('polyline').forEach((polyline) => {
|
||||
const pointsAttr = polyline.getAttribute('points') || '';
|
||||
const points = parsePoints(pointsAttr);
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
state.polylines.push({
|
||||
id: createId('poly'),
|
||||
points
|
||||
});
|
||||
});
|
||||
|
||||
if (state.polylines.length > 0) {
|
||||
state.selectedPolylineId = state.polylines[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore invalid svg content
|
||||
}
|
||||
}
|
||||
|
||||
function parsePoints(pointsAttr) {
|
||||
return pointsAttr
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => {
|
||||
const [x, y] = pair.split(',').map((value) => Number(value));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
return null;
|
||||
}
|
||||
return { x: Math.round(x), y: Math.round(y) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildSvgMarkup(polylines, guides) {
|
||||
const svg = createSvgElement('svg');
|
||||
svg.setAttribute('xmlns', SVG_NS);
|
||||
svg.setAttribute('viewBox', `0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`);
|
||||
|
||||
const background = createSvgElement('rect');
|
||||
background.setAttribute('x', '0');
|
||||
background.setAttribute('y', '0');
|
||||
background.setAttribute('width', String(VIEWBOX_WIDTH));
|
||||
background.setAttribute('height', String(VIEWBOX_HEIGHT));
|
||||
background.setAttribute('fill', '#fafafa');
|
||||
background.setAttribute('stroke', '#e1e1e1');
|
||||
background.setAttribute('stroke-width', '1');
|
||||
svg.appendChild(background);
|
||||
|
||||
guides.forEach((guide) => {
|
||||
const line = createSvgElement('line');
|
||||
if (guide.orientation === 'horizontal') {
|
||||
line.setAttribute('x1', '0');
|
||||
line.setAttribute('y1', String(guide.position));
|
||||
line.setAttribute('x2', String(VIEWBOX_WIDTH));
|
||||
line.setAttribute('y2', String(guide.position));
|
||||
} else {
|
||||
line.setAttribute('x1', String(guide.position));
|
||||
line.setAttribute('y1', '0');
|
||||
line.setAttribute('x2', String(guide.position));
|
||||
line.setAttribute('y2', String(VIEWBOX_HEIGHT));
|
||||
}
|
||||
line.setAttribute('stroke', '#8f8f8f');
|
||||
line.setAttribute('stroke-width', '1');
|
||||
line.setAttribute('stroke-dasharray', '8 6');
|
||||
line.setAttribute('class', 'floor-guide');
|
||||
line.setAttribute('data-guide', '1');
|
||||
line.setAttribute('data-orientation', guide.orientation);
|
||||
svg.appendChild(line);
|
||||
});
|
||||
|
||||
polylines.forEach((polyline) => {
|
||||
if (polyline.points.length < 2) {
|
||||
return;
|
||||
}
|
||||
const line = createSvgElement('polyline');
|
||||
line.setAttribute('fill', 'none');
|
||||
line.setAttribute('stroke', '#1f2937');
|
||||
line.setAttribute('stroke-width', '3');
|
||||
line.setAttribute('class', 'floor-polyline');
|
||||
line.setAttribute('points', polyline.points.map((point) => `${point.x},${point.y}`).join(' '));
|
||||
svg.appendChild(line);
|
||||
});
|
||||
|
||||
return new XMLSerializer().serializeToString(svg);
|
||||
}
|
||||
|
||||
function lockTo45(origin, point) {
|
||||
const dx = point.x - origin.x;
|
||||
const dy = point.y - origin.y;
|
||||
const length = Math.sqrt((dx * dx) + (dy * dy));
|
||||
if (length === 0) {
|
||||
return { x: origin.x, y: origin.y };
|
||||
}
|
||||
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const step = Math.PI / 4;
|
||||
const snapped = Math.round(angle / step) * step;
|
||||
return {
|
||||
x: origin.x + Math.cos(snapped) * length,
|
||||
y: origin.y + Math.sin(snapped) * length
|
||||
};
|
||||
}
|
||||
|
||||
function snapPointToGuides(point, guides, tolerance) {
|
||||
let next = { ...point };
|
||||
guides.forEach((guide) => {
|
||||
if (guide.orientation === 'vertical') {
|
||||
if (Math.abs(next.x - guide.position) <= tolerance) {
|
||||
next.x = guide.position;
|
||||
}
|
||||
} else if (Math.abs(next.y - guide.position) <= tolerance) {
|
||||
next.y = guide.position;
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function toSvgPoint(svg, event) {
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = event.clientX;
|
||||
pt.y = event.clientY;
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) {
|
||||
return null;
|
||||
}
|
||||
const transformed = pt.matrixTransform(ctm.inverse());
|
||||
return {
|
||||
x: transformed.x,
|
||||
y: transformed.y
|
||||
};
|
||||
}
|
||||
|
||||
function createSvgElement(name) {
|
||||
return document.createElementNS(SVG_NS, name);
|
||||
}
|
||||
|
||||
function createId(prefix) {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initFloorSvgEditor);
|
||||
})();
|
||||
@@ -12,6 +12,7 @@
|
||||
* -> bewusst simpel & erweiterbar
|
||||
*/
|
||||
|
||||
(() => {
|
||||
/* =========================
|
||||
* Konfiguration
|
||||
* ========================= */
|
||||
@@ -287,3 +288,4 @@ document.addEventListener('keydown', (e) => {
|
||||
|
||||
// TODO: Delete -> Gerät entfernen?
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* Abhängigkeiten: keine (Vanilla JS)
|
||||
*/
|
||||
|
||||
(() => {
|
||||
/* =========================
|
||||
* Konfiguration
|
||||
* ========================= */
|
||||
@@ -256,3 +257,4 @@ document.addEventListener('keydown', (e) => {
|
||||
deleteSelectedPort();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
|
||||
@@ -35,6 +35,12 @@ $shapeDefinition = $deviceType['shape_definition'] ?? '[]';
|
||||
if (trim($shapeDefinition) === '') {
|
||||
$shapeDefinition = '[]';
|
||||
}
|
||||
$portTypes = $sql->get(
|
||||
"SELECT id, name FROM port_types ORDER BY name",
|
||||
"",
|
||||
[]
|
||||
);
|
||||
$defaultPortTypeSelected = (int)($_POST['default_port_type_id'] ?? 0);
|
||||
|
||||
$isEdit = !empty($deviceType);
|
||||
$pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType['name']) : "Neuer Gerätetyp";
|
||||
@@ -86,6 +92,19 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
|
||||
placeholder="z.B. 48">
|
||||
<small>Beim Speichern werden bis zu dieser Zahl Platzhalter-Ports erstellt, bestehende Einträge bleiben erhalten.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="default_port_type_id">Standard-Porttyp für neue Ports</label>
|
||||
<select id="default_port_type_id" name="default_port_type_id">
|
||||
<option value="">- Kein Standard -</option>
|
||||
<?php foreach ($portTypes as $portType): ?>
|
||||
<option value="<?php echo (int)$portType['id']; ?>" <?php echo ($defaultPortTypeSelected === (int)$portType['id']) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($portType['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small>Wird beim automatischen Erstellen neuer Ports als Startwert gesetzt.</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- =========================
|
||||
@@ -101,82 +120,131 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
|
||||
</div>
|
||||
|
||||
<?php if ($isEdit && $deviceType['image_path']): ?>
|
||||
<div class="form-group">
|
||||
<div class="form-group form-file-preview">
|
||||
<label>Aktuelles Bild:</label>
|
||||
<img src="<?php echo htmlspecialchars($deviceType['image_path']); ?>"
|
||||
alt="Gerätetyp-Bild" style="max-width: 300px; border: 1px solid #ddd; padding: 10px;">
|
||||
<img class="device-type-current-image"
|
||||
src="<?php echo htmlspecialchars($deviceType['image_path']); ?>"
|
||||
alt="Gerätetyp-Bild">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Gerätedesign (Rechtecke, Kreise, Text)</legend>
|
||||
<legend>Gerätedesign (SVG-Editor)</legend>
|
||||
|
||||
<input type="hidden" name="shape_definition" id="shape-definition" value="<?php echo htmlspecialchars($shapeDefinition); ?>">
|
||||
|
||||
<div class="shape-editor">
|
||||
<div class="shape-editor-canvas">
|
||||
<svg id="shape-canvas" viewBox="0 0 400 200" role="img" aria-label="Gerätezeichnung">
|
||||
<rect width="100%" height="100%" fill="#f8f8f8" stroke="#ddd" stroke-width="1"></rect>
|
||||
</svg>
|
||||
<div class="shape-meta-controls">
|
||||
<div class="shape-meta-column">
|
||||
<label for="shape-meta-form-factor">Formfaktor</label>
|
||||
<select id="shape-meta-form-factor">
|
||||
<option value="other">Andere/Breiter</option>
|
||||
<option value="10">10 Zoll Front</option>
|
||||
<option value="19">19 Zoll Front</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="shape-editor-controls">
|
||||
<div class="form-group">
|
||||
<label for="shape-type">Form</label>
|
||||
<select id="shape-type">
|
||||
<option value="rect">Rechteck</option>
|
||||
<option value="circle">Kreis</option>
|
||||
<option value="text">Text</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="shape-control-grid">
|
||||
<label>
|
||||
x
|
||||
<input type="number" id="shape-x" value="20" step="1">
|
||||
</label>
|
||||
<label>
|
||||
y
|
||||
<input type="number" id="shape-y" value="20" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Breite
|
||||
<input type="number" id="shape-width" value="120" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Höhe
|
||||
<input type="number" id="shape-height" value="60" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Radius
|
||||
<input type="number" id="shape-radius" value="30" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Text
|
||||
<input type="text" id="shape-text" value="Label">
|
||||
</label>
|
||||
<label>
|
||||
Füllung
|
||||
<input type="color" id="shape-fill" value="#cccccc">
|
||||
</label>
|
||||
<label>
|
||||
Strich
|
||||
<input type="color" id="shape-stroke" value="#333333">
|
||||
</label>
|
||||
<label>
|
||||
Strichbreite
|
||||
<input type="number" id="shape-stroke-width" value="1" step="0.5">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button button-primary" id="shape-add">Form hinzufügen</button>
|
||||
<p class="hint">Shapes werden als JSON gespeichert und können jederzeit angepasst werden.</p>
|
||||
<div class="shape-meta-column">
|
||||
<label for="shape-meta-rack-height">Höhe (HE)</label>
|
||||
<input type="number" id="shape-meta-rack-height" min="1" step="1" value="1">
|
||||
</div>
|
||||
<div class="shape-meta-column">
|
||||
<label for="shape-meta-canvas-width">Zeichenfläche Breite (px)</label>
|
||||
<input type="number" id="shape-meta-canvas-width" min="200" step="10" value="800">
|
||||
</div>
|
||||
<div class="shape-meta-column">
|
||||
<label for="shape-meta-canvas-height">Zeichenfläche Höhe (px)</label>
|
||||
<input type="number" id="shape-meta-canvas-height" min="120" step="10" value="360">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shape-list">
|
||||
<h4>Shapes</h4>
|
||||
<ul id="shape-list"></ul>
|
||||
<input type="hidden" name="shape_definition" id="shape-definition" value="<?php echo htmlspecialchars($shapeDefinition); ?>">
|
||||
|
||||
<div class="shape-editor" id="device-type-shape-editor">
|
||||
<div class="shape-toolbox">
|
||||
<h4>Werkzeuge</h4>
|
||||
<p class="hint">Objekttyp anklicken, im Canvas diagonal ziehen und mit Loslassen fixieren.</p>
|
||||
<div class="shape-tool-list">
|
||||
<button type="button" class="shape-tool" draggable="true" data-shape-template="rect">Rechteck</button>
|
||||
<button type="button" class="shape-tool" draggable="true" data-shape-template="circle">Kreis</button>
|
||||
<button type="button" class="shape-tool" draggable="true" data-shape-template="text">Text</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shape-editor-canvas">
|
||||
<svg id="shape-canvas" viewBox="0 0 800 360" role="img" aria-label="Gerätezeichnung">
|
||||
<rect width="100%" height="100%" fill="#f8f8f8" stroke="#ddd" stroke-width="1"></rect>
|
||||
</svg>
|
||||
<p class="hint">Bestehende SVG-Objekte sind anklickbar und per Drag-and-Drop verschiebbar.</p>
|
||||
</div>
|
||||
|
||||
<div class="shape-overlay" id="shape-overlay">
|
||||
<h4>Objekt-Parameter</h4>
|
||||
<p class="shape-overlay-empty" id="shape-overlay-empty">Kein Objekt ausgewählt.</p>
|
||||
|
||||
<div class="shape-overlay-form" id="shape-overlay-form" hidden>
|
||||
<div class="shape-control-grid">
|
||||
<label>
|
||||
Typ
|
||||
<input type="text" id="shape-param-type" readonly>
|
||||
</label>
|
||||
<label>
|
||||
X
|
||||
<input type="number" id="shape-param-x" step="1">
|
||||
</label>
|
||||
<label>
|
||||
Y
|
||||
<input type="number" id="shape-param-y" step="1">
|
||||
</label>
|
||||
<label data-field="width">
|
||||
Breite
|
||||
<input type="number" id="shape-param-width" step="1">
|
||||
</label>
|
||||
<label data-field="height">
|
||||
Höhe
|
||||
<input type="number" id="shape-param-height" step="1">
|
||||
</label>
|
||||
<label data-field="radius">
|
||||
Radius
|
||||
<input type="number" id="shape-param-radius" step="1">
|
||||
</label>
|
||||
<label data-field="font_size">
|
||||
Font-Size
|
||||
<input type="number" id="shape-param-font-size" step="1">
|
||||
</label>
|
||||
<label data-field="text">
|
||||
Text
|
||||
<input type="text" id="shape-param-text">
|
||||
</label>
|
||||
<label>
|
||||
Füllung
|
||||
<input type="color" id="shape-param-fill">
|
||||
</label>
|
||||
<label>
|
||||
Strich
|
||||
<input type="color" id="shape-param-stroke">
|
||||
</label>
|
||||
<label>
|
||||
Strichbreite
|
||||
<input type="number" id="shape-param-stroke-width" step="0.5" min="0">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="shape-port-settings">
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="shape-param-is-port">
|
||||
Dieses Objekt ist ein Port
|
||||
</label>
|
||||
<label id="shape-port-name-label" hidden>
|
||||
Port-Name
|
||||
<select id="shape-param-port-name">
|
||||
<option value="">- Port wählen -</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="shape-overlay-actions">
|
||||
<button type="button" class="button button-danger" id="shape-delete">Objekt entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -193,32 +261,51 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
|
||||
<table class="port-definition-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th></th>
|
||||
<th>Port-Typ</th>
|
||||
<th>Löschen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="port-definition-body">
|
||||
<?php if (!empty($ports)): ?>
|
||||
<?php foreach ($ports as $port): ?>
|
||||
<?php foreach ($ports as $idx => $port): ?>
|
||||
<tr>
|
||||
<td><?php echo htmlspecialchars($port['name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($port['port_type_id'] ?? '-'); ?></td>
|
||||
<td><a href="#" class="button button-small button-danger">Entfernen</a></td>
|
||||
<td><?php echo (int)($idx + 1); ?></td>
|
||||
<td>
|
||||
<input type="hidden" name="port_rows[<?php echo (int)$idx; ?>][id]" value="<?php echo (int)$port['id']; ?>">
|
||||
<input type="text" name="port_rows[<?php echo (int)$idx; ?>][name]" value="<?php echo htmlspecialchars($port['name']); ?>" placeholder="z.B. Gi1/0/1">
|
||||
</td>
|
||||
<td>
|
||||
<select name="port_rows[<?php echo (int)$idx; ?>][port_type_id]">
|
||||
<option value="">- Kein Typ -</option>
|
||||
<?php foreach ($portTypes as $portType): ?>
|
||||
<option value="<?php echo (int)$portType['id']; ?>" <?php echo ((int)($port['port_type_id'] ?? 0) === (int)$portType['id']) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($portType['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" name="port_rows[<?php echo (int)$idx; ?>][delete]" value="1">
|
||||
entfernen
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="3"><em>Noch keine Ports definiert.</em></td>
|
||||
<td colspan="4"><em>Noch keine Ports definiert.</em></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<input type="hidden" id="shape-port-options" value="<?php echo htmlspecialchars(json_encode(array_values(array_column($ports, 'name'))), ENT_QUOTES); ?>">
|
||||
|
||||
<div style="margin-top: 15px;">
|
||||
<input type="text" id="port_name" placeholder="Port-Name (z.B. GigabitEthernet 1/1)">
|
||||
<input type="text" id="port_type" placeholder="Port-Typ (z.B. RJ45)">
|
||||
<button type="button" class="button" onclick="addPortRow()">+ Port hinzufügen</button>
|
||||
<div class="port-actions">
|
||||
<button type="button" class="button" id="add-port-row">+ Port hinzufügen</button>
|
||||
<small>Ports können nach dem ersten Speichern jederzeit einzeln bearbeitet und gelöscht werden.</small>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -237,213 +324,51 @@ $pageTitle = $isEdit ? "Gerätetyp bearbeiten: " . htmlspecialchars($deviceType[
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.device-type-edit {
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.edit-form fieldset {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-form legend {
|
||||
padding: 0 10px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="file"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.port-definition-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.port-definition-table th,
|
||||
.port-definition-table td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.port-definition-table th {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.shape-editor {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.shape-editor-canvas {
|
||||
flex: 1 1 320px;
|
||||
min-width: 280px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.shape-editor-canvas svg {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.shape-editor-controls {
|
||||
flex: 1 1 220px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.shape-control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.shape-control-grid label {
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shape-control-grid input,
|
||||
.shape-control-grid select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.shape-list {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.shape-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 6px 0 0;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
max-height: 130px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shape-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.shape-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shape-list button {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 15px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function addPortRow() {
|
||||
const name = document.getElementById('port_name').value;
|
||||
const type = document.getElementById('port_type').value;
|
||||
|
||||
if (!name.trim()) {
|
||||
alert('Port-Name erforderlich');
|
||||
const body = document.getElementById('port-definition-body');
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Neue Reihe zur Tabelle hinzufügen
|
||||
alert('Port hinzufügen funktioniert noch nicht');
|
||||
const emptyRow = body.querySelector('tr td em');
|
||||
if (emptyRow) {
|
||||
emptyRow.closest('tr').remove();
|
||||
}
|
||||
|
||||
document.getElementById('port_name').value = '';
|
||||
document.getElementById('port_type').value = '';
|
||||
const rowCount = body.querySelectorAll('tr').length;
|
||||
const index = rowCount;
|
||||
const number = rowCount + 1;
|
||||
|
||||
const portTypeOptions = `<?php
|
||||
$optionHtml = '<option value="">- Kein Typ -</option>';
|
||||
foreach ($portTypes as $pt) {
|
||||
$optionHtml .= '<option value="' . (int)$pt['id'] . '">' . htmlspecialchars($pt['name'], ENT_QUOTES) . '</option>';
|
||||
}
|
||||
echo str_replace('`', '\`', $optionHtml);
|
||||
?>`;
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${number}</td>
|
||||
<td>
|
||||
<input type="hidden" name="port_rows[${index}][id]" value="">
|
||||
<input type="text" name="port_rows[${index}][name]" value="" placeholder="z.B. Gi1/0/1">
|
||||
</td>
|
||||
<td>
|
||||
<select name="port_rows[${index}][port_type_id]">
|
||||
${portTypeOptions}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" name="port_rows[${index}][delete]" value="1">
|
||||
entfernen
|
||||
</label>
|
||||
</td>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
}
|
||||
|
||||
function confirmDelete(id) {
|
||||
@@ -452,174 +377,13 @@ function confirmDelete(id) {
|
||||
alert('Löschen noch nicht implementiert');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hiddenInput = document.getElementById('shape-definition');
|
||||
const svgCanvas = document.getElementById('shape-canvas');
|
||||
const shapeList = document.getElementById('shape-list');
|
||||
const addShapeButton = document.getElementById('shape-add');
|
||||
const typeSelect = document.getElementById('shape-type');
|
||||
const xInput = document.getElementById('shape-x');
|
||||
const yInput = document.getElementById('shape-y');
|
||||
const widthInput = document.getElementById('shape-width');
|
||||
const heightInput = document.getElementById('shape-height');
|
||||
const radiusInput = document.getElementById('shape-radius');
|
||||
const textInput = document.getElementById('shape-text');
|
||||
const fillInput = document.getElementById('shape-fill');
|
||||
const strokeInput = document.getElementById('shape-stroke');
|
||||
const strokeWidthInput = document.getElementById('shape-stroke-width');
|
||||
|
||||
if (!hiddenInput || !svgCanvas || !shapeList) {
|
||||
return;
|
||||
const addButton = document.getElementById('add-port-row');
|
||||
if (addButton) {
|
||||
addButton.addEventListener('click', addPortRow);
|
||||
}
|
||||
|
||||
let shapes = [];
|
||||
|
||||
function parseShapes() {
|
||||
try {
|
||||
const parsed = JSON.parse(hiddenInput.value || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistShapes() {
|
||||
hiddenInput.value = JSON.stringify(shapes);
|
||||
}
|
||||
|
||||
function clearGenerated() {
|
||||
svgCanvas.querySelectorAll('.generated-shape').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
function createSvgElement(name) {
|
||||
return document.createElementNS('http://www.w3.org/2000/svg', name);
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
clearGenerated();
|
||||
shapes.forEach((shape) => {
|
||||
let el;
|
||||
const fill = shape.fill || '#cccccc';
|
||||
const stroke = shape.stroke || '#333333';
|
||||
const strokeWidth = typeof shape.strokeWidth === 'number' ? shape.strokeWidth : 1;
|
||||
|
||||
if (shape.type === 'rect') {
|
||||
el = createSvgElement('rect');
|
||||
el.setAttribute('x', shape.x ?? 10);
|
||||
el.setAttribute('y', shape.y ?? 10);
|
||||
el.setAttribute('width', shape.width ?? 120);
|
||||
el.setAttribute('height', shape.height ?? 60);
|
||||
} else if (shape.type === 'circle') {
|
||||
el = createSvgElement('circle');
|
||||
el.setAttribute('cx', shape.x ?? 60);
|
||||
el.setAttribute('cy', shape.y ?? 60);
|
||||
el.setAttribute('r', shape.r ?? 30);
|
||||
} else if (shape.type === 'text') {
|
||||
el = createSvgElement('text');
|
||||
el.setAttribute('x', shape.x ?? 30);
|
||||
el.setAttribute('y', shape.y ?? 20);
|
||||
el.setAttribute('fill', fill);
|
||||
el.setAttribute('font-size', '18');
|
||||
el.setAttribute('text-anchor', 'middle');
|
||||
el.setAttribute('dominant-baseline', 'central');
|
||||
el.textContent = shape.text || 'Text';
|
||||
el.classList.add('generated-shape');
|
||||
svgCanvas.appendChild(el);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.setAttribute('fill', fill);
|
||||
el.setAttribute('stroke', stroke);
|
||||
el.setAttribute('stroke-width', strokeWidth);
|
||||
el.classList.add('generated-shape');
|
||||
svgCanvas.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
shapeList.innerHTML = '';
|
||||
if (shapes.length === 0) {
|
||||
const empty = document.createElement('li');
|
||||
empty.innerHTML = '<em>Noch keine Formen definiert.</em>';
|
||||
shapeList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
shapes.forEach((shape, index) => {
|
||||
const li = document.createElement('li');
|
||||
const label = document.createElement('span');
|
||||
const typeLabels = {
|
||||
rect: 'Rechteck',
|
||||
circle: 'Kreis',
|
||||
text: 'Text'
|
||||
};
|
||||
const summary = `${typeLabels[shape.type] || shape.type} @ (${shape.x ?? 0}, ${shape.y ?? 0})`;
|
||||
label.textContent = summary;
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = 'Entfernen';
|
||||
removeButton.classList.add('button', 'button-small', 'button-danger');
|
||||
removeButton.dataset.removeShape = index;
|
||||
|
||||
li.appendChild(label);
|
||||
li.appendChild(removeButton);
|
||||
shapeList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function getNumberValue(input, fallback) {
|
||||
const value = parseFloat(input.value);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
addShapeButton.addEventListener('click', () => {
|
||||
const type = typeSelect.value;
|
||||
const baseShape = {
|
||||
type,
|
||||
x: getNumberValue(xInput, 20),
|
||||
y: getNumberValue(yInput, 20),
|
||||
fill: fillInput.value || '#cccccc',
|
||||
stroke: strokeInput.value || '#333333',
|
||||
strokeWidth: getNumberValue(strokeWidthInput, 1)
|
||||
};
|
||||
|
||||
if (type === 'rect') {
|
||||
baseShape.width = getNumberValue(widthInput, 120);
|
||||
baseShape.height = getNumberValue(heightInput, 60);
|
||||
} else if (type === 'circle') {
|
||||
baseShape.r = getNumberValue(radiusInput, 30);
|
||||
} else if (type === 'text') {
|
||||
baseShape.text = textInput.value || 'Text';
|
||||
}
|
||||
|
||||
shapes.push(baseShape);
|
||||
renderCanvas();
|
||||
renderList();
|
||||
persistShapes();
|
||||
});
|
||||
|
||||
shapeList.addEventListener('click', (event) => {
|
||||
if (!event.target.dataset.removeShape) {
|
||||
return;
|
||||
}
|
||||
const index = Number(event.target.dataset.removeShape);
|
||||
shapes.splice(index, 1);
|
||||
renderCanvas();
|
||||
renderList();
|
||||
persistShapes();
|
||||
});
|
||||
|
||||
shapes = parseShapes();
|
||||
renderCanvas();
|
||||
renderList();
|
||||
persistShapes();
|
||||
});
|
||||
</script>
|
||||
|
||||
<script src="/assets/js/device-type-shape-editor.js" defer></script>
|
||||
|
||||
@@ -22,6 +22,8 @@ $name = trim($_POST['name'] ?? '');
|
||||
$category = $_POST['category'] ?? 'other';
|
||||
$comment = trim($_POST['comment'] ?? '');
|
||||
$seedPortCount = max(0, (int)($_POST['seed_ports'] ?? 0));
|
||||
$defaultPortTypeId = normalizeNullableInt($_POST['default_port_type_id'] ?? null);
|
||||
$portRows = is_array($_POST['port_rows'] ?? null) ? $_POST['port_rows'] : [];
|
||||
$rawShapes = trim($_POST['shape_definition'] ?? '');
|
||||
$shapeDefinition = '[]';
|
||||
if ($rawShapes !== '') {
|
||||
@@ -126,7 +128,8 @@ if ($deviceTypeId > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
seedDeviceTypePorts($sql, $deviceTypeId, $seedPortCount);
|
||||
seedDeviceTypePorts($sql, $deviceTypeId, $seedPortCount, $defaultPortTypeId);
|
||||
syncDeviceTypePorts($sql, $deviceTypeId, $portRows);
|
||||
|
||||
$_SESSION['success'] = $deviceTypeId ? "Gerätetyp gespeichert" : "Fehler beim Speichern";
|
||||
|
||||
@@ -136,7 +139,7 @@ $_SESSION['success'] = $deviceTypeId ? "Gerätetyp gespeichert" : "Fehler beim S
|
||||
header('Location: ?module=device_types&action=list');
|
||||
exit;
|
||||
|
||||
function seedDeviceTypePorts($sql, $deviceTypeId, $targetCount)
|
||||
function seedDeviceTypePorts($sql, $deviceTypeId, $targetCount, $defaultPortTypeId = null)
|
||||
{
|
||||
if ($deviceTypeId <= 0 || $targetCount <= 0) {
|
||||
return;
|
||||
@@ -153,11 +156,101 @@ function seedDeviceTypePorts($sql, $deviceTypeId, $targetCount)
|
||||
|
||||
for ($i = 1; $i <= $toCreate; $i++) {
|
||||
$index = $existing + $i;
|
||||
$sql->set(
|
||||
"INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y) VALUES (?, ?, NULL, 0, 0)",
|
||||
"is",
|
||||
[$deviceTypeId, "Port $index"]
|
||||
);
|
||||
if ($defaultPortTypeId) {
|
||||
$sql->set(
|
||||
"INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y) VALUES (?, ?, ?, 0, 0)",
|
||||
"isi",
|
||||
[$deviceTypeId, "Port $index", $defaultPortTypeId]
|
||||
);
|
||||
} else {
|
||||
$sql->set(
|
||||
"INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y) VALUES (?, ?, NULL, 0, 0)",
|
||||
"is",
|
||||
[$deviceTypeId, "Port $index"]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncDeviceTypePorts($sql, $deviceTypeId, array $rows)
|
||||
{
|
||||
if ($deviceTypeId <= 0 || empty($rows)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingPorts = $sql->get(
|
||||
"SELECT id FROM device_type_ports WHERE device_type_id = ?",
|
||||
"i",
|
||||
[$deviceTypeId]
|
||||
);
|
||||
$existingIds = [];
|
||||
foreach ($existingPorts as $existingPort) {
|
||||
$existingIds[(int)$existingPort['id']] = true;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$rowId = (int)($row['id'] ?? 0);
|
||||
$name = trim($row['name'] ?? '');
|
||||
$portTypeId = normalizeNullableInt($row['port_type_id'] ?? null);
|
||||
$delete = (int)($row['delete'] ?? 0) === 1;
|
||||
|
||||
if ($rowId > 0 && !isset($existingIds[$rowId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($delete) {
|
||||
if ($rowId > 0) {
|
||||
$sql->set(
|
||||
"DELETE FROM device_type_ports WHERE id = ? AND device_type_id = ?",
|
||||
"ii",
|
||||
[$rowId, $deviceTypeId]
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rowId > 0) {
|
||||
if ($portTypeId) {
|
||||
$sql->set(
|
||||
"UPDATE device_type_ports SET name = ?, port_type_id = ? WHERE id = ? AND device_type_id = ?",
|
||||
"siii",
|
||||
[$name, $portTypeId, $rowId, $deviceTypeId]
|
||||
);
|
||||
} else {
|
||||
$sql->set(
|
||||
"UPDATE device_type_ports SET name = ?, port_type_id = NULL WHERE id = ? AND device_type_id = ?",
|
||||
"sii",
|
||||
[$name, $rowId, $deviceTypeId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if ($portTypeId) {
|
||||
$sql->set(
|
||||
"INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y) VALUES (?, ?, ?, 0, 0)",
|
||||
"isi",
|
||||
[$deviceTypeId, $name, $portTypeId]
|
||||
);
|
||||
} else {
|
||||
$sql->set(
|
||||
"INSERT INTO device_type_ports (device_type_id, name, port_type_id, x, y) VALUES (?, ?, NULL, 0, 0)",
|
||||
"is",
|
||||
[$deviceTypeId, $name]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNullableInt($value)
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$intValue = (int)$value;
|
||||
return $intValue > 0 ? $intValue : null;
|
||||
}
|
||||
|
||||
|
||||
50
app/modules/devices/delete.php
Normal file
50
app/modules/devices/delete.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
/**
|
||||
* app/modules/devices/delete.php
|
||||
*
|
||||
* Löscht ein Gerät inkl. abhängiger Verbindungen.
|
||||
*/
|
||||
|
||||
$deviceId = (int)($_GET['id'] ?? 0);
|
||||
|
||||
if ($deviceId <= 0) {
|
||||
$_SESSION['error'] = "Ungültige Geräte-ID";
|
||||
header('Location: ?module=devices&action=list');
|
||||
exit;
|
||||
}
|
||||
|
||||
$device = $sql->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;
|
||||
@@ -235,8 +235,7 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
|
||||
<script>
|
||||
function confirmDelete(id) {
|
||||
if (confirm('Dieses Gerät wirklich löschen?')) {
|
||||
// TODO: AJAX-Delete implementieren
|
||||
alert('Löschen noch nicht implementiert');
|
||||
window.location.href = '?module=devices&action=delete&id=' + encodeURIComponent(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -331,8 +331,7 @@ $racks = $sql->get("SELECT id, name FROM racks ORDER BY name", "", []);
|
||||
<script>
|
||||
function confirmDelete(id) {
|
||||
if (confirm('Dieses Gerät wirklich löschen?')) {
|
||||
// TODO: AJAX-Delete implementieren
|
||||
alert('Löschen noch nicht implementiert');
|
||||
window.location.href = '?module=devices&action=delete&id=' + encodeURIComponent(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -119,6 +119,9 @@ function copyDevicePortsFromType($sql, $deviceId, $deviceTypeId)
|
||||
}
|
||||
|
||||
$portTypeId = isset($port['port_type_id']) ? (int)$port['port_type_id'] : null;
|
||||
if ($portTypeId !== null && $portTypeId <= 0) {
|
||||
$portTypeId = null;
|
||||
}
|
||||
|
||||
if ($portTypeId === null) {
|
||||
$sql->set(
|
||||
|
||||
@@ -5,12 +5,9 @@
|
||||
* Floor / Stockwerk anlegen oder bearbeiten
|
||||
* - Name, Ebene, Beschreibung
|
||||
* - Zugehöriges Gebäude
|
||||
* - SVG-Grundriss (optional)
|
||||
* - SVG-Grundriss mit Linien-Editor (Polylinien)
|
||||
*/
|
||||
|
||||
// =========================
|
||||
// Kontext bestimmen
|
||||
// =========================
|
||||
$floorId = (int)($_GET['id'] ?? 0);
|
||||
$floor = null;
|
||||
|
||||
@@ -24,26 +21,26 @@ if ($floorId > 0) {
|
||||
|
||||
$isEdit = !empty($floor);
|
||||
$pageTitle = $isEdit ? "Stockwerk bearbeiten: " . htmlspecialchars($floor['name']) : "Neues Stockwerk";
|
||||
|
||||
// =========================
|
||||
// Gebäude laden
|
||||
// =========================
|
||||
$buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
|
||||
$existingSvgContent = '';
|
||||
if (!empty($floor['svg_path'])) {
|
||||
$relativePath = ltrim((string)$floor['svg_path'], "/\\");
|
||||
$absolutePath = __DIR__ . '/../../' . $relativePath;
|
||||
if (is_file($absolutePath) && is_readable($absolutePath)) {
|
||||
$existingSvgContent = file_get_contents($absolutePath) ?: '';
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="floor-edit">
|
||||
<h1><?php echo $pageTitle; ?></h1>
|
||||
|
||||
<form method="post" action="?module=floors&action=save" enctype="multipart/form-data" class="edit-form">
|
||||
|
||||
<?php if ($isEdit): ?>
|
||||
<input type="hidden" name="id" value="<?php echo $floorId; ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- =========================
|
||||
Basisdaten
|
||||
========================= -->
|
||||
<fieldset>
|
||||
<legend>Allgemein</legend>
|
||||
|
||||
@@ -58,8 +55,7 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
<label for="level">Ebene</label>
|
||||
<input type="number" id="level" name="level"
|
||||
value="<?php echo htmlspecialchars($floor['level'] ?? '0'); ?>"
|
||||
placeholder="z.B. 0 für Erdgeschoss, 1 für 1. OG">
|
||||
<small>Dient zur Sortierung</small>
|
||||
placeholder="z.B. 0 für EG, 1 für 1. OG">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -69,19 +65,15 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- =========================
|
||||
Gebäude & Standort
|
||||
========================= -->
|
||||
<fieldset>
|
||||
<legend>Standort</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="building_id">Gebäude <span class="required">*</span></label>
|
||||
<select id="building_id" name="building_id" required>
|
||||
<option value="">- Wählen -</option>
|
||||
<?php foreach ($buildings as $building): ?>
|
||||
<option value="<?php echo $building['id']; ?>"
|
||||
<?php echo ($floor['building_id'] ?? 0) == $building['id'] ? 'selected' : ''; ?>>
|
||||
<option value="<?php echo (int)$building['id']; ?>"
|
||||
<?php echo ((int)($floor['building_id'] ?? 0) === (int)$building['id']) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($building['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
@@ -89,43 +81,65 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- =========================
|
||||
SVG-Grundriss (optional)
|
||||
========================= -->
|
||||
<fieldset>
|
||||
<legend>Grundriss (SVG)</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="svg_file">SVG-Datei hochladen</label>
|
||||
<input type="file" id="svg_file" name="svg_file" accept=".svg">
|
||||
<small>Optionales Floorplan-SVG. Kann später im Editor bearbeitet werden.</small>
|
||||
<small>Optional. Wenn ein Zeichnungsinhalt im Editor erstellt wird, wird dieser beim Speichern bevorzugt.</small>
|
||||
</div>
|
||||
|
||||
<?php if ($isEdit && $floor['svg_path']): ?>
|
||||
<div class="form-group">
|
||||
<label>Aktueller Grundriss:</label>
|
||||
<p><small><?php echo htmlspecialchars($floor['svg_path']); ?></small></p>
|
||||
<textarea id="floor-svg-content" name="floor_svg_content" hidden><?php echo htmlspecialchars($existingSvgContent); ?></textarea>
|
||||
|
||||
<div id="floor-svg-editor" class="floor-svg-editor">
|
||||
<div class="floor-svg-tools">
|
||||
<h4>Zeichenwerkzeug</h4>
|
||||
<button type="button" class="button" id="floor-start-polyline">Neue Linie starten</button>
|
||||
<button type="button" class="button" id="floor-finish-polyline">Linie beenden</button>
|
||||
<button type="button" class="button button-danger" id="floor-delete-polyline">Ausgewählte Linie löschen</button>
|
||||
<button type="button" class="button button-danger" id="floor-clear-drawing">Alles löschen</button>
|
||||
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="floor-lock-45">
|
||||
Winkel auf 45°-Vielfache begrenzen
|
||||
</label>
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="floor-snap-guides" checked>
|
||||
Ecken an Hilfslinien snappen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="floor-svg-canvas-wrap">
|
||||
<svg id="floor-svg-canvas" viewBox="0 0 2000 1000" role="img" aria-label="Stockwerk-Zeichnung"></svg>
|
||||
<p class="hint">Linien müssen nicht geschlossen sein. Klick auf freie Fläche fügt Punkte hinzu, Punkte sind per Drag verschiebbar.</p>
|
||||
</div>
|
||||
|
||||
<div class="floor-guides">
|
||||
<h4>Hilfslinien</h4>
|
||||
<div class="floor-guide-form">
|
||||
<select id="floor-guide-orientation">
|
||||
<option value="vertical">Vertikal</option>
|
||||
<option value="horizontal">Horizontal</option>
|
||||
</select>
|
||||
<input type="number" id="floor-guide-position" placeholder="Position (SVG-Koordinate)">
|
||||
<button type="button" class="button" id="floor-add-guide">Hilfslinie setzen</button>
|
||||
</div>
|
||||
<ul id="floor-guide-list" class="guide-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
|
||||
<!-- =========================
|
||||
Aktionen
|
||||
========================= -->
|
||||
<fieldset class="form-actions">
|
||||
<button type="submit" class="button button-primary">Speichern</button>
|
||||
<a href="?module=floors&action=list" class="button">Abbrechen</a>
|
||||
<?php if ($isEdit): ?>
|
||||
<a href="#" class="button button-danger" onclick="confirmDelete(<?php echo $floorId; ?>)">Löschen</a>
|
||||
<?php endif; ?>
|
||||
</fieldset>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.floor-edit {
|
||||
max-width: 800px;
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
@@ -147,7 +161,6 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
.edit-form legend {
|
||||
padding: 0 10px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -172,14 +185,68 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
.floor-svg-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr 280px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
.floor-svg-tools,
|
||||
.floor-guides,
|
||||
.floor-svg-canvas-wrap {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.floor-svg-tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.floor-svg-canvas-wrap svg {
|
||||
width: 100%;
|
||||
min-height: 560px;
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
border: 1px solid #eee;
|
||||
background: #fafafa;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.floor-guide-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.guide-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.inline-checkbox {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.82rem;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.required {
|
||||
@@ -189,7 +256,7 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -211,64 +278,11 @@ $buildings = $sql->get("SELECT id, name FROM buildings ORDER BY name", "", []);
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
opacity: 0.8;
|
||||
@media (max-width: 1200px) {
|
||||
.floor-svg-editor {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function confirmDelete(id) {
|
||||
if (confirm('Dieses Stockwerk wirklich löschen? Alle Räume und Racks werden gelöscht.')) {
|
||||
// TODO: AJAX-Delete implementieren
|
||||
alert('Löschen noch nicht implementiert');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
========================= -->
|
||||
|
||||
<fieldset>
|
||||
<legend>Grundriss / Floorplan</legend>
|
||||
|
||||
<div class="svg-editor-container">
|
||||
<svg
|
||||
id="floor-svg"
|
||||
viewBox="0 0 2000 1000"
|
||||
width="100%"
|
||||
height="600"
|
||||
>
|
||||
<!-- TODO: Floorplan SVG laden -->
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
Räume und Netzwerkdosen per Drag & Drop platzieren. Nummerierung und Bezeichnungen editierbar.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- =========================
|
||||
Aktionen
|
||||
========================= -->
|
||||
|
||||
<fieldset>
|
||||
<button type="submit">Speichern</button>
|
||||
<button type="button" onclick="history.back()">Abbrechen</button>
|
||||
<!-- TODO: Löschen, falls edit -->
|
||||
</fieldset>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- =========================
|
||||
JS-Konfiguration
|
||||
========================= -->
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Konfiguration für Floorplan SVG-Editor
|
||||
*/
|
||||
|
||||
// TODO: Floor-ID aus PHP setzen
|
||||
// window.FLOOR_ID = <?= (int)$floorId ?>;
|
||||
|
||||
// TODO: Räume / Dosen an JS übergeben
|
||||
// window.ROOMS = <?= json_encode($rooms) ?>;
|
||||
</script>
|
||||
<script src="/assets/js/floor-svg-editor.js" defer></script>
|
||||
|
||||
@@ -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, '<svg') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$relativePath = null;
|
||||
if ($floorId > 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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="/assets/css/app.css">
|
||||
<link rel="stylesheet" href="/assets/css/device-type-edit.css">
|
||||
|
||||
<!-- JS -->
|
||||
<script src="/assets/js/app.js" defer></script>
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user