diff --git a/public/assets/app.css b/public/assets/app.css index b0d66d6..81f9421 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -197,6 +197,11 @@ button, input, select { padding: 14px; display: grid; gap: 10px; + cursor: grab; +} + +.component-card:active { + cursor: grabbing; } .component-card__preview { @@ -400,12 +405,19 @@ button, input, select { pointer-events: auto; cursor: pointer; z-index: 3; + opacity: 0.32; + transition: opacity 120ms ease, transform 120ms ease; } .rack-insert-zone.is-disabled { cursor: not-allowed; } +.rack-insert-zone.is-preview, +.rack-insert-zone:hover { + opacity: 1; +} + .rack-insert-zone__ear { width: 28px; height: calc(100% - 4px); @@ -482,11 +494,11 @@ button, input, select { user-select: none; overflow: hidden; border-radius: 8px; - border: 1px solid rgba(34, 38, 44, 0.55); - background: linear-gradient(180deg, rgba(237, 241, 246, 0.96), rgba(205, 213, 222, 0.96)); + border: 1px solid rgba(32, 36, 42, 0.8); + background: linear-gradient(180deg, rgba(235, 239, 244, 0.98), rgba(191, 200, 211, 0.98)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 10px 18px rgba(5, 6, 7, 0.28); + inset 0 1px 0 rgba(255, 255, 255, 0.94), + 0 8px 14px rgba(5, 6, 7, 0.24); touch-action: none; cursor: grab; } @@ -509,8 +521,10 @@ button, input, select { display: flex; flex-direction: column; justify-content: space-between; - padding: 8px 10px 8px 12px; - background: linear-gradient(90deg, rgba(250, 251, 252, 0.86), rgba(250, 251, 252, 0.08) 38%, rgba(250, 251, 252, 0.72)); + padding: 6px 10px 6px 10px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 42%), + linear-gradient(90deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.06) 34%, rgba(255, 255, 255, 0.18)); } .rack-item__face { @@ -527,6 +541,7 @@ button, input, select { .rack-item__header strong { color: #17191c; + font-size: 1rem; } .rack-item__drag-hint { @@ -537,6 +552,7 @@ button, input, select { color: rgba(245, 247, 250, 0.95); font-size: 0.72rem; letter-spacing: 0.04em; + pointer-events: none; } .rack-item__remove { @@ -557,6 +573,10 @@ button, input, select { cursor: pointer; } +.rack-item__remove:hover { + background: rgba(134, 24, 24, 0.88); +} + .subpanel { padding: 16px; display: grid; @@ -646,7 +666,7 @@ button, input, select { position: relative; width: 100%; height: 100%; - padding: 8px 28px; + padding: 6px 24px; overflow: hidden; } @@ -699,7 +719,7 @@ button, input, select { .device-brand { position: absolute; - top: 7px; + top: 6px; left: 36px; font-size: 0.72rem; letter-spacing: 0.04em; @@ -710,7 +730,7 @@ button, input, select { .device-silkscreen { position: absolute; left: 36px; - bottom: 8px; + bottom: 6px; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; @@ -765,7 +785,8 @@ button, input, select { position: absolute; left: 40px; right: 70px; - top: 22px; + top: 48%; + transform: translateY(-50%); grid-template-columns: repeat(12, minmax(0, 1fr)); } @@ -792,7 +813,8 @@ button, input, select { position: absolute; left: 40px; right: 40px; - top: 20px; + top: 48%; + transform: translateY(-50%); grid-template-columns: repeat(12, minmax(0, 1fr)); } @@ -807,10 +829,11 @@ button, input, select { .device-uplink { position: absolute; - top: 20px; + top: 50%; right: 40px; width: 22px; height: 16px; + transform: translateY(-50%); border-radius: 4px; border: 1px solid rgba(110, 117, 127, 0.25); background: linear-gradient(180deg, #5b646f, #262b31); @@ -818,9 +841,10 @@ button, input, select { .device-sockets { position: absolute; - top: 18px; + top: 50%; left: 40px; right: 56px; + transform: translateY(-50%); grid-template-columns: repeat(8, minmax(0, 1fr)); align-items: center; } @@ -838,9 +862,10 @@ button, input, select { .device-display { position: absolute; left: 40px; - top: 16px; + top: 50%; width: 68px; height: 30px; + transform: translateY(-50%); border-radius: 8px; background: linear-gradient(180deg, rgba(46, 180, 156, 0.9), rgba(18, 97, 90, 0.92)), @@ -852,10 +877,11 @@ button, input, select { .device-vents { position: absolute; - top: 18px; + top: 50%; right: 42px; width: 96px; height: 22px; + transform: translateY(-50%); background: repeating-linear-gradient(90deg, rgba(102, 108, 118, 0.36) 0 3px, transparent 3px 7px); opacity: 0.8; @@ -879,8 +905,9 @@ button, input, select { position: absolute; left: 40px; right: 40px; - top: 18px; + top: 50%; height: 18px; + transform: translateY(-50%); border-radius: 4px 4px 12px 12px; background: linear-gradient(180deg, #d1d7df, #8f98a4); box-shadow: 0 5px 12px rgba(78, 82, 90, 0.18); diff --git a/public/assets/app.js b/public/assets/app.js index fb0f6e8..38c745e 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -9,6 +9,8 @@ const state = { dragPlacementId: null, pointerDrag: null, selectedComponentId: null, + libraryDragComponentId: null, + rackDropPreviewY: null, }; const ui = {}; @@ -64,6 +66,7 @@ function bindEvents() { ui.exportBom.addEventListener("click", copyBomCsv); document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); + document.addEventListener("dragend", handleLibraryDragEnd); } async function loadBootstrap() { @@ -152,7 +155,7 @@ function renderLibrary() { ui.componentLibrary.innerHTML = filtered .map( (component) => ` -
+
${renderComponentFace(component, "library")}
@@ -169,7 +172,7 @@ function renderLibrary() { ${formatCurrency(component.priceNet, component.currency)}
` @@ -182,40 +185,21 @@ function renderLibrary() { renderAll(); }); }); -} -function addComponentToRack(componentId) { - const component = state.components.find((entry) => entry.id === componentId); - const rack = getCurrentRackTemplate(); - if (!component || !rack) { - return; - } - - const position = findFirstFreePosition(component.heightU); - if (position === null) { - alert("Keine freie Position fuer diese Komponente im aktuellen Rack."); - return; - } - - state.placedItems.push({ - placementId: `p${state.nextPlacementId++}`, - componentId: component.id, - y: position, - x: getDefaultComponentX(component), + ui.componentLibrary.querySelectorAll("[data-component-drag-id]").forEach((card) => { + card.addEventListener("dragstart", (event) => beginLibraryDrag(event, card.dataset.componentDragId)); }); - - renderAll(); } function renderSelectionInfo() { - const component = getComponent(state.selectedComponentId); + const component = getComponent(state.libraryDragComponentId || state.selectedComponentId); if (!component) { ui.selectedComponentInfo.textContent = "Kein Element ausgewaehlt"; ui.selectedComponentInfo.classList.remove("is-active"); return; } - ui.selectedComponentInfo.textContent = `${component.name} · ${component.heightU}U · freie HE-Zone anklicken`; + ui.selectedComponentInfo.textContent = `${component.name} · ${component.heightU}U · aus der Bibliothek ins Rack ziehen`; ui.selectedComponentInfo.classList.add("is-active"); } @@ -251,6 +235,10 @@ function renderRack() { const layer = document.getElementById("rack-items-layer"); layer.style.height = "100%"; + const bay = ui.rackGrid.querySelector(".rack-grid__bay"); + bay.addEventListener("dragover", handleRackDragOver); + bay.addEventListener("drop", handleRackDrop); + bay.addEventListener("dragleave", handleRackDragLeave); const insertionLayer = document.getElementById("rack-insertion-layer"); insertionLayer.innerHTML = renderInsertionZones(rack); @@ -282,7 +270,7 @@ function renderRack() { ${escapeHtml(component.name)}
${component.heightU}U · ${component.depthMm} mm · ${formatCurrency(component.priceNet, component.currency)}
- ${formatRackPosition(item.y)} + ${formatRackPosition(item.y, component)}
Ziehen zum verschieben
@@ -300,19 +288,20 @@ function renderRack() { } function insertSelectedComponentAt(y) { - if (!state.selectedComponentId) { + const componentId = state.libraryDragComponentId || state.selectedComponentId; + if (!componentId) { return; } - const component = getComponent(state.selectedComponentId); + const component = getComponent(componentId); const rack = getCurrentRackTemplate(); if (!component || !rack) { return; } const nextRect = { - left: getDefaultComponentX(component), - top: y, + left: 0, + top: snapToUnit(y), width: getComponentWidthPercent(component), height: getComponentHeightPx(component), }; @@ -340,9 +329,11 @@ function insertSelectedComponentAt(y) { state.placedItems.push({ placementId: `p${state.nextPlacementId++}`, componentId: component.id, - y, - x: getDefaultComponentX(component), + y: snapToUnit(y), + x: 0, }); + state.libraryDragComponentId = null; + state.rackDropPreviewY = null; state.selectedComponentId = null; renderAll(); } @@ -442,7 +433,7 @@ function renderCableSelectors() { if (!component) { return ""; } - return ``; + return ``; }) ) .join(""); @@ -628,43 +619,6 @@ async function copyBomCsv() { } } -function findFirstFreePosition(heightU) { - const rack = getCurrentRackTemplate(); - if (!rack) { - return null; - } - - const unitHeight = getUnitHeightPx(); - const testHeight = heightU * unitHeight; - for (let y = 0; y <= getRackHeightPx(rack) - testHeight; y += unitHeight) { - const blocked = state.placedItems.some((item) => { - const otherComponent = getComponent(item.componentId); - if (!otherComponent) { - return false; - } - return rectanglesOverlap( - { - left: getDefaultComponentX(component), - top: y, - width: getComponentWidthPercent(component), - height: testHeight, - }, - { - left: item.x, - top: item.y, - width: getComponentWidthPercent(otherComponent), - height: getComponentHeightPx(otherComponent), - } - ); - }); - if (!blocked) { - return y; - } - } - - return null; -} - function getCurrentRackTemplate() { return state.bootstrap?.rackTemplates.find((template) => template.id === state.rackTemplateId) ?? null; } @@ -701,9 +655,7 @@ function beginPointerDrag(event, item, component) { const rect = event.currentTarget.getBoundingClientRect(); state.pointerDrag = { placementId: item.placementId, - offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, - widthPercent: getComponentWidthPercent(component), heightPx: getComponentHeightPx(component), }; event.currentTarget.setPointerCapture?.(event.pointerId); @@ -727,12 +679,10 @@ function handlePointerMove(event) { } const layerRect = layer.getBoundingClientRect(); - const widthPx = (state.pointerDrag.widthPercent / 100) * layerRect.width; - const xPx = clamp(event.clientX - layerRect.left - state.pointerDrag.offsetX, 0, layerRect.width - widthPx); const yPx = clamp(event.clientY - layerRect.top - state.pointerDrag.offsetY, 0, getRackHeightPx(rack) - state.pointerDrag.heightPx); - item.x = (xPx / layerRect.width) * 100; - item.y = yPx; + item.x = 0; + item.y = snapToUnit(yPx); renderAll(); } @@ -857,7 +807,11 @@ function renderSocketStrip(component) { } function renderInsertionZones(rack) { - const component = getComponent(state.selectedComponentId); + if (!state.libraryDragComponentId) { + return ""; + } + + const component = getComponent(state.libraryDragComponentId); if (!component) { return ""; } @@ -872,27 +826,28 @@ function renderInsertionZones(rack) { } return rectanglesOverlap( { - left: getDefaultComponentX(component), + left: 0, top: y, - width: getComponentWidthPercent(component), + width: 100, height: getComponentHeightPx(component), }, { - left: item.x, + left: 0, top: item.y, - width: getComponentWidthPercent(other), + width: 100, height: getComponentHeightPx(other), } ); }); + const isPreview = state.rackDropPreviewY !== null && Math.abs(state.rackDropPreviewY - y) < 1; zones.push(`