This commit is contained in:
2026-04-13 16:32:30 +08:00
parent dad17dbadd
commit 1de35f21d7
14 changed files with 1081 additions and 63 deletions

View File

@@ -27,9 +27,17 @@
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top shadow-sm">
<div class="container-fluid">
<div class="container-fluid position-relative d-flex justify-content-between align-items-center">
<a class="navbar-brand fw-bold" href="{{ url_for('main.index') }}"><i class="bi bi-printer me-2"></i>AIO 3D Slicer</a>
<div class="d-flex text-light align-items-center">
<div class="d-none d-md-flex mx-auto" style="position: absolute; left: 50%; transform: translateX(-50%);">
<div class="btn-group border border-secondary shadow-sm rounded-pill p-1 bg-dark" role="group" style="background-color: #1a1e21 !important;">
<a href="{{ url_for('main.files') }}" class="btn btn-sm rounded-pill {% if not request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-4{% else %}btn-transparent text-secondary border-0 px-3{% endif %}" style="transition: all 0.2s;"><i class="bi bi-layers me-1"></i>{{ _('Slicer') }}</a>
<a href="{{ url_for('printer.status') }}" class="btn btn-sm rounded-pill {% if request.blueprint == 'printer' %}btn-primary active fw-bold text-white px-4{% else %}btn-transparent text-secondary border-0 px-3{% endif %}" style="transition: all 0.2s;"><i class="bi bi-printer-fill me-1"></i>{{ _('Printer') }}</a>
</div>
</div>
<div class="d-flex text-light align-items-center ms-auto">
<div class="dropdown me-3">
<button class="btn btn-sm btn-outline-light dropdown-toggle" type="button" id="langDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-globe me-1"></i>{{ _('Language') }}
@@ -57,6 +65,48 @@
<div class="row">
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-white sidebar collapse border-end">
<div class="sidebar-sticky pt-3 px-2">
{% if request.blueprint == 'printer' %}
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
<span><i class="bi bi-list-task me-1"></i>{{ _('General Operations') }}</span>
</h6>
<ul class="nav flex-column nav-pills gap-1">
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.status' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.status') }}">
<i class="bi bi-activity me-2"></i>{{ _('Printer Status') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.prepare' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.prepare') }}">
<i class="bi bi-file-earmark-plus me-2"></i>{{ _('Prepare Print') }}
</a>
</li>
<li class="nav-item mb-1">
<a class="nav-link text-dark {% if request.endpoint == 'printer.control' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.control') }}">
<i class="bi bi-arrows-move me-2"></i>{{ _('Control') }}
</a>
</li>
</ul>
{% if current_user.is_authenticated and current_user.is_admin %}
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-2 text-muted fw-bold text-uppercase" style="font-size: 0.75rem;">
<span><i class="bi bi-shield-lock me-1"></i>{{ _('Admin / OctoPrint') }}</span>
</h6>
<ul class="nav flex-column nav-pills gap-1 mb-2">
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.octo_config' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.octo_config') }}">
<i class="bi bi-plug me-2"></i>{{ _('System Config') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'printer.octo_embed' %}active text-white shadow-sm{% endif %}" href="{{ url_for('printer.octo_embed') }}">
<i class="bi bi-window-sidebar me-2"></i>{{ _('OctoPrint Panel') }}
</a>
</li>
</ul>
{% endif %}
{% else %}
<ul class="nav flex-column nav-pills gap-1">
<li class="nav-item">
<a class="nav-link text-dark {% if request.endpoint == 'main.index' %}active text-white shadow-sm{% endif %}" href="{{ url_for('main.index') }}">
@@ -92,6 +142,8 @@
</li>
</ul>
{% endif %}
{% endif %}
</div>
</nav>

View File

@@ -28,7 +28,7 @@
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #57b357;"></span>{{ _('Support') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="SKIRT" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #00ffff;"></span>{{ _('Skirt') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="SUPPORT-INTERFACE" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #2b6b2b;"></span>{{ _('Support Interface') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="TRAVEL" style="cursor: pointer; transition: opacity 0.2s;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #405060;"></span>{{ _('Travel (Move)') }}</div>
<div class="mb-1 legend-item user-select-none" data-type="TRAVEL" style="cursor: pointer; transition: opacity 0.2s; opacity: 0.4;"><span class="d-inline-block rounded-circle me-2 border border-dark" style="width: 12px; height: 12px; background: #405060;"></span>{{ _('Travel (Move)') }}</div>
</div>
<!-- Bottom Slider (Intra-Layer Progress) -->
@@ -100,7 +100,7 @@ document.addEventListener('DOMContentLoaded', async function() {
uShowSupport: { value: 1.0 },
uShowSkirt: { value: 1.0 },
uShowSupportInterface: { value: 1.0 },
uShowTravel: { value: 1.0 },
uShowTravel: { value: 0.0 },
uShowDefault: { value: 1.0 }
},
vertexShader: `

View File

@@ -16,7 +16,7 @@
<!-- 3D Area -->
<div class="col-md-9 h-100 position-relative">
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa;"></div>
<div id="plater-container" class="w-100 h-100 rounded shadow-sm border border-secondary" style="overflow: hidden; background: #f8f9fa; position: relative;"></div>
<!-- Parameterized Scale Input Box -->
<div id="scale-panel" class="position-absolute top-50 start-0 translate-middle-y ms-5 ps-4 d-none" style="z-index: 10; pointer-events: none;">
@@ -174,6 +174,13 @@ let offsetX = {{ offset_x|default(0) }};
let offsetY = {{ offset_y|default(0) }};
let loadedModels = [];
let activeModel = null;
let selectedModels = [];
let selectionBoxDiv = document.createElement('div');
selectionBoxDiv.id = 'selection-box';
selectionBoxDiv.style.cssText = 'position: absolute; border: 1px dashed #007bff; background: rgba(0, 123, 255, 0.1); pointer-events: none; display: none; z-index: 100;';
document.getElementById('plater-container').appendChild(selectionBoxDiv);
let dragStartPoint = null;
let isDraggingBox = false;
const initialAddId = new URLSearchParams(window.location.search).get('add');
initPlater();
@@ -271,12 +278,24 @@ function initPlater() {
});
transformControl.addEventListener('dragging-changed', function (event) {
orbit.enabled = !event.value;
if (!event.value && activeModel) {
scene.attach(activeModel);
transformProxy.position.copy(activeModel.getWorldPosition(new THREE.Vector3()));
if (!event.value && selectedModels.length > 0) {
let center = new THREE.Vector3();
selectedModels.forEach(m => {
scene.attach(m);
m.geometry.computeBoundingBox();
let box = m.geometry.boundingBox.clone();
box.applyMatrix4(m.matrixWorld);
center.add(box.getCenter(new THREE.Vector3()));
});
center.divideScalar(selectedModels.length);
transformProxy.position.copy(center);
transformProxy.rotation.set(0, 0, 0);
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(activeModel);
selectedModels.forEach(m => {
transformProxy.attach(m);
});
}
});
scene.add(transformControl);
@@ -306,7 +325,7 @@ function setTransformMode(mode) {
document.getElementById('btn-scale').className = mode === 'scale' ? 'btn btn-primary btn-sm rounded' : 'btn btn-outline-secondary btn-sm rounded';
document.getElementById('btn-layflat').className = 'btn btn-outline-info btn-sm rounded';
if (mode === 'scale' && activeModel) {
if (mode === 'scale' && selectedModels && selectedModels.length > 0) {
document.getElementById('scale-panel').classList.remove('d-none');
updateScalePanel();
} else {
@@ -324,47 +343,56 @@ function setTransformMode(mode) {
}
function updateScalePanel() {
if (!activeModel) return;
const v = activeModel.getWorldScale(new THREE.Vector3());
document.getElementById('scale-x').value = v.x.toFixed(3);
document.getElementById('scale-y').value = v.y.toFixed(3);
document.getElementById('scale-z').value = v.z.toFixed(3);
if (selectedModels.length === 0) return;
// Check if scales match
let firstScale = selectedModels[0].getWorldScale(new THREE.Vector3());
let allXMatch = true, allYMatch = true, allZMatch = true;
for (let i = 1; i < selectedModels.length; i++) {
let v = selectedModels[i].getWorldScale(new THREE.Vector3());
if (Math.abs(v.x - firstScale.x) > 0.001) allXMatch = false;
if (Math.abs(v.y - firstScale.y) > 0.001) allYMatch = false;
if (Math.abs(v.z - firstScale.z) > 0.001) allZMatch = false;
}
document.getElementById('scale-x').value = allXMatch ? firstScale.x.toFixed(3) : '';
document.getElementById('scale-y').value = allYMatch ? firstScale.y.toFixed(3) : '';
document.getElementById('scale-z').value = allZMatch ? firstScale.z.toFixed(3) : '';
}
function applyScaleInput(axis) {
if (!activeModel) return;
let val = parseFloat(document.getElementById('scale-' + axis).value);
if (selectedModels.length === 0) return;
let valStr = document.getElementById('scale-' + axis).value;
if (valStr === '') return;
let val = parseFloat(valStr);
if (isNaN(val) || val <= 0.001) val = 1.0;
scene.attach(activeModel); // temporarily detach to operate purely on local=world scale
const isUniform = document.getElementById('scale-uniform').checked;
if (isUniform) {
// Find previous scale
const prev = activeModel.scale[axis];
const ratio = val / prev;
activeModel.scale.x *= ratio;
activeModel.scale.y *= ratio;
activeModel.scale.z *= ratio;
} else {
activeModel.scale[axis] = val;
}
selectedModels.forEach(m => {
scene.attach(m);
if (isUniform) {
const prev = m.scale[axis];
const ratio = val / prev;
m.scale.x *= ratio;
m.scale.y *= ratio;
m.scale.z *= ratio;
} else {
m.scale[axis] = val;
}
m.updateMatrixWorld(true);
});
activeModel.updateMatrixWorld(true);
// re-attach proxy pivot logic without modifying the actual spatial scale
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(activeModel);
selectedModels.forEach(m => {
transformProxy.attach(m);
});
updateScalePanel();
}
function removeActiveModel() {
if (activeModel) {
removeModel(activeModel);
}
}
function onKeyDown(event) {
switch (event.key.toLowerCase()) {
@@ -386,6 +414,15 @@ function onPointerDown(event) {
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
if (event.ctrlKey || event.metaKey) {
dragStartPoint = { x: event.clientX, y: event.clientY };
isDraggingBox = false;
orbit.enabled = false;
document.addEventListener('pointermove', onPointerMoveBox);
document.addEventListener('pointerup', onPointerUpBox);
}
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(loadedModels, true);
@@ -424,30 +461,146 @@ function onPointerDown(event) {
// Exit lay flat mode and reset to translate
setTransformMode('translate');
selectModel(obj);
selectModels([obj]);
}
}
return;
}
if (intersects.length > 0) {
selectModel(intersects[0].object);
if (event.ctrlKey || event.metaKey) {
toggleModelSelection(intersects[0].object);
} else {
selectModels([intersects[0].object]);
}
} else {
selectModel(null);
if (!event.ctrlKey && !event.metaKey) {
selectModels([]);
}
}
}
function selectModel(model) {
if (activeModel && activeModel !== model) {
scene.attach(activeModel);
function onPointerMoveBox(event) {
if (!dragStartPoint) return;
const dx = Math.abs(event.clientX - dragStartPoint.x);
const dy = Math.abs(event.clientY - dragStartPoint.y);
if (dx > 5 || dy > 5) {
isDraggingBox = true;
const container = document.getElementById('plater-container');
const rect = container.getBoundingClientRect();
const startX = dragStartPoint.x - rect.left;
const startY = dragStartPoint.y - rect.top;
const currentX = event.clientX - rect.left;
const currentY = event.clientY - rect.top;
selectionBoxDiv.style.display = 'block';
selectionBoxDiv.style.left = Math.min(currentX, startX) + 'px';
selectionBoxDiv.style.top = Math.min(currentY, startY) + 'px';
selectionBoxDiv.style.width = Math.abs(currentX - startX) + 'px';
selectionBoxDiv.style.height = Math.abs(currentY - startY) + 'px';
}
activeModel = model;
if (model) {
scene.attach(model);
transformProxy.position.copy(model.getWorldPosition(new THREE.Vector3()));
}
function onPointerUpBox(event) {
document.removeEventListener('pointermove', onPointerMoveBox);
document.removeEventListener('pointerup', onPointerUpBox);
orbit.enabled = true;
if (isDraggingBox) {
selectionBoxDiv.style.display = 'none';
const rect = renderer.domElement.getBoundingClientRect();
const minX = Math.min(dragStartPoint.x, event.clientX) - rect.left;
const maxX = Math.max(dragStartPoint.x, event.clientX) - rect.left;
const minY = Math.min(dragStartPoint.y, event.clientY) - rect.top;
const maxY = Math.max(dragStartPoint.y, event.clientY) - rect.top;
let newSelection = [...selectedModels];
loadedModels.forEach(m => {
m.geometry.computeBoundingBox();
let box = m.geometry.boundingBox.clone();
box.applyMatrix4(m.matrixWorld);
// Project 8 corners
const corners = [
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
new THREE.Vector3(box.max.x, box.max.y, box.max.z)
];
let inside = false;
corners.forEach(v => {
v.project(camera);
let sx = (v.x * .5 + .5) * rect.width;
let sy = (v.y * -.5 + .5) * rect.height;
if (sx >= minX && sx <= maxX && sy >= minY && sy <= maxY) {
inside = true;
}
});
if (inside && !newSelection.includes(m)) {
newSelection.push(m);
}
});
selectModels(newSelection);
} else if (dragStartPoint && !isDraggingBox) {
// Just a ctrl+click that missed logic is handled by raycaster above, but we have to ensure no double toggle
}
dragStartPoint = null;
isDraggingBox = false;
}
function toggleModelSelection(model) {
let newSel = [...selectedModels];
if (newSel.includes(model)) {
newSel = newSel.filter(m => m !== model);
} else {
newSel.push(model);
}
selectModels(newSel);
}
function selectModels(models) {
selectedModels.forEach(m => {
scene.attach(m);
m.material.color.setHex(0xcccccc);
});
selectedModels = models;
activeModel = selectedModels.length > 0 ? selectedModels[selectedModels.length - 1] : null;
if (selectedModels.length > 0) {
// compute joint center
let center = new THREE.Vector3();
let count = 0;
selectedModels.forEach(m => {
m.material.color.setHex(0x0d6efd);
scene.attach(m); // ensure in world
m.geometry.computeBoundingBox();
let box = m.geometry.boundingBox.clone();
box.applyMatrix4(m.matrixWorld);
center.add(box.getCenter(new THREE.Vector3()));
count++;
});
center.divideScalar(count);
transformProxy.position.copy(center);
transformProxy.rotation.set(0, 0, 0);
transformProxy.scale.set(1, 1, 1);
transformProxy.attach(model);
selectedModels.forEach(m => {
transformProxy.attach(m);
});
transformControl.attach(transformProxy);
if(transformControl.getMode() === 'scale') {
document.getElementById('scale-panel').classList.remove('d-none');
@@ -459,14 +612,32 @@ function selectModel(model) {
}
}
function removeModel(model) {
if (activeModel === model) {
transformControl.detach();
scene.attach(model);
activeModel = null;
// Keep a backward compatible selectModel definition for single cases
function selectModel(model) {
if (model) {
selectModels([model]);
} else {
selectModels([]);
}
}
function removeModel(model) {
if (selectedModels.includes(model)) {
selectedModels = selectedModels.filter(m => m !== model);
if (selectedModels.length === 0) transformControl.detach();
}
scene.attach(model);
scene.remove(model);
loadedModels = loadedModels.filter(m => m !== model);
activeModel = selectedModels.length > 0 ? selectedModels[0] : null;
}
function removeActiveModel() {
if (selectedModels.length > 0) {
[...selectedModels].forEach(m => removeModel(m));
selectModels([]);
}
}
function clearPlate() {
@@ -476,12 +647,29 @@ function clearPlate() {
scene.remove(m);
});
loadedModels = [];
selectedModels = [];
activeModel = null;
}
function addModelToPlate(btnElement, fileId, url, name, status) {
let matrixData = btnElement ? btnElement.getAttribute('data-matrix') : null;
if (matrixData) {
try {
let data = JSON.parse(matrixData);
if (data.settings) {
if (data.settings.infill) document.getElementById('infill-density').value = data.settings.infill;
if (data.settings.support) {
let supportSelect = document.getElementById('support-type');
supportSelect.value = data.settings.support;
supportSelect.dispatchEvent(new Event('change'));
}
if (data.settings.support_pattern) document.getElementById('support-pattern').value = data.settings.support_pattern;
if (data.settings.quality) document.getElementById('quality').value = data.settings.quality;
}
} catch (e) {}
}
if (matrixData && matrixData.includes('"is_composite"')) {
try {
let comp = JSON.parse(matrixData);
@@ -538,7 +726,7 @@ function loadSTL(fileId, url, name, status, matrixData, callback) {
const minZ = geometry.boundingBox.min.z;
geometry.translate(-center.x, -center.y, -minZ);
const material = new THREE.MeshPhongMaterial({ color: 0x0d6efd, specular: 0x111111, shininess: 200 });
const material = new THREE.MeshPhongMaterial({ color: 0xcccccc, specular: 0x111111, shininess: 200 });
const mesh = new THREE.Mesh(geometry, material);
mesh.userData = {
fileId: fileId,
@@ -547,9 +735,15 @@ function loadSTL(fileId, url, name, status, matrixData, callback) {
geomTrans: new THREE.Matrix4().makeTranslation(-center.x, -center.y, -minZ)
};
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None' && !matrixData.includes('"is_composite"')) {
if (matrixData && matrixData.trim() !== '' && matrixData !== 'None') {
try {
let mArray = JSON.parse(matrixData);
// Skip if it actually is a composite (handled by addModelToPlate)
if (mArray && mArray.is_composite === true) return;
if (mArray && !Array.isArray(mArray) && mArray.matrix) {
mArray = mArray.matrix;
}
let savedMatrix = new THREE.Matrix4().fromArray(mArray);
savedMatrix.decompose(mesh.position, mesh.quaternion, mesh.scale);
} catch (e) {
@@ -622,7 +816,7 @@ function animate() {
}
function mergeAndSlice() {
selectModel(null); // Detach any active model to bake transformProxy world coordinates into its local matrix properties
selectModels([]); // Detach any active model to bake transformProxy world coordinates into its local matrix properties
if (loadedModels.length === 0) {
alert("{{ _('Please add at least one model to the build plate.') }}");
@@ -728,8 +922,13 @@ document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('add-model-btn-' + addId);
if (btn) {
let matrixData = btn.getAttribute('data-matrix');
if (matrixData && matrixData.includes('"is_composite"')) {
window.isCompositeEdit = true;
if (matrixData) {
try {
let d = JSON.parse(matrixData);
if (d && d.is_composite === true) {
window.isCompositeEdit = true;
}
} catch(e) {}
}
btn.click();
}

View File

@@ -0,0 +1,87 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-arrows-move text-primary me-2"></i>{{ _('Printer Control') }}</h1>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
</div>
{% else %}
<div class="row row-cols-1 row-cols-lg-2 g-4">
<!-- Webcam Stream -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-dark text-light fw-bold rounded-top">
<i class="bi bi-camera-video me-1"></i>{{ _('Live Webcam') }}
</div>
<div class="card-body p-0 ratio ratio-16x9">
<img src="{{ webcam_url }}" alt="{{ _('Loading webcam stream...') }}" class="w-100 h-100 object-fit-cover">
</div>
</div>
</div>
<!-- Motion Control -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-dpad me-1"></i>{{ _('Basic Control') }}
</div>
<div class="card-body text-center d-flex flex-column justify-content-center align-items-center">
<!-- Home button -->
<button class="btn btn-lg btn-primary rounded-circle mb-4 shadow" style="width: 80px; height: 80px;" onclick="sendCommand('home')" title="{{ _('Home All Axes') }}">
<i class="bi bi-house-door fs-2"></i>
</button>
<div class="text-muted mb-4">{{ _('Home All Axes') }} (G28)</div>
<!-- Quick macros -->
<div class="d-flex gap-3 justify-content-center flex-wrap w-100">
<button class="btn btn-outline-danger flex-fill shadow-sm py-3" onclick="sendCommand('pause')" title="{{ _('Pause/Resume Print') }}">
<i class="bi bi-pause-circle fs-4 d-block mb-1"></i>{{ _('Pause') }}
</button>
<button class="btn btn-outline-warning flex-fill shadow-sm py-3" onclick="sendCommand('cancel')" title="{{ _('Cancel Print') }}">
<i class="bi bi-stop-circle fs-4 d-block mb-1"></i>{{ _('Cancel') }}
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function sendCommand(cmdName) {
if ((cmdName === 'cancel' || cmdName === 'home') && !confirm("Are you sure you want to perform this action?")) {
return;
}
fetch('{{ url_for("printer.api_command") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({command: cmdName})
})
.then(r => r.json())
.then(data => {
if(data.success) {
flashMessage("success", "Command " + cmdName + " sent.");
} else {
flashMessage("danger", "Control failed: " + data.error);
}
})
.catch(err => {
flashMessage("danger", "Network Error: " + err);
});
}
function flashMessage(type, text) {
const container = document.querySelector('.toast-container');
if(!container) return alert(text);
const toast = document.createElement('div');
toast.className = `toast align-items-center border-0 bg-${type} text-white show`;
toast.innerHTML = `<div class="d-flex"><div class="toast-body fw-medium">${text}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-gear-wide-connected text-primary me-2"></i>{{ _('OctoPrint Configuration') }}</h1>
</div>
<div class="card shadow-sm border-0">
<div class="card-header bg-light fw-bold text-secondary border-bottom-0">
<i class="bi bi-link-45deg me-1"></i>{{ _('Connection Settings') }}
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('printer.octo_config') }}">
<div class="mb-3">
<label for="octoprint_url" class="form-label fw-bold">{{ _('OctoPrint Base URL') }}</label>
<div class="input-group mb-3 shadow-sm">
<span class="input-group-text bg-white text-muted" id="url-addon"><i class="bi bi-globe"></i></span>
<input type="url" class="form-control" id="octoprint_url" name="octoprint_url" aria-describedby="url-addon" placeholder="e.g. http://octopi.local" value="{{ configs.get('octoprint_url', '') }}" required>
</div>
<div class="form-text">
{{ _('The local IP address or hostname of your OctoPrint server.') }}
</div>
</div>
<div class="mb-4">
<label for="octoprint_apikey" class="form-label fw-bold">{{ _('API Key / Application Key') }}</label>
<div class="input-group shadow-sm">
<span class="input-group-text bg-white text-muted" id="key-addon"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" id="octoprint_apikey" name="octoprint_apikey" aria-describedby="key-addon" placeholder="{{ _('Paste API Key here') }}" value="{{ configs.get('octoprint_apikey', '') }}">
</div>
<div class="form-text">
{{ _('Can be found in OctoPrint Settings -> Application Keys or API.') }}
</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary px-4 rounded-pill shadow-sm"><i class="bi bi-save2 me-2"></i>{{ _('Save Connection Settings') }}</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-window-sidebar text-info me-2"></i>{{ _('OctoPrint Panel (Embedded)') }}</h1>
</div>
{% if embed_url %}
<div class="card shadow rounded overflow-hidden" style="height: calc(100vh - 180px); min-height: 500px;">
<!-- iFrame wrapper for responsivness -->
<div class="w-100 h-100 position-relative">
<iframe src="{{ embed_url }}"
class="position-absolute border-0 w-100 h-100"
style="top: 0; left: 0;"
allowfullscreen>
</iframe>
</div>
</div>
{% else %}
<div class="alert alert-warning shadow-sm border-0 d-flex align-items-center" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-4 text-warning me-3"></i>
<div>
<strong>{{ _('Configuration Required:') }}</strong>
{{ _('The OctoPrint URL is not set. Please go to the ') }} <a href="{{ url_for('printer.octo_config') }}" class="alert-link text-decoration-underline">{{ _('System Configuration') }}</a> {{ _('page to set it up.') }}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-file-earmark-plus text-primary me-2"></i>{{ _('Prepare Print') }}</h1>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-card-text me-1"></i>{{ _('Available Files on Printer') }}
</div>
<div class="list-group list-group-flush">
{% for f in files %}
{% if f.type == 'machinecode' %}
<div class="list-group-item list-group-item-action d-flex justify-content-between align-items-center py-3">
<div class="me-auto text-truncate" style="max-width: 80%;">
<h6 class="mb-1"><i class="bi bi-file-earmark-code text-primary me-2"></i>{{ f.name }}</h6>
<small class="text-muted d-block">{{ _('Size:') }} {{ f.size }} bytes, {{ _('Time:') }} {{ f.gcodeAnalysis.estimatedPrintTime if f.gcodeAnalysis else 'Unknown' }}s</small>
</div>
<div>
<button class="btn btn-sm btn-outline-success rounded-pill px-3 shadow-sm" onclick="printFile('{{ f.origin }}', '{{ f.path }}')"><i class="bi bi-play-fill me-1"></i>{{ _('Print Now') }}</button>
<!-- <button class="btn btn-sm btn-outline-secondary rounded-pill ms-2" onclick="selectFile('{{ f.origin }}', '{{ f.path }}')">{{ _('Select') }}</button> -->
</div>
</div>
{% endif %}
{% else %}
<div class="list-group-item text-center py-5 text-muted">
<i class="bi bi-inbox display-4 d-block mb-3"></i>
<p>{{ _('No printable files found. Go slice some G-Code first!') }}</p>
</div>
{% endfor %}
</div>
</div>
<script>
function printFile(origin, path) {
if(!confirm("{{ _('Send this file to print immediately?') }}\n\n" + path)) return;
fetch('{{ url_for("printer.api_print_file") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ origin: origin, path: path })
})
.then(response => response.json())
.then(data => {
if(data.success) {
alert("{{ _('Print starting! Going to dashboard...') }}");
window.location.href = "{{ url_for('printer.status') }}";
} else {
alert("Error: " + data.error);
}
})
.catch(err => alert("Error: " + err));
}
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,105 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="bi bi-activity text-primary me-2"></i>{{ _('Printer Status') }}</h1>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
{% if current_user.is_admin %}
<a href="{{ url_for('printer.octo_config') }}" class="alert-link">{{ _('Go to Configuration') }}</a>
{% endif %}
</div>
{% elif status %}
<div class="row row-cols-1 row-cols-md-2 g-4">
<!-- State Card -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-info-circle me-1"></i>{{ _('Current State') }}
</div>
<div class="card-body text-center">
<h3 class="display-6 mt-3 text-primary">{{ status.get('state', {}).get('text', 'Unknown') }}</h3>
</div>
</div>
</div>
<!-- Temperature Card -->
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold text-secondary">
<i class="bi bi-thermometer-half me-1"></i>{{ _('Temperatures') }}
</div>
<div class="card-body">
{% set temps = status.get('temperature', {}) %}
<h5 class="mb-1"><i class="bi bi-fire text-danger me-2"></i>{{ _('Tool/Nozzle') }}</h5>
<h4 class="ms-4 mb-4">
{{ temps.get('tool0', {}).get('actual', 0) }} °C
<small class="text-muted fs-6">/ {{ temps.get('tool0', {}).get('target', 0) }} °C</small>
</h4>
<h5 class="mb-1"><i class="bi bi-square-fill text-warning me-2"></i>{{ _('Bed') }}</h5>
<h4 class="ms-4">
{{ temps.get('bed', {}).get('actual', 0) }} °C
<small class="text-muted fs-6">/ {{ temps.get('bed', {}).get('target', 0) }} °C</small>
</h4>
</div>
</div>
</div>
</div>
{% if job and job.get('job', {}).get('file', {}).get('name') %}
<div class="card shadow-sm mt-4 border-success">
<div class="card-header bg-success text-white fw-bold">
<i class="bi bi-play-circle me-1"></i>{{ _('Active Print Job') }}
</div>
<div class="card-body">
<h5>{{ job.get('job', {}).get('file', {}).get('name') }}</h5>
{% set progress = job.get('progress', {}).get('completion', 0) %}
{% if progress == None %}{% set progress = 0 %}{% endif %}
<div class="progress mt-3 mb-2" style="height: 25px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-success" role="progressbar" style="width: {{ progress }}%;" aria-valuenow="{{ progress }}" aria-valuemin="0" aria-valuemax="100">
{{ "%.1f"|format(progress) }}%
</div>
</div>
<div class="d-flex justify-content-between text-muted small mt-2">
<span><strong>{{ _('Print Time:') }}</strong> {{ job.get('progress', {}).get('printTime', 0) }}s</span>
<span><strong>{{ _('Time Left:') }}</strong> {{ job.get('progress', {}).get('printTimeLeft', 0) }}s</span>
</div>
<div class="mt-4 gap-2 d-flex">
<button class="btn btn-warning" onclick="sendCmd('pause')"><i class="bi bi-pause-fill me-1"></i>{{ _('Pause/Resume') }}</button>
<button class="btn btn-danger" onclick="sendCmd('cancel')"><i class="bi bi-stop-fill me-1"></i>{{ _('Cancel') }}</button>
</div>
</div>
</div>
{% endif %}
{% endif %}
<script>
function sendCmd(cmd) {
if(cmd === 'cancel' && !confirm("{{ _('Are you sure you want to cancel the print?') }}")) return;
fetch('{{ url_for("printer.api_command") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({command: cmd})
})
.then(res => res.json())
.then(data => {
if(data.success) {
window.location.reload();
} else {
alert("Error: " + data.error);
}
});
}
setTimeout(() => { if (!window.pauseRefresh) window.location.reload(); }, 15000);
</script>
{% endblock %}