Merge remote-tracking branch 'origin/nightly' into nightly_plus_notifs

This commit is contained in:
2026-07-23 05:57:15 -05:00
16 changed files with 1333 additions and 926 deletions

View File

@ -1,8 +1,4 @@
## Changes in this build
- Fix: the poll interval setting was saved to config.ini but never actually applied — the poll loop always used a hardcoded 3s wait
- Fix: Spoolman status showed a green "connected" dot even when the server was unreachable — reachability is now rechecked periodically and shown accurately (green/red)
- Fix: **settings could silently revert after saving** — the bridge restart that applies settings didn't clean up all the relevant environment variables, so the old value could win over the one just saved (affected poll interval, resonance compensation, Docker host IP, and the new HTTP log toggle below). Fixed at the root so this class of bug can't recur for future settings.
- Feat: new "Log every HTTP request (verbose)" toggle in Settings — off by default, since aiohttp's per-request access log was drowning out the bridge's own logs with the frontend's 2s polling
**A stable release is coming soon** with the fixes and features from the last several nightly builds. As usual it will be published as ready-to-run binaries (Linux amd64/arm64, Windows) alongside the Docker image.
- Fix: on printers with multiple daisy-chained ACE units and no toolhead buffer (e.g. Kobra S1 with 2 ACE Pro), only the first unit's 4 slots were ever shown on the dashboard or synced to OrcaSlicer — the bridge silently discarded every ACE unit after the first. All units now show up correctly (Issue #95, thanks @hoovercl for the detailed report and logs)
- Feat: the GCode browser now supports multi-select and bulk delete — click any file's checkbox to enter select mode, use "Select All" to grab everything currently visible (respecting your active search/filter), then delete the selection with one confirmation instead of one-by-one (Issue #94, thanks @Blaim)

View File

@ -588,15 +588,23 @@ class GCodeStore:
class CameraCache:
"""Zentraler Kamera-Demuxer.
Keeps ONE ffmpeg process open that reads the FLV stream from the printer
and produces two outputs in parallel:
Keeps ONE ffmpeg process per output type open that reads the FLV stream
from the printer and produces:
- MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot
- MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers
- MJPEG @ 15fps/640px -> fanout to all /api/camera/stream subscribers
(the live-view used by the dashboard AND by every Moonraker-compatible
client, since server.webcams.list advertises this same stream_url)
Damit:
* Only ONE FLV connection to the printer (solves the single-client limit / 429)
* Only ONE FLV connection to the printer per output type (solves the
single-client limit / 429) - previously /api/camera/stream opened a
brand-new, uncached ffmpeg + printer connection per HTTP client, which
competed with the cached jpeg/h264 connections for the printer's very
limited number of concurrent camera clients and caused intermittent
"stream unavailable" failures.
* Snapshots are instant (memory read, no ffmpeg spawn per request)
* Multiple parallel H.264 consumers possible (plugin + web UI + ...)
* Multiple parallel H.264/MJPEG consumers possible (plugin + web UI + ...)
Lazy start on the first consumer, auto-restart on ffmpeg crash.
"""
@ -610,40 +618,77 @@ class CameraCache:
self.latest_jpeg: bytes = b""
self.latest_jpeg_ts: float = 0.0
self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set()
self.mjpeg_subscribers: "set[asyncio.Queue[bytes]]" = set()
self._proc_jpeg: "asyncio.subprocess.Process | None" = None
self._proc_h264: "asyncio.subprocess.Process | None" = None
self._proc_mjpeg: "asyncio.subprocess.Process | None" = None
self._task_jpeg: "asyncio.Task | None" = None
self._task_h264: "asyncio.Task | None" = None
self._task_mjpeg: "asyncio.Task | None" = None
self._lock = asyncio.Lock()
self._fail_count_jpeg: int = 0
self._fail_count_h264: int = 0
self._fail_count_mjpeg: int = 0
def set_url(self, url: str):
self._url = url
def reset(self):
"""Reset backoff counters and kill running ffmpeg processes."""
"""Reset backoff counters and forcefully tear down any running
ffmpeg loops - including cancelling their background tasks.
Only killing the ffmpeg subprocess is not enough: the owning task
might currently be sitting in `await asyncio.sleep(delay)` from a
previous exponential backoff (up to 300s) after an earlier failure.
Resetting the fail-count doesn't wake it up early, so a user
clicking "reset" could see nothing happen for minutes. Cancelling
the task guarantees an immediate, clean restart on the next
ensure_running() call.
"""
self._fail_count_jpeg = 0
self._fail_count_h264 = 0
for proc in (self._proc_jpeg, self._proc_h264):
self._fail_count_mjpeg = 0
for task in (self._task_jpeg, self._task_h264, self._task_mjpeg):
if task is not None and not task.done():
task.cancel()
for proc in (self._proc_jpeg, self._proc_h264, self._proc_mjpeg):
if proc is not None:
try:
proc.kill()
except Exception:
pass
self._task_jpeg = self._task_h264 = self._task_mjpeg = None
self._proc_jpeg = self._proc_h264 = self._proc_mjpeg = None
async def ensure_running(self):
if self._proc_jpeg is None or self._proc_jpeg.returncode is not None:
# NOTE: we check the *task* state, not self._proc_* - the process
# handle is only assigned later, inside the task body, once ffmpeg
# has actually been spawned. Checking self._proc_* here left a race
# window: two callers arriving before the newly-created task got a
# chance to run would both see "no process yet" and each spawn a
# duplicate ffmpeg + duplicate printer connection, silently
# orphaning the older one (whichever task's coroutine runs last
# overwrites the shared self._proc_* reference, so nobody keeps a
# handle to kill the earlier orphaned process). Task creation is
# synchronous, so checking self._task_* here is race-free.
if self._task_jpeg is None or self._task_jpeg.done():
self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
if self._proc_h264 is None or self._proc_h264.returncode is not None:
if self._task_h264 is None or self._task_h264.done():
self._task_h264 = asyncio.create_task(self._run_h264_loop())
if self._task_mjpeg is None or self._task_mjpeg.done():
self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop())
def _input_args(self, url: str) -> list[str]:
args = ["-fflags", "nobuffer", "-flags", "low_delay"]
if url.lower().startswith("rtsp://"):
args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else:
args += ["-probesize", "500000", "-analyzeduration", "500000"]
# The printer's FLV source occasionally emits non-monotonic container
# timestamps (PTS jumps of days) while the video data itself stays
# valid. Without this flag ffmpeg's realtime pacing breaks on such a
# jump and the stream stalls after ~15-30 min (Issue #90).
args += ["-use_wallclock_as_timestamps", "1",
"-probesize", "500000", "-analyzeduration", "500000"]
return args
async def _run_jpeg_loop(self):
@ -790,6 +835,98 @@ class CameraCache:
self._fail_count_h264 = 0
await asyncio.sleep(2.0)
async def _run_mjpeg_loop(self):
"""Keeps an ffmpeg process alive that fans out MJPEG@15fps/640px
(complete JPEG frames) to all /api/camera/stream subscribers."""
while True:
url = self._url
if not url:
await asyncio.sleep(2.0)
continue
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-vf", "fps=15,scale=640:-1",
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
"-flush_packets", "1", "pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._proc_mjpeg = proc
except Exception as e:
log.warning(f"CameraCache: ffmpeg-mjpeg start failed: {e}")
await asyncio.sleep(3.0)
continue
buf = b""
rc = None
try:
while True:
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
buf += chunk
# extract complete JPEG frames and fan them out whole
# (so every subscriber gets clean multipart boundaries,
# not arbitrary byte chunks like the h264/mpegts fanout)
while True:
start = buf.find(self.JPEG_SOI)
if start == -1:
buf = b""
break
end = buf.find(self.JPEG_EOI, start + 2)
if end == -1:
buf = buf[start:]
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
for q in list(self.mjpeg_subscribers):
if q.full():
try:
q.get_nowait()
except Exception:
pass
try:
q.put_nowait(frame)
except Exception:
pass
except Exception as e:
log.debug(f"CameraCache: mjpeg-loop unterbrochen: {e}")
finally:
# NOTE: cleanup operates on the local `proc` reference, not
# on self._proc_mjpeg. If this task got cancelled (e.g. by
# reset()) a new task may already have started and assigned
# its own process to self._proc_mjpeg by the time we reach
# here - killing that shared attribute instead of our own
# local proc would kill the WRONG (newer) process.
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode
if rc:
try:
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-mjpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
if self._proc_mjpeg is proc:
self._proc_mjpeg = None
if rc:
self._fail_count_mjpeg += 1
delay = min(2.0 * (2 ** self._fail_count_mjpeg), 300.0)
log.warning(f"CameraCache: ffmpeg-mjpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_mjpeg})")
await asyncio.sleep(delay)
else:
self._fail_count_mjpeg = 0
await asyncio.sleep(2.0)
class SpoolmanClient:
"""Thin synchronous HTTP client for Spoolman filament tracking.
@ -888,6 +1025,8 @@ class KobraXBridge:
"filament_mode": "toolhead",
"supplies_usage": 0,
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
"error_code": 0,
"pause_msg": "",
}
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot
@ -1390,6 +1529,17 @@ class KobraXBridge:
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
self._camera_user_stopped = False # release for the next print
if kobra_state in ("pause", "paused"):
pause_msg = payload.get("msg", "")
if pause_msg:
error_code = payload.get("code", 0)
self._state["error_code"] = error_code
self._state["pause_msg"] = pause_msg
log.warning(f"Printer paused: [{error_code}] {pause_msg}")
elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"):
self._state["error_code"] = 0
self._state["pause_msg"] = ""
# Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id:
@ -1648,7 +1798,10 @@ class KobraXBridge:
Modes:
- toolhead: only toolhead slots
- ace_direct: ACE channels directly mapped (no toolhead box present)
- ace_direct: ACE channels directly mapped, no toolhead box present.
Covers one unit (Kobra X) as well as multiple daisy-chained units
(Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a
block of 4 global slots at box_id * 4.
- ace_hub: toolhead + ACE via hub (slot 4 as hub path)
"""
toolhead = any(b.get("id") == -1 for b in boxes)
@ -1684,19 +1837,22 @@ class KobraXBridge:
return global_slots, global_loaded
if mode == "ace_direct":
# ace_direct exposes exactly 4 channels total.
# If firmware reports multiple ACE boxes, keep only the first one.
if ace_boxes:
ace = ace_boxes[0]
ace_id = ace["id"]
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
s = dict(s)
s["global_index"] = local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if 0 <= ace_loaded < 4:
global_loaded = ace_loaded
# One or more ACE units, no toolhead buffer (Kobra X: 1 unit,
# Kobra S1: up to 2+ units, Issue #95). Global index =
# box_id * 4 + local slot, so the numbering matches
# _global_to_box_slot's //4-%4 fallback and stays stable
# regardless of report order.
for ace in ace_boxes:
ace_id = int(ace["id"])
base = ace_id * 4
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
s = dict(s)
s["global_index"] = base + local_idx
s["box_id"] = ace_id
global_slots.append(s)
ace_loaded = ace.get("loaded_slot", -1)
if 0 <= ace_loaded < 4:
global_loaded = base + ace_loaded
return global_slots, global_loaded
# ace_hub
@ -1887,20 +2043,18 @@ class KobraXBridge:
if box_id == -1:
return local_slot
if self._filament_mode == "ace_direct":
return local_slot
# Multi-ACE (Issue #95): each unit occupies its own block of 4.
# Identical to the old `return local_slot` for a single unit (id 0).
return box_id * 4 + local_slot
return 3 + box_id * 4 + local_slot
def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
"""Build {global_slot_index: loading|unloading} from feed_status data."""
# Note: all boxes are considered — the old primary_ace_id filter (skip
# every ACE box except the first in ace_direct mode) is gone since the
# slot aggregation now handles multiple ACE units (Issue #95).
activity: dict = {}
primary_ace_id = -1
if self._filament_mode == "ace_direct":
ace_ids = sorted(int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0)
if ace_ids:
primary_ace_id = ace_ids[0]
for box in boxes:
if self._filament_mode == "ace_direct" and primary_ace_id >= 0 and int(box.get("id", -1)) != primary_ace_id:
continue
fs = box.get("feed_status") or {}
current_status = int(fs.get("current_status", -1))
local_slot = int(fs.get("slot_index", -1))
@ -3842,7 +3996,7 @@ class KobraXBridge:
# filter applies and empty/shifted slots are not mapped incorrectly.
gcode_filaments = None
try:
db_file = self._db.get_file_by_name(filename)
db_file = self._store.get_file_by_name(filename)
if db_file and db_file.get("gcode_filaments"):
gcode_filaments = json.loads(db_file["gcode_filaments"])
except Exception as e:
@ -3972,21 +4126,33 @@ class KobraXBridge:
# (only the bare HTML) -> without inlining neither
# a single button works there (Issue #29). It is equally correct in a normal browser.
base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme)
try:
css = pathlib.Path(os.path.join(base, "style.css")).read_text(encoding="utf-8")
page = page.replace(
'<link rel="stylesheet" href="/kx/ui/style.css">',
"<style>\n" + css + "\n</style>")
except OSError:
pass
try:
js = pathlib.Path(os.path.join(base, "app.js")).read_text(encoding="utf-8")
js = js.replace("__VERSION__", self._read_version())
page = page.replace(
'<script src="/kx/ui/app.js"></script>',
"<script>\n" + js + "\n</script>")
except OSError:
pass
# Inline vendored lib CSS/JS too — the OrcaSlicer webview loads no
# external <link>/<script src>, so GridStack (and its stylesheet) must
# be embedded like style.css/app.js. Order matters: GridStack's <script>
# sits in <head>, before app.js, so it is defined when app.js inits.
def _inline_css(rel_path: str, link_tag: str):
nonlocal page
try:
data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
page = page.replace(link_tag, "<style>\n" + data + "\n</style>")
except OSError:
pass
def _inline_js(rel_path: str, script_tag: str, version_sub: bool = False):
nonlocal page
try:
data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
if version_sub:
data = data.replace("'__VERSION__'", f"'{self._read_version()}'")
page = page.replace(script_tag, "<script>\n" + data + "\n</script>")
except OSError:
pass
_inline_css("lib/gridstack.min.css", '<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">')
_inline_js("lib/gridstack-all.min.js", '<script src="/kx/ui/lib/gridstack-all.min.js"></script>')
_inline_css("style.css", '<link rel="stylesheet" href="/kx/ui/style.css">')
_inline_js("app.js", '<script src="/kx/ui/app.js"></script>', version_sub=True)
return web.Response(text=page, content_type="text/html",
headers={"Cache-Control": "no-store, no-cache, must-revalidate"})
@ -4334,10 +4500,15 @@ class KobraXBridge:
Useful after a 429 lock (Retry-After expired) or after a printer restart."""
self.camera_cache.reset()
url = self._state.get("camera_url", "")
if url:
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
return web.json_response({"result": "ok"})
if not url:
log.warning("Camera reset requested but no camera_url is known yet (waiting for printer status)")
return web.json_response({
"result": "no_url",
"message": "No camera URL known yet - wait for the next printer status update, or start a print/enable the camera first.",
})
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
return web.json_response({"result": "ok", "url": url})
async def handle_api_camera_snapshot(self, request):
"""Last JPEG frame from the CameraCache - instant from RAM,
@ -4345,10 +4516,10 @@ class KobraXBridge:
printer and is ~1 s faster)."""
url = self._state.get("camera_url", "")
if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt")
return web.Response(status=503, text="No camera URL known")
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
# Initialer Warmup: bis zu 5 s auf ersten Frame warten
# Initial warmup: wait up to 5s for the first frame
deadline = time.time() + 5.0
while not self.camera_cache.latest_jpeg and time.time() < deadline:
await asyncio.sleep(0.1)
@ -4364,43 +4535,26 @@ class KobraXBridge:
return web.Response(body=jpeg, content_type="image/jpeg", headers=headers)
async def handle_camera_stream(self, request):
"""MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace."""
"""MJPEG live view, served as multipart/x-mixed-replace.
Fed from the central CameraCache fanout (same pattern as
handle_camera_h264) instead of spawning a dedicated ffmpeg process
per HTTP client. The printer's camera server only tolerates a very
limited number of concurrent connections (see CameraCache docstring)
- previously every consumer of this endpoint (dashboard, OrcaSlicer,
moonraker-obico, a second browser tab, ...) opened its own separate
connection, so two simultaneous viewers could already exhaust the
printer's connection limit and cause intermittent "stream
unavailable" failures. Now all consumers share one connection.
"""
url = self._state.get("camera_url", "")
if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt")
return web.Response(status=503, text="No camera URL known")
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
is_rtsp = url.lower().startswith("rtsp://")
ffmpeg_input_args = [
"-fflags", "nobuffer",
"-flags", "low_delay",
]
if is_rtsp:
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
else:
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
# Start ffmpeg BEFORE the StreamResponse is opened
# (so we can still send a normal HTTP response on error)
try:
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "quiet",
*ffmpeg_input_args,
"-i", url,
"-vf", "fps=15,scale=640:-1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "3",
"-flush_packets", "1",
"pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
except (FileNotFoundError, OSError) as e:
log.warning("Camera: ffmpeg not found camera stream unavailable")
return web.Response(status=503, text="ffmpeg not found")
except Exception as e:
log.warning(f"Camera: could not start ffmpeg: {e}")
return web.Response(status=503, text=str(e))
q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8)
self.camera_cache.mjpeg_subscribers.add(q)
boundary = "kobraxframe"
resp = web.StreamResponse(headers={
@ -4409,46 +4563,24 @@ class KobraXBridge:
"Connection": "keep-alive",
})
await resp.prepare(request)
buf = b""
try:
while True:
chunk = await proc.stdout.read(65536)
if not chunk:
frame = await q.get()
header = (
f"--{boundary}\r\n"
f"Content-Type: image/jpeg\r\n"
f"Content-Length: {len(frame)}\r\n\r\n"
).encode()
try:
await resp.write(header + frame + b"\r\n")
except (ConnectionResetError, asyncio.CancelledError):
break
except Exception:
break
buf += chunk
# Extract complete JPEG frames (SOI=FFD8, EOI=FFD9)
while True:
start = buf.find(b"\xff\xd8")
if start == -1:
buf = b""
break
end = buf.find(b"\xff\xd9", start + 2)
if end == -1:
buf = buf[start:]
break
frame = buf[start:end + 2]
buf = buf[end + 2:]
header = (
f"--{boundary}\r\n"
f"Content-Type: image/jpeg\r\n"
f"Content-Length: {len(frame)}\r\n\r\n"
).encode()
try:
await resp.write(header + frame + b"\r\n")
except Exception:
return resp
except Exception as e:
log.warning(f"Camera stream interrupted: {e}")
finally:
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
self.camera_cache.mjpeg_subscribers.discard(q)
return resp
@ -4458,7 +4590,7 @@ class KobraXBridge:
additional FLV connection to the printer (single-client limit)."""
url = self._state.get("camera_url", "")
if not url:
return web.Response(status=503, text="Keine Kamera-URL bekannt")
return web.Response(status=503, text="No camera URL known")
self.camera_cache.set_url(url)
await self.camera_cache.ensure_running()
@ -4553,6 +4685,8 @@ class KobraXBridge:
"file_ready": s["file_ready"],
"print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)),
"version": self._read_version(),
"pause_msg": s.get("pause_msg", ""),
"error_code": s.get("error_code", 0),
})
async def handle_moonraker_database(self, request):

View File

@ -2,12 +2,13 @@
Shared fixtures für KX-Bridge Tests.
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
"""
import sys, types, argparse, pytest, pytest_asyncio
import sys, types, argparse, tempfile, pytest, pytest_asyncio
from unittest.mock import MagicMock
from aiohttp.test_utils import TestClient, TestServer
# ── Pfad ──────────────────────────────────────────────────────────────────────
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
env_mod = types.ModuleType("env_loader")
@ -41,6 +42,7 @@ def make_args(**overrides):
device_id = "",
host = "127.0.0.1",
port = 7125,
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
)
for k, v in overrides.items():
setattr(args, k, v)

View File

@ -0,0 +1,117 @@
"""Multi-ACE aggregation in ace_direct mode (Issue #95, Kobra S1).
A Kobra S1 with two daisy-chained ACE Pro units reports
multi_color_box = [{id:0, slots:[4]}, {id:1, slots:[4]}] with NO toolhead
entry (id:-1). The old ace_direct branch kept only ace_boxes[0], silently
dropping the second unit — the dashboard and the OrcaSlicer sync only ever
saw 4 of the 8 slots. Payloads below are trimmed from the real log attached
to the issue.
"""
from kobrax_moonraker_bridge import KobraXBridge
def _slot(index, type_="PLA", color=(1, 2, 3), status=5):
return {
"index": index, "sku": "", "type": type_, "color": list(color),
"edit_status": 0, "status": status,
"color_group": [list(color) + [255]], "icon_type": 0,
"consumables_percent": 50,
}
def _ace_box(box_id, loaded_slot=-1, n_slots=4):
return {
"id": box_id, "status": 1, "model_id": 0, "auto_feed": 1,
"loaded_slot": loaded_slot,
"feed_status": {"code": 200, "type": -1, "current_status": -1, "slot_index": -1},
"temp": 30, "humidity": 0,
"drying_status": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"slots": [_slot(i) for i in range(n_slots)],
}
def _toolhead_box(n_slots=4):
box = _ace_box(-1, n_slots=n_slots)
return box
# ── mode detection ───────────────────────────────────────────────────────────
def test_two_ace_units_no_toolhead_is_ace_direct():
boxes = [_ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_direct"
# ── ace_direct aggregation ───────────────────────────────────────────────────
def test_single_ace_unit_yields_4_slots():
"""Kobra X regression: one unit, global indices 0-3 exactly as before."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0)], "ace_direct")
assert len(slots) == 4
assert [s["global_index"] for s in slots] == [0, 1, 2, 3]
assert all(s["box_id"] == 0 for s in slots)
assert loaded == -1
def test_two_ace_units_yield_8_slots():
"""Issue #95: the second unit's slots must appear as global 4-7."""
slots, loaded = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
assert len(slots) == 8
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_two_ace_units_report_order_does_not_matter():
"""Boxes sorted by id — global numbering stays stable if the firmware
reports unit 1 before unit 0."""
slots, _ = KobraXBridge._aggregate_slots([_ace_box(1), _ace_box(0)], "ace_direct")
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7]
assert [s["box_id"] for s in slots] == [0, 0, 0, 0, 1, 1, 1, 1]
def test_loaded_slot_on_second_unit_maps_to_global():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0), _ace_box(1, loaded_slot=2)], "ace_direct")
assert loaded == 6 # 1*4 + 2
def test_loaded_slot_on_first_unit_unchanged():
slots, loaded = KobraXBridge._aggregate_slots(
[_ace_box(0, loaded_slot=3), _ace_box(1)], "ace_direct")
assert loaded == 3
# ── ace_hub regression (unchanged behavior) ──────────────────────────────────
def test_ace_hub_numbering_unchanged():
boxes = [_toolhead_box(), _ace_box(0), _ace_box(1)]
assert KobraXBridge._detect_filament_mode(boxes) == "ace_hub"
slots, _ = KobraXBridge._aggregate_slots(boxes, "ace_hub")
# 3 toolhead + 4 + 4 ACE
assert len(slots) == 11
assert [s["global_index"] for s in slots] == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
assert [s["box_id"] for s in slots][:3] == [-1, -1, -1]
# ── _box_local_to_global / _global_to_box_slot round-trip ────────────────────
def _bridge_with_mode(mode, slots):
b = object.__new__(KobraXBridge)
b._filament_mode = mode
b._ams_slots = slots
return b
def test_box_local_to_global_ace_direct_second_unit():
b = _bridge_with_mode("ace_direct", [])
assert b._box_local_to_global(0, 2, []) == 2
assert b._box_local_to_global(1, 2, []) == 6
def test_global_to_box_slot_round_trip_two_units():
slots, _ = KobraXBridge._aggregate_slots([_ace_box(0), _ace_box(1)], "ace_direct")
b = _bridge_with_mode("ace_direct", slots)
for g in range(8):
box_id, local = b._global_to_box_slot(g)
assert (box_id, local) == (g // 4, g % 4)
assert b._box_local_to_global(box_id, local, []) == g

View File

@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured):
@pytest.mark.asyncio
async def test_settings_post_writes_env(client):
"""POST /api/settings schreibt Werte in .env-Datei."""
async def test_settings_post_writes_config_ini(client):
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("")
bridge._find_env_path = lambda: env_path
config_path = pathlib.Path(tmpdir) / "config.ini"
bridge._find_config_path = lambda: config_path
bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process
resp = await c.post("/api/settings", json={
"printer_ip": "10.0.0.5",
@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client):
})
assert resp.status == 200
content = env_path.read_text()
assert "PRINTER_IP=10.0.0.5" in content
assert "MQTT_USERNAME=userABCD" in content
assert "DEVICE_ID=deadbeef01234567" in content
content = config_path.read_text()
assert "printer_ip = 10.0.0.5" in content
assert "username = userABCD" in content
assert "device_id = deadbeef01234567" in content
@pytest.mark.asyncio
async def test_settings_post_preserves_existing_keys(client):
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
"""POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server)."""
c, bridge = client
with tempfile.TemporaryDirectory() as tmpdir:
env_path = pathlib.Path(tmpdir) / ".env"
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
bridge._find_env_path = lambda: env_path
config_path = pathlib.Path(tmpdir) / "config.ini"
config_path.write_text(
"[spoolman]\nserver = http://192.168.1.50:7912\n\n"
"[connection]\nprinter_ip = old\n"
)
bridge._find_config_path = lambda: config_path
bridge._restart_bridge = lambda: None
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
content = env_path.read_text()
assert "GITEA_TOKEN=mytoken" in content
assert "PRINTER_IP=10.0.0.99" in content
content = config_path.read_text()
assert "server = http://192.168.1.50:7912" in content
assert "printer_ip = 10.0.0.99" in content

View File

@ -394,41 +394,36 @@ function applyLang() {
bnb = document.getElementById("bnb-store");
if (bnb) bnb.lastChild.textContent = T.nav_browser;
// Browser panel
setText("printers-panel-title", "🖨 " + T.nav_printers);
setText("add-printer-btn-label", T.add_printer);
setText("apd-title", T.add_printer);
setText("skip-title", T.skip_title);
setText("skip-hint", T.skip_hint);
setText("d-btn-skip-label", T.skip_btn_label);
setText("fd-objects-toggle-lbl", T.fd_objects_toggle);
setText("apd-lbl-ip", T.apd_lbl_ip);
setText("apd-lbl-name", T.apd_lbl_name);
var apn = document.getElementById("apd-name");
if (apn) apn.setAttribute("placeholder", T.apd_placeholder_name);
setText("apd-cancel", T.apd_cancel);
setText("apd-confirm", T.apd_confirm);
setText("fd-slots-hint", T.fd_slots_hint);
setText("fd-cancel", T.fd_cancel);
setText("fd-print", T.fd_print);
setText("store-panel-title", "🗂 " + T.panel_browser_title);
var srb = document.getElementById("store-refresh-btn");
if (srb) srb.textContent = T.store_refresh;
var ssp = document.getElementById("store-search");
if (ssp) ssp.setAttribute("placeholder", T.store_search_placeholder);
setText("store-upload-label-prefix", T.store_upload_label_prefix);
setText("store-upload-label-browse", T.store_upload_label_browse);
setText("store-empty", T.store_empty);
setText("sf-all", T.sf_all);
setText("sf-ok", T.sf_ok);
setText("sf-err", T.sf_err);
setText("sf-new", T.sf_new);
setText("ss-date", T.ss_date);
setText("ss-name", T.ss_name);
setText("ss-dur", T.ss_dur);
setText("store-web-verify-title", T.store_web_verify_title);
setText("store-web-verify-msg", T.store_web_verify_msg);
setText("store-web-verify-confirm", T.store_web_verify_confirm);
setText("store-web-verify-abort", T.store_web_verify_abort);
setText('printers-panel-title','🖨 '+T.nav_printers);
setText('add-printer-btn-label',T.add_printer);
setText('apd-title',T.add_printer);
setText('skip-title',T.skip_title);
setText('skip-hint',T.skip_hint);
setText('d-btn-skip-label',T.skip_btn_label);
setText('fd-objects-toggle-lbl',T.fd_objects_toggle);
setText('apd-lbl-ip',T.apd_lbl_ip);
setText('apd-lbl-name',T.apd_lbl_name);
var apn=document.getElementById('apd-name');if(apn)apn.setAttribute('placeholder',T.apd_placeholder_name);
setText('apd-cancel',T.apd_cancel);
setText('apd-confirm',T.apd_confirm);
setText('fd-slots-hint',T.fd_slots_hint);
setText('fd-cancel',T.fd_cancel);
setText('fd-print',T.fd_print);
setText('store-panel-title','🗂 '+T.panel_browser_title);
var srb=document.getElementById('store-refresh-btn');if(srb)srb.textContent=T.store_refresh;
var ssp=document.getElementById('store-search');if(ssp)ssp.setAttribute('placeholder',T.store_search_placeholder);
setText('store-upload-label-prefix',T.store_upload_label_prefix);
setText('store-upload-label-browse',T.store_upload_label_browse);
setText('store-empty',T.store_empty);
setText('sf-all',T.sf_all);setText('sf-ok',T.sf_ok);setText('sf-err',T.sf_err);setText('sf-new',T.sf_new);
setText('ss-date',T.ss_date);setText('ss-name',T.ss_name);setText('ss-dur',T.ss_dur);
setText('store-web-verify-title',T.store_web_verify_title);
setText('store-web-verify-msg',T.store_web_verify_msg);
setText('store-web-verify-confirm',T.store_web_verify_confirm);
setText('store-web-verify-abort',T.store_web_verify_abort);
setText('store-lbl-select-all',T.store_select_all||'Select All');
setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected');
setText('store-lbl-exit-select',T.store_exit_select||'Cancel');
// Dashboard card titles
setText("d-card-progress", T.card_progress);
setText("d-card-temps", T.card_temps);
@ -511,29 +506,20 @@ function applyLang() {
pollHint.textContent =
T.settings_poll_interval_hint || "Wie oft die Bridge den Drucker-Status abfragt";
setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)');
setText(
"lbl-filament-mapping",
T.settings_filament_mapping || "Filament-Profil-Mapping (pro Slot)"
);
var fmHint = document.getElementById("filament-mapping-hint");
if (fmHint)
fmHint.textContent =
T.settings_filament_mapping_hint ||
'Festes Orca-Profil pro AMS-Slot. Beim Slicer-Sync sendet die Bridge dieses Profil statt „Generic".';
setText("lbl-filament-mapping-save", T.settings_filament_mapping_save || "Mapping speichern");
setText(
"lbl-visible-vendors",
T.settings_visible_vendors || "Sichtbare Hersteller (Profil-Dropdown)"
);
var vfs = document.getElementById("vendor-filter-search");
if (vfs)
vfs.setAttribute("placeholder", T.settings_vendor_filter_placeholder || "Hersteller suchen…");
setText(
"visible-vendors-hint",
T.settings_visible_vendors_hint ||
'Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.'
);
setText("lbl-visible-vendors-save", T.settings_visible_vendors_save || "Auswahl speichern");
var dashLbl=document.getElementById('dash-lbl-edit');
if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
setText('dash-lbl-reset',T.dash_reset||'Reset');
setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset');
var dashPresetStd=document.getElementById('dash-preset-standard');
if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard';
var dashPresetWide=document.getElementById('dash-preset-wide89');
if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop';
if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar();
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
setText('visible-vendors-hint',T.settings_visible_vendors_hint||'Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.');
setText('lbl-visible-vendors-save',T.settings_visible_vendors_save||'Auswahl speichern');
// Custom-Profile-Import (Issue #41)
setText("modal-sec-orca-profiles", T.orca_profile_section);
setText("orca-profiles-hint", T.orca_profile_hint);
@ -1003,21 +989,25 @@ function applyState() {
var s = S;
_syncAceDryPresetsFromServer(s.ace_dry_presets);
// connection error banner nur wenn überhaupt ein Drucker konfiguriert ist
var banner = document.getElementById("conn-error-banner");
if (banner) {
if (s.connection_error && _printers.length > 0) {
banner.textContent = "⚠ " + tr("lbl_conn_error") + " " + s.connection_error;
banner.style.display = "block";
} else {
banner.style.display = "none";
var banner=document.getElementById('conn-error-banner');
if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}}
var pauseBanner=document.getElementById('pause-msg-banner');
if(pauseBanner){
if(s.pause_msg && s.print_state==='paused'){
var codePart = (s.error_code==0) ? ' ' :
' [<a href="https://wiki.anycubic.com/en/error-codes/'+s.error_code+'-code" target="_blank">'+s.error_code+'</a>] ';
pauseBanner.innerHTML='⏸ '+tr('lbl_pause_reason')+codePart+s.pause_msg;
pauseBanner.style.display='block';
}else{
pauseBanner.style.display='none';
}
}
var bannerVisible = false;
var frb = document.getElementById("file-ready-banner");
if (frb) {
var shouldAutoOpen = s.print_start_dialog === undefined ? true : !!s.print_start_dialog;
if (s.file_ready && s.print_state === "standby") {
document.getElementById("file-ready-name").textContent = s.file_ready;
var bannerVisible=false;
var frb=document.getElementById('file-ready-banner');
if(frb){
var shouldAutoOpen=(s.print_start_dialog===undefined?true:!!s.print_start_dialog);
if(s.file_ready&&s.print_state==='standby'){
document.getElementById('file-ready-name').textContent=s.file_ready;
// Neue Datei → Abbruch-Sperre aufheben
if (_fdAutoOpenedFile && _fdAutoOpenedFile !== s.file_ready) {
_fdUserCancelled = false;
@ -2860,8 +2850,313 @@ var pollTimer;
}).catch(function(){});
poll();pollTimer=setInterval(poll,ms);
setInterval(_loadSpoolmanStatus,30000);
// initDashGrid() is called at the very end of the file, after all the
// DASH_* var declarations below have executed (they are hoisted but not yet
// assigned at this point in the IIFE).
})();
// ── Dashboard Free Grid (Issue #89) ────────────────────────────────────────
// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js).
// Cards move + resize on a 12-column snap grid; layout persisted per browser
// in localStorage. Built on documented APIs only:
// - float:false (default) -> compact packing, no holes between cards
// - staticGrid:true -> locked by default, setStatic(false) in edit mode
// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id
// - draggable.cancel -> inputs/buttons/img excluded from drag start
// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware)
var DASH_STORAGE_KEY='dashLayout';
var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states
var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams'];
// Layout arrays in GridStack save()/load() format. cellHeight=60px.
var DASH_DEFAULT_LAYOUT=[
{id:'camera', x:0,y:0, w:12,h:7},
{id:'progress',x:0,y:7, w:12,h:6},
{id:'temps', x:0,y:13,w:6, h:7},
{id:'motion', x:6,y:13,w:6, h:7},
{id:'speed', x:0,y:20,w:6, h:3},
{id:'fan', x:6,y:20,w:6, h:3},
{id:'ams', x:0,y:23,w:12,h:4},
];
// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center,
// motion right.
var DASH_PRESET_WIDE=[
{id:'progress',x:0, y:0, w:12,h:4},
{id:'camera', x:0, y:4, w:6, h:11},
{id:'temps', x:6, y:4, w:3, h:7},
{id:'speed', x:6, y:11,w:3, h:2},
{id:'fan', x:6, y:13,w:3, h:2},
{id:'motion', x:9, y:4, w:3, h:11},
{id:'ams', x:0, y:15,w:12,h:4},
];
var _dashGrid=null;
var _dashEditing=false;
var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show)
// GridStack requires .grid-stack-item > .grid-stack-item-content around each
// card — wrap the existing .card elements in place (IDs/event handlers survive,
// appendChild moves nodes without recreating them).
function _dashWrapCards(){
var grid=document.getElementById('dash-grid');
if(!grid)return;
DASH_CARD_KEYS.forEach(function(key){
var card=grid.querySelector('[data-card="'+key+'"]');
if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return;
var item=document.createElement('div');
item.className='grid-stack-item';
item.setAttribute('gs-id',key);
var content=document.createElement('div');
content.className='grid-stack-item-content';
grid.insertBefore(item,card);
item.appendChild(content);
content.appendChild(card);
});
}
function _dashItem(key){
return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]');
}
function _dashLoadState(){
try{
var raw=localStorage.getItem(DASH_STORAGE_KEY);
if(!raw)return null;
var s=JSON.parse(raw);
if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null;
return s;
}catch(e){return null;}
}
function _dashSaveState(){
if(!_dashGrid)return;
localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({
version:DASH_LAYOUT_VERSION,
layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only
hidden:_dashHidden.slice(),
}));
}
function initDashGrid(){
var el=document.getElementById('dash-grid');
if(!el||typeof GridStack==='undefined')return;
_dashWrapCards();
_dashGrid=GridStack.init({
cellHeight:60,
margin:8,
staticGrid:true, // locked by default; setStatic(false) enables drag+resize
columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware)
draggable:{cancel:'input,textarea,button,select,option,img,.slider'},
},el);
var state=_dashLoadState();
if(state){
_dashHidden=Array.isArray(state.hidden)?state.hidden:[];
_dashGrid.load(state.layout,false); // update matching ids, no add/remove
_dashHidden.forEach(function(n){
var item=_dashItem(n.id);
if(item){_dashGrid.removeWidget(item,false);item.style.display='none';}
});
}else{
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
}
_dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();});
_dashRefreshPresetDropdown();
}
function toggleDashEdit(){
if(!_dashGrid)return;
_dashEditing=!_dashEditing;
_dashGrid.setStatic(!_dashEditing);
document.getElementById('dash-grid').classList.toggle('editing',_dashEditing);
document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none';
document.getElementById('dash-preset').style.display=_dashEditing?'':'none';
document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none';
_dashUpdatePresetDeleteBtn();
if(_dashEditing)_dashInjectControls(); else _dashRemoveControls();
_dashRenderHiddenBar();
if(!_dashEditing)_dashSaveState();
}
// ── Custom presets (user-saved layouts) ────────────────────────────────────
var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets';
var DASH_BUILTIN_PRESET_IDS=['standard','wide89'];
function _dashLoadCustomPresets(){
try{
var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY);
var obj=raw?JSON.parse(raw):{};
return (obj&&typeof obj==='object')?obj:{};
}catch(e){return {};}
}
function _dashSaveCustomPresets(presets){
localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets));
}
function _dashRefreshPresetDropdown(){
var sel=document.getElementById('dash-preset');
if(!sel)return;
var current=sel.value;
var customs=_dashLoadCustomPresets();
// Remove previously-rendered custom <option>s, keep the two built-ins.
Array.prototype.slice.call(sel.querySelectorAll('option')).forEach(function(o){
if(DASH_BUILTIN_PRESET_IDS.indexOf(o.value)===-1)sel.removeChild(o);
});
Object.keys(customs).sort().forEach(function(name){
var opt=document.createElement('option');
opt.value='custom:'+name;
opt.textContent=name;
sel.appendChild(opt);
});
if(Array.prototype.some.call(sel.options,function(o){return o.value===current;}))sel.value=current;
_dashUpdatePresetDeleteBtn();
}
function _dashUpdatePresetDeleteBtn(){
var sel=document.getElementById('dash-preset');
var delBtn=document.getElementById('dash-preset-delete-btn');
if(!sel||!delBtn)return;
var isCustom=sel.value.indexOf('custom:')===0;
delBtn.style.display=(_dashEditing&&isCustom)?'':'none';
}
function saveCurrentAsDashPreset(){
if(!_dashGrid)return;
var name=(prompt(T.dash_preset_name_prompt||'Preset name:')||'').trim();
if(!name)return;
var customs=_dashLoadCustomPresets();
customs[name]=_dashGrid.save(false);
_dashSaveCustomPresets(customs);
_dashRefreshPresetDropdown();
var sel=document.getElementById('dash-preset');
if(sel)sel.value='custom:'+name;
_dashUpdatePresetDeleteBtn();
}
function deleteCurrentDashPreset(){
var sel=document.getElementById('dash-preset');
if(!sel||sel.value.indexOf('custom:')!==0)return;
var name=sel.value.slice(7);
if(!confirm((T.dash_preset_delete_confirm||'Delete preset "{name}"?').replace('{name}',name)))return;
var customs=_dashLoadCustomPresets();
delete customs[name];
_dashSaveCustomPresets(customs);
sel.value='standard';
_dashRefreshPresetDropdown();
resetDashLayout();
}
function _dashInjectControls(){
DASH_CARD_KEYS.forEach(function(key){
if(_dashHidden.some(function(n){return n.id===key;}))return;
var content=document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"] .grid-stack-item-content');
if(!content||content.querySelector('.dash-card-controls'))return;
var ctrl=document.createElement('div');
ctrl.className='dash-card-controls';
var hideBtn=document.createElement('button');
hideBtn.className='dash-card-ctrl-btn';
hideBtn.title=T.dash_hide||'Hide';
hideBtn.textContent='👁';
hideBtn.addEventListener('click',function(e){e.stopPropagation();dashHideCard(key);});
ctrl.appendChild(hideBtn);
content.appendChild(ctrl);
});
}
function _dashRemoveControls(){
var nodes=document.querySelectorAll('#dash-grid .dash-card-controls');
Array.prototype.forEach.call(nodes,function(n){n.parentNode&&n.parentNode.removeChild(n);});
}
function _dashCardLabel(key){
var card=document.querySelector('[data-card="'+key+'"]');
if(!card)return key;
var t=card.querySelector('.card-title');
return t?t.textContent.trim():key;
}
function _dashRenderHiddenBar(){
var bar=document.getElementById('dash-hidden-bar');
if(!bar)return;
bar.innerHTML='';
if(!_dashEditing||!_dashHidden.length){bar.classList.remove('show');return;}
bar.classList.add('show');
var label=document.createElement('span');
label.textContent=(T.dash_hidden_cards||'Hidden cards')+': ';
bar.appendChild(label);
_dashHidden.forEach(function(n){
var chip=document.createElement('span');
chip.className='dash-hidden-chip';
chip.appendChild(document.createTextNode(_dashCardLabel(n.id)+' '));
var btn=document.createElement('button');
btn.textContent='👁';
btn.title=T.dash_show||'Show';
btn.addEventListener('click',function(){dashShowCard(n.id);});
chip.appendChild(btn);
bar.appendChild(chip);
});
}
function dashHideCard(key){
if(_dashHidden.some(function(n){return n.id===key;}))return;
var item=_dashItem(key);
if(!item||!_dashGrid)return;
// Remember position/size so re-show can restore it.
var n=item.gridstackNode||{};
_dashHidden.push({id:key,x:n.x,y:n.y,w:n.w,h:n.h});
_dashGrid.removeWidget(item,false); // keep DOM element, drop the widget
item.style.display='none';
_dashSaveState();
_dashRenderHiddenBar();
}
function dashShowCard(key){
var entry=null;
_dashHidden=_dashHidden.filter(function(n){if(n.id===key){entry=n;return false;}return true;});
var item=_dashItem(key);
if(item&&_dashGrid){
item.style.display='';
_dashGrid.makeWidget(item);
if(entry&&entry.w)_dashGrid.update(item,{x:entry.x,y:entry.y,w:entry.w,h:entry.h});
if(_dashEditing)_dashInjectControls();
}
_dashSaveState();
_dashRenderHiddenBar();
}
function _dashShowAllHidden(){
_dashHidden.slice().forEach(function(n){dashShowCard(n.id);});
_dashHidden=[];
}
function resetDashLayout(){
if(!_dashGrid)return;
localStorage.removeItem(DASH_STORAGE_KEY);
var sel=document.getElementById('dash-preset');if(sel)sel.value='standard';
_dashShowAllHidden();
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
if(_dashEditing){_dashInjectControls();_dashSaveState();}
_dashRenderHiddenBar();
}
function applyDashPreset(name){
if(name==='standard'){resetDashLayout();return;}
if(!_dashGrid)return;
var layout=null;
if(name==='wide89'){
layout=DASH_PRESET_WIDE;
}else if(name.indexOf('custom:')===0){
var customs=_dashLoadCustomPresets();
layout=customs[name.slice(7)];
}
if(!layout)return;
var sel=document.getElementById('dash-preset');
if(sel)sel.value=name; // keep the dropdown in sync even when called directly
_dashShowAllHidden();
_dashGrid.load(layout,false);
_dashUpdatePresetDeleteBtn();
if(_dashEditing)_dashInjectControls();
_dashSaveState();
_dashRenderHiddenBar();
}
// Init now — all DASH_* declarations above have run, and app.js loads at
// </body>, so the DOM is ready.
initDashGrid();
// ── Print actions ──
function printAction(a) {
post("/printer/print/" + a, {})
@ -3490,25 +3785,79 @@ function aceDryStop(aceId) {
});
}
function loadStore() {
fetch(_apiUrl("/kx/files"))
.then(function (r) {
return r.json();
})
.then(function (d) {
storeFiles = d.result || [];
renderStore();
})
.catch(function (e) {
clog(tr("log_error", "Fehler:") + " " + e, "msg-err");
});
function loadStore(){
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
storeFiles=d.result||[];
// Reset any pending selection - stale ids from a deleted/renamed file
// should never carry over into a fresh file list.
storeExitSelectMode();
renderStore();
}).catch(function(e){clog('Store-Fehler: '+e,'msg-err')});
}
function uploadGcode(file) {
if (!file) return;
var zone = document.getElementById("store-upload-zone");
var status = document.getElementById("store-upload-status");
var label = document.getElementById("store-upload-label");
// Applies the store's search/filter/sort controls to storeFiles. Shared by
// renderStore() and storeToggleSelectAll() so "Select All" only selects what
// the user can currently see, not the entire (possibly filtered-out) list.
function _storeFilteredFiles(){
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
return files;
}
// Applies the store's search/filter/sort controls to storeFiles. Shared by
// renderStore() and storeToggleSelectAll() so "Select All" only selects what
// the user can currently see, not the entire (possibly filtered-out) list.
function _storeFilteredFiles(){
var q=(document.getElementById('store-search')||{value:''}).value.toLowerCase().trim();
var filter=(document.getElementById('store-filter')||{value:'all'}).value;
var sort=(document.getElementById('store-sort')||{value:'date_desc'}).value;
var files=storeFiles.filter(function(f){
if(q&&f.filename.toLowerCase().indexOf(q)===-1) return false;
if(filter==='completed'&&f.last_print_status!=='completed') return false;
if(filter==='failed'&&(f.last_print_status!=='cancelled'&&f.last_print_status!=='failed')) return false;
if(filter==='never'&&f.last_print_status) return false;
return true;
});
files.sort(function(a,b){
if(sort==='name_asc') return a.filename.localeCompare(b.filename);
if(sort==='duration_asc'){
var da=a.last_print_duration||a.est_print_time_sec||0;
var db=b.last_print_duration||b.est_print_time_sec||0;
return da-db;
}
// date_desc (default)
return (b.uploaded_at||'').localeCompare(a.uploaded_at||'');
});
return files;
}
function uploadGcode(file){
if(!file) return;
var zone=document.getElementById('store-upload-zone');
var status=document.getElementById('store-upload-status');
var label=document.getElementById('store-upload-label');
// Nur druckbare Dateien zulassen (Issue #59) — Drag&Drop umgeht das
// accept-Attribut, daher hier explizit prüfen.
var _fn = (file.name || "").toLowerCase();
@ -3565,40 +3914,10 @@ function uploadGcode(file) {
});
}
function renderStore() {
var grid = document.getElementById("store-grid");
var empty = document.getElementById("store-empty");
// Suche
var q = (document.getElementById("store-search") || { value: "" }).value.toLowerCase().trim();
// Filter
var filter = (document.getElementById("store-filter") || { value: "all" }).value;
// Sortierung
var sort = (document.getElementById("store-sort") || { value: "date_desc" }).value;
var files = storeFiles.filter(function (f) {
if (q && f.filename.toLowerCase().indexOf(q) === -1) return false;
if (filter === "completed" && f.last_print_status !== "completed") return false;
if (
filter === "failed" &&
f.last_print_status !== "cancelled" &&
f.last_print_status !== "failed"
)
return false;
if (filter === "never" && f.last_print_status) return false;
return true;
});
files.sort(function (a, b) {
if (sort === "name_asc") return a.filename.localeCompare(b.filename);
if (sort === "duration_asc") {
var da = a.last_print_duration || a.est_print_time_sec || 0;
var db = b.last_print_duration || b.est_print_time_sec || 0;
return da - db;
}
// date_desc (default)
return (b.uploaded_at || "").localeCompare(a.uploaded_at || "");
});
function renderStore(){
var grid=document.getElementById('store-grid');
var empty=document.getElementById('store-empty');
var files=_storeFilteredFiles();
if (!storeFiles.length) {
empty.textContent = T.store_empty;
@ -3612,82 +3931,75 @@ function renderStore() {
empty.style.display = "block";
return;
}
empty.style.display = "none";
grid.innerHTML = files
.map(function (f) {
var thumb = f.thumbnail_b64
? '<img src="data:image/png;base64,' +
f.thumbnail_b64 +
'" style="width:100%;height:130px;object-fit:cover;border-radius:6px;display:block;margin-bottom:8px">'
: '<div style="width:100%;height:130px;background:var(--raised);border-radius:6px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;font-size:32px">🖨</div>';
var name = f.filename.length > 28 ? f.filename.slice(0, 25) + "…" : f.filename;
var date = f.uploaded_at ? f.uploaded_at.replace("T", " ").slice(0, 16) : "";
var est = f.est_print_time_sec ? formatDur(f.est_print_time_sec) : "";
var statusBadge = "";
var lastInfo = "";
if (f.last_print_status === "completed") {
statusBadge =
'<span style="background:#2a7a3b;color:#fff;border-radius:4px;padding:1px 6px;font-size:10px;margin-left:4px">✓</span>';
if (f.last_print_duration)
lastInfo =
'<div style="font-size:11px;color:var(--ok);margin-bottom:2px">✓ ' +
formatDur(f.last_print_duration) +
"</div>";
} else if (f.last_print_status === "cancelled" || f.last_print_status === "failed") {
statusBadge =
'<span style="background:#a04020;color:#fff;border-radius:4px;padding:1px 6px;font-size:10px;margin-left:4px">✗</span>';
lastInfo =
'<div style="font-size:11px;color:var(--err);margin-bottom:2px">✗ ' +
f.last_print_status +
"</div>";
} else if (!f.last_print_status) {
lastInfo =
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px;opacity:.6">' +
T.store_never +
"</div>";
}
return (
'<div style="background:var(--raised);border:1px solid var(--border);border-radius:8px;padding:10px;display:flex;flex-direction:column">' +
thumb +
'<div title="' +
f.filename +
'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">' +
name +
statusBadge +
"</div>" +
lastInfo +
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">⏱ ' +
T.store_estimate +
": " +
est +
"</div>" +
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 ' +
date +
"</div>" +
'<div style="display:flex;gap:6px;margin-top:auto">' +
"<button onclick=\"storePrint('" +
f.id +
"','" +
f.filename.replace(/'/g, "\\'") +
"')\" " +
'style="flex:1;font-size:12px;padding:5px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer">' +
T.store_print +
"</button>" +
"<button onclick=\"storeDownload('" +
f.id +
'\')" title="' +
T.store_download +
'" ' +
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">⬇</button>' +
"<button onclick=\"storeDelete('" +
f.id +
"')\" " +
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>' +
"</div>" +
"</div>"
);
})
.join("");
empty.style.display='none';
grid.innerHTML=files.map(function(f){
var thumb=f.thumbnail_b64
? '<img src="data:image/png;base64,'+f.thumbnail_b64+'" style="width:100%;height:130px;object-fit:cover;border-radius:6px;display:block;margin-bottom:8px">'
: '<div style="width:100%;height:130px;background:var(--raised);border-radius:6px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;font-size:32px">🖨</div>';
var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename;
var date=f.uploaded_at?f.uploaded_at.replace('T',' ').slice(0,16):'';
var est=f.est_print_time_sec?formatDur(f.est_print_time_sec):'';
var statusBadge='';
var lastInfo='';
if(f.last_print_status==='completed'){
statusBadge='<span style="background:#2a7a3b;color:#fff;border-radius:4px;padding:1px 6px;font-size:10px;margin-left:4px">✓</span>';
if(f.last_print_duration) lastInfo='<div style="font-size:11px;color:var(--ok);margin-bottom:2px">✓ '+formatDur(f.last_print_duration)+'</div>';
} else if(f.last_print_status==='cancelled'||f.last_print_status==='failed'){
statusBadge='<span style="background:#a04020;color:#fff;border-radius:4px;padding:1px 6px;font-size:10px;margin-left:4px">✗</span>';
lastInfo='<div style="font-size:11px;color:var(--err);margin-bottom:2px">✗ '+f.last_print_status+'</div>';
} else if(!f.last_print_status){
lastInfo='<div style="font-size:11px;color:var(--txt2);margin-bottom:2px;opacity:.6">'+T.store_never+'</div>';
}
var isSelected=!!_storeSelected[f.id];
var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px';
// Always visible (not just once select mode is on) - otherwise there is
// no way to ever enter select mode, since it's normally entered by
// clicking this very checkbox. White circle behind it so it stays
// legible against any thumbnail.
var checkbox='<span style="position:absolute;top:6px;left:6px;width:22px;height:22px;'+
'border-radius:50%;background:rgba(255,255,255,.85);z-index:2;display:flex;'+
'align-items:center;justify-content:center">'+
'<input type="checkbox" class="store-card-cb" '+(isSelected?'checked':'')+
' onclick="event.stopPropagation();storeToggleSelect(\''+f.id+'\')" '+
'style="width:16px;height:16px;margin:0"></span>';
var cardClick=_storeSelectMode?'onclick="storeToggleSelect(\''+f.id+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"':
'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"';
return '<div '+cardClick+'>'+
checkbox+
thumb+
'<div title="'+f.filename+'" style="font-size:12px;font-weight:600;margin-bottom:4px;color:var(--txt)">'+name+statusBadge+'</div>'+
lastInfo+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:2px">⏱ '+T.store_estimate+': '+est+'</div>'+
'<div style="font-size:11px;color:var(--txt2);margin-bottom:8px">📅 '+date+'</div>'+
'<div style="display:flex;gap:6px;margin-top:auto">'+
'<button onclick="event.stopPropagation();storePrint(\''+f.id+'\',\''+f.filename.replace(/'/g,"\\'")+'\')" '+
'style="flex:1;font-size:12px;padding:5px;background:var(--accent);color:#fff;border:none;border-radius:6px;cursor:pointer">'+T.store_print+'</button>'+
'<button onclick="event.stopPropagation();storeDownload(\''+f.id+'\')" title="'+T.store_download+'" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">⬇</button>'+
'<button onclick="event.stopPropagation();storeDelete(\''+f.id+'\')" '+
'style="font-size:12px;padding:5px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">🗑</button>'+
'</div>'+
'</div>';
}).join('');
_storeUpdateSelectBar(files);
}
// Reflects the current selection onto the select-all checkbox (incl.
// indeterminate state), the selected-count label, and the delete button's
// disabled state. `files` is the currently-filtered/visible list.
function _storeUpdateSelectBar(files){
var selectAll=document.getElementById('store-select-all');
var countEl=document.getElementById('store-selected-count');
var delBtn=document.getElementById('store-delete-selected-btn');
if(!selectAll||!countEl||!delBtn)return;
var visibleIds=files.map(function(f){return f.id;});
var selectedVisible=visibleIds.filter(function(id){return _storeSelected[id];});
var n=selectedVisible.length;
selectAll.checked=n>0&&n===visibleIds.length;
selectAll.indeterminate=n>0&&n<visibleIds.length;
countEl.textContent=n>0?(T.store_selected_count||'{n} selected').replace('{n}',n):'';
delBtn.disabled=n===0;
}
function formatDur(sec) {
@ -3706,7 +4018,53 @@ var _pendingWebVerifyAutoOpen = false;
// GCode-Store-Dateiliste. MUSS deklariert sein sonst ReferenceError, wenn
// "Slots wählen" im Banner geklickt wird, bevor der Browser-Tab je geladen
// wurde (Issue #29 / Theme-Auslagerung PR #27).
var storeFiles = [];
var storeFiles=[];
// Multi-select state for the GCode browser (Issue #94).
var _storeSelectMode=false;
var _storeSelected={}; // file.id -> true
function storeToggleSelect(id){
if(!_storeSelectMode){
_storeSelectMode=true;
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='flex';
}
if(_storeSelected[id])delete _storeSelected[id];
else _storeSelected[id]=true;
renderStore();
}
function storeToggleSelectAll(checked){
// Only the currently filtered/visible files are affected, so search/filter
// never causes "Select All" to silently pick up hidden files too.
var visible=_storeFilteredFiles();
_storeSelected={};
if(checked)visible.forEach(function(f){_storeSelected[f.id]=true;});
renderStore();
}
function storeExitSelectMode(){
_storeSelectMode=false;
_storeSelected={};
var bar=document.getElementById('store-select-bar');
if(bar)bar.style.display='none';
renderStore();
}
function storeDeleteSelected(){
var ids=Object.keys(_storeSelected);
if(!ids.length)return;
if(!confirm((T.store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',ids.length)))return;
Promise.all(ids.map(function(id){
return fetch(_apiUrl('/kx/files/'+id),{method:'DELETE'}).then(function(r){return{id:id,ok:r.ok};});
})).then(function(results){
var failed=results.filter(function(r){return !r.ok;});
if(failed.length)clog((T.log_delete_failed||'Delete failed')+': '+failed.length,'msg-err');
storeExitSelectMode();
loadStore();
});
}
var _gcodeFilaments = [];

View File

@ -6,10 +6,13 @@
<title>KX-Bridge</title>
<link rel="stylesheet" href="/kx/ui/style.css">
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">
<script src="/kx/ui/lib/pickr.min.js"></script>
<script src="/kx/ui/lib/gridstack-all.min.js"></script>
<body>
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
<div id="pause-msg-banner" style="display:none;background:#b8860b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:997;"></div>
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
<span>📄 <span id="file-ready-name"></span></span>
<button id="file-ready-btn" onclick="startReadyFile()"
@ -244,9 +247,19 @@
<main>
<!-- ═══ DASHBOARD ═══ -->
<div class="panel active" id="panel-dashboard">
<div class="grid">
<div id="dash-toolbar" style="display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-bottom:10px">
<select id="dash-preset" onchange="applyDashPreset(this.value)" style="display:none;padding:4px 8px;font-size:12px;background:var(--raised);color:var(--txt);border:1px solid var(--border);border-radius:6px">
<option value="standard" id="dash-preset-standard">Standard</option>
<option value="wide89" id="dash-preset-wide89">Desktop breit</option>
</select>
<button id="dash-preset-delete-btn" onclick="deleteCurrentDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--err);border:1px solid var(--border);border-radius:6px;cursor:pointer">🗑</button>
<button id="dash-preset-save-btn" onclick="saveCurrentAsDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">💾 <span id="dash-lbl-save-preset">Als Preset speichern</span></button>
<button id="dash-reset-btn" onclick="resetDashLayout()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer"><span id="dash-lbl-reset">Zurücksetzen</span></button>
<button id="dash-edit-btn" onclick="toggleDashEdit()" style="padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">🖉 <span id="dash-lbl-edit">Dashboard anpassen</span></button>
</div>
<div class="grid-stack" id="dash-grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="card-camera" data-card="camera" gs-w="12" gs-h="7">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
<div style="display:flex;align-items:center;gap:10px">
@ -261,7 +274,7 @@
<div class="cam-wrap" id="cam-wrap">
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
<div class="cam-spinner" id="cam-spinner"></div>
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
<img id="cam-img" draggable="false" style="display:none;width:100%;height:auto" alt="Kamera">
<div class="cam-overlay" id="cam-overlay" style="display:none">
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
</div>
@ -270,200 +283,97 @@
</div>
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column: 1/-1">
<div class="card-title">
<span></span> <span id="d-card-progress">Fortschritt</span>
<!-- Fortschritt -->
<div class="card" id="card-progress" data-card="progress" gs-w="12" gs-h="6">
<div class="card-title"><span></span> <span id="d-card-progress">Fortschritt</span></div>
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
<div style="display:flex;align-items:center;gap:10px;margin:8px 0">
<div class="progress-bar" style="flex:1;margin:0"><div class="progress-fill" id="d-pbar" style="width:0%"></div></div>
<div style="display:flex;flex-direction:column;gap:4px;flex-shrink:0">
<div class="time-block" style="padding:6px 10px;min-width:72px;text-align:center">
<div class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size:16px" id="d-layers"></div>
</div>
<img
id="d-thumbnail"
src=""
alt=""
style="
display: none;
width: 100%;
max-height: 160px;
object-fit: contain;
border-radius: 8px;
background: #111;
margin-bottom: 10px;
"
/>
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
<div style="display: flex; align-items: center; gap: 10px; margin: 8px 0">
<div class="progress-bar" style="flex: 1; margin: 0">
<div class="progress-fill" id="d-pbar" style="width: 0%"></div>
</div>
<div style="display: flex; flex-direction: column; gap: 4px; flex-shrink: 0">
<div
class="time-block"
style="padding: 6px 10px; min-width: 72px; text-align: center"
>
<div class="time-label" id="d-lbl-layers"></div>
<div class="time-val" style="font-size: 16px" id="d-layers"></div>
</div>
<div
class="time-block"
style="padding: 4px 10px; min-width: 72px; text-align: center"
>
<div class="time-label" id="d-lbl-zpos">Z</div>
<div class="time-val" style="font-size: 13px" id="d-zpos"></div>
</div>
</div>
</div>
<div class="time-grid">
<div class="time-block">
<div class="time-label" id="d-lbl-elapsed"></div>
<div class="time-val" id="d-elapsed"></div>
</div>
<div class="time-block" id="d-slicer-row" style="display: none">
<div class="time-label" id="d-slicer-label"></div>
<div class="time-val" id="d-slicer-time"></div>
</div>
<div class="time-block" style="color: var(--acc)">
<div class="time-label" id="d-lbl-remain"></div>
<div class="time-val" id="d-remain" style="color: var(--acc)"></div>
</div>
</div>
<div class="fname" id="d-fname" title="" style="margin-top: 6px"></div>
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top: 12px; display: none">
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="togglePauseResume()">
⏸ Pause
</button>
<button
class="btn btn-skip btn-sm"
id="d-btn-skip"
onclick="openSkipDialog()"
style="display: none"
>
<span id="d-btn-skip-label">Objekte</span>
</button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">
✕ Stopp
</button>
</div>
<!-- Aktionen für eine geladene, aber nicht laufende Datei (Issue #55) -->
<div class="ctrl-btns" id="d-idle-btns" style="margin-top: 12px; display: none">
<button class="btn btn-accent btn-sm" id="d-idle-print" onclick="startIdleFile()">
<span id="d-idle-print-lbl">Drucken</span>
</button>
<button
class="btn btn-sm"
id="d-idle-slots"
onclick="startIdleFileWithSlots()"
style="background: var(--raised); color: var(--txt)"
>
<span id="d-idle-slots-lbl">Slots zuordnen</span>
</button>
<button class="btn btn-cancel btn-sm" id="d-idle-clear" onclick="clearIdleFile()">
<span id="d-idle-clear-lbl">Leeren</span>
</button>
<div class="time-block" style="padding:4px 10px;min-width:72px;text-align:center">
<div class="time-label" id="d-lbl-zpos">Z</div>
<div class="time-val" style="font-size:13px" id="d-zpos"></div>
</div>
</div>
</div>
<div class="time-grid">
<div class="time-block">
<div class="time-label" id="d-lbl-elapsed"></div>
<div class="time-val" id="d-elapsed"></div>
</div>
<div class="time-block" id="d-slicer-row" style="display:none">
<div class="time-label" id="d-slicer-label"></div>
<div class="time-val" id="d-slicer-time"></div>
</div>
<div class="time-block" style="color:var(--acc)">
<div class="time-label" id="d-lbl-remain"></div>
<div class="time-val" id="d-remain" style="color:var(--acc)"></div>
</div>
</div>
<div class="fname" id="d-fname" title="" style="margin-top:6px"></div>
<div class="ctrl-btns" id="d-ctrl-btns" style="margin-top:12px;display:none">
<button class="btn btn-pause btn-sm" id="d-btn-pause" onclick="togglePauseResume()">⏸ Pause</button>
<button class="btn btn-skip btn-sm" id="d-btn-skip" onclick="openSkipDialog()" style="display:none"><span id="d-btn-skip-label">Objekte</span></button>
<button class="btn btn-cancel btn-sm" id="d-btn-cancel" onclick="confirmCancel()">✕ Stopp</button>
</div>
<!-- Aktionen für eine geladene, aber nicht laufende Datei (Issue #55) -->
<div class="ctrl-btns" id="d-idle-btns" style="margin-top:12px;display:none">
<button class="btn btn-accent btn-sm" id="d-idle-print" onclick="startIdleFile()"><span id="d-idle-print-lbl">Drucken</span></button>
<button class="btn btn-sm" id="d-idle-slots" onclick="startIdleFileWithSlots()" style="background:var(--raised);color:var(--txt)"><span id="d-idle-slots-lbl">Slots zuordnen</span></button>
<button class="btn btn-cancel btn-sm" id="d-idle-clear" onclick="clearIdleFile()"><span id="d-idle-clear-lbl">Leeren</span></button>
</div>
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column: 1/-1">
<div class="card-title">
<span></span> <span id="d-card-temps">Temperaturen</span>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" id="card-temps" data-card="temps" gs-w="6" gs-h="7">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
<div class="temp-label" id="d-lbl-nozzle">Nozzle</div>
<div class="temp-row">
<div class="temp-val" id="d-nt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-card-inner">
<div class="temp-block">
<div class="temp-label" id="d-lbl-nozzle">Nozzle</div>
<div class="temp-row">
<div class="temp-val" id="d-nt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-nt-t">0</span>°C</div>
<div class="progress-bar" style="margin: 8px 0 0">
<div
class="progress-fill"
id="d-ntbar"
style="width: 0%; background: linear-gradient(90deg, var(--accent2), #ffb020)"
></div>
</div>
<div class="temp-edit" style="margin-top: 10px">
<input
type="number"
class="temp-input"
id="p-nozzle-inp"
placeholder="Ziel"
min="0"
max="300"
style="flex: 1"
/>
<button class="btn btn-sm btn-accent" onclick="setNozzle()">
<span class="lbl-set">Set</span>
</button>
<button
class="btn btn-sm"
style="background: var(--raised); color: var(--txt)"
onclick="
document.getElementById('p-nozzle-inp').value = 0;
setNozzle();
"
>
<span class="lbl-off">Aus</span>
</button>
</div>
</div>
<div class="temp-block">
<div class="temp-label" id="d-lbl-bed">Bett</div>
<div class="temp-row">
<div class="temp-val" id="d-bt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-bt-t">0</span>°C</div>
<div class="progress-bar" style="margin: 8px 0 0">
<div
class="progress-fill"
id="d-btbar"
style="width: 0%; background: linear-gradient(90deg, #ff6b35, var(--warn))"
></div>
</div>
<div class="temp-edit" style="margin-top: 10px">
<input
type="number"
class="temp-input"
id="p-bed-inp"
placeholder="Ziel"
min="0"
max="120"
style="flex: 1"
/>
<button class="btn btn-sm btn-accent" onclick="setBed()">
<span class="lbl-set">Set</span>
</button>
<button
class="btn btn-sm"
style="background: var(--raised); color: var(--txt)"
onclick="
document.getElementById('p-bed-inp').value = 0;
setBed();
"
>
<span class="lbl-off">Aus</span>
</button>
</div>
</div>
<div class="temp-target"><span id="d-nt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-ntbar" style="width:0%;background:linear-gradient(90deg,var(--accent2),#ffb020)"></div>
</div>
<div style="margin-top: 14px">
<div
style="font-size: 10px; color: var(--txt2); margin-bottom: 4px"
id="d-chart-label"
>
Verlauf (letzte 60 Messungen)
</div>
<canvas
id="d-chart"
width="800"
height="120"
style="width: 100%; height: 120px; background: var(--raised); border-radius: 8px"
></canvas>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-nozzle-inp" placeholder="Ziel" min="0" max="300" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setNozzle()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-nozzle-inp').value=0;setNozzle()"><span class="lbl-off">Aus</span></button>
</div>
</div>
<div class="temp-block">
<div class="temp-label" id="d-lbl-bed">Bett</div>
<div class="temp-row">
<div class="temp-val" id="d-bt"></div>
<div class="temp-unit">°C</div>
</div>
<div class="temp-target"><span id="d-bt-t">0</span>°C</div>
<div class="progress-bar" style="margin:8px 0 0">
<div class="progress-fill" id="d-btbar" style="width:0%;background:linear-gradient(90deg,#ff6b35,var(--warn))"></div>
</div>
<div class="temp-edit" style="margin-top:10px">
<input type="number" class="temp-input" id="p-bed-inp" placeholder="Ziel" min="0" max="120" style="flex:1">
<button class="btn btn-sm btn-accent" onclick="setBed()"><span class="lbl-set">Set</span></button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="document.getElementById('p-bed-inp').value=0;setBed()"><span class="lbl-off">Aus</span></button>
</div>
</div>
</div>
<div style="margin-top:14px">
<div style="font-size:10px;color:var(--txt2);margin-bottom:4px" id="d-chart-label">Verlauf (letzte 60 Messungen)</div>
<canvas id="d-chart" width="800" height="120" style="width:100%;height:120px;background:var(--raised);border-radius:8px"></canvas>
</div>
</div>
<!-- Achsensteuerung -->
<div class="card">
<div class="card" id="card-motion" data-card="motion" gs-w="6" gs-h="7">
<div class="card-title"><span></span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
<!-- XY -->
@ -510,99 +420,59 @@
</div>
</div>
<!-- Print Speed -->
<div class="card">
<div class="card-title">
<span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span>
</div>
<div style="display: flex; gap: 8px; margin-top: 4px">
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
<span class="spd-icon">🐢</span>
<span id="d-spd-lbl-1">Leise</span>
</button>
<button class="spd-btn spd-active" id="d-spd-2" onclick="setSpeed(2)">
<span class="spd-icon"></span>
<span id="d-spd-lbl-2">Normal</span>
</button>
<button class="spd-btn" id="d-spd-3" onclick="setSpeed(3)">
<span class="spd-icon">🚀</span>
<span id="d-spd-lbl-3">Sport</span>
</button>
</div>
<div class="spd-bar" style="margin-top: 12px">
<div class="spd-bar-fill" id="d-spd-bar" style="width: 50%"></div>
</div>
</div>
<!-- Lüfter -->
<div class="card">
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
<div class="slider-row">
<input
type="range"
class="slider"
min="0"
max="100"
value="0"
id="d-fan"
oninput="document.getElementById('d-fan-val').textContent = this.value"
onchange="setFan()"
/>
<span class="slider-val" id="d-fan-val">0</span>
</div>
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap">
<button
class="btn btn-sm"
id="d-fan-off"
style="background: var(--raised); color: var(--txt)"
onclick="quickFan(0)"
>
Aus
</button>
<button
class="btn btn-sm"
style="background: var(--raised); color: var(--txt)"
onclick="quickFan(25)"
>
25%
</button>
<button
class="btn btn-sm"
style="background: var(--raised); color: var(--txt)"
onclick="quickFan(50)"
>
50%
</button>
<button
class="btn btn-sm"
style="background: var(--raised); color: var(--txt)"
onclick="quickFan(75)"
>
75%
</button>
<button class="btn btn-sm btn-accent" onclick="quickFan(100)">100%</button>
</div>
</div>
<div id="d-ace-dry-wrap" style="display: none">
<div id="d-ace-dry-grid" style="display: contents"></div>
</div>
<!-- AMS -->
<div class="card" style="grid-column: 1/-1" id="d-ams-card">
<div class="card-title"><span></span> <span id="d-card-ams">Filament</span></div>
<div class="ams-slots" id="ams-slots">
<div
style="grid-column: 1/-1; text-align: center; color: var(--txt2); padding: 20px"
id="ams-no-data"
>
Keine AMS-Daten empfangen
</div>
</div>
</div>
<!-- Print Speed -->
<div class="card" id="card-speed" data-card="speed" gs-w="6" gs-h="3">
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
<div style="display:flex;gap:8px;margin-top:4px">
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
<span class="spd-icon">🐢</span>
<span id="d-spd-lbl-1">Leise</span>
</button>
<button class="spd-btn spd-active" id="d-spd-2" onclick="setSpeed(2)">
<span class="spd-icon"></span>
<span id="d-spd-lbl-2">Normal</span>
</button>
<button class="spd-btn" id="d-spd-3" onclick="setSpeed(3)">
<span class="spd-icon">🚀</span>
<span id="d-spd-lbl-3">Sport</span>
</button>
</div>
<div class="spd-bar" style="margin-top:12px">
<div class="spd-bar-fill" id="d-spd-bar" style="width:50%"></div>
</div>
</div>
<!-- Lüfter -->
<div class="card" id="card-fan" data-card="fan" gs-w="6" gs-h="3">
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
<div class="slider-row">
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
<span class="slider-val" id="d-fan-val">0</span>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(0)">Aus</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(25)">25%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(50)">50%</button>
<button class="btn btn-sm" style="background:var(--raised);color:var(--txt)" onclick="quickFan(75)">75%</button>
<button class="btn btn-sm btn-accent" onclick="quickFan(100)">100%</button>
</div>
</div>
<!-- AMS -->
<div class="card" id="d-ams-card" data-card="ams" gs-w="12" gs-h="4">
<div class="card-title"><span></span> <span id="d-card-ams">Filament</span></div>
<div class="ams-slots" id="ams-slots">
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
</div>
</div>
</div>
<!-- ACE-Trocknung: außerhalb des GridStack-Grids, per Drucker-State eingeblendet -->
<div id="d-ace-dry-wrap" class="grid" style="display:none;margin-top:16px">
<div id="d-ace-dry-grid" style="display:contents"></div>
</div>
<div id="dash-hidden-bar"></div>
</div>
<!-- ═══ CONSOLE ═══ -->
<!-- ═══ DRUCKER ═══ -->
<div class="panel" id="panel-printers">
@ -660,137 +530,58 @@
</div>
</div>
<!-- ═══ GCODE STORE ═══ -->
<div class="panel" id="panel-store">
<div class="card">
<div
class="card-title"
style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
"
>
<span id="store-panel-title">🗂 Datei-Browser</span>
<button
id="store-refresh-btn"
onclick="loadStore()"
style="
font-size: 12px;
padding: 4px 12px;
background: var(--raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--txt2);
cursor: pointer;
"
>
↻ Aktualisieren
</button>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap">
<input
id="store-search"
type="text"
placeholder="🔍 Suche…"
oninput="renderStore()"
style="
flex: 1;
min-width: 140px;
padding: 6px 10px;
background: var(--raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--txt);
font-size: 13px;
"
/>
<select
id="store-filter"
onchange="renderStore()"
style="
padding: 6px 8px;
background: var(--raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--txt);
font-size: 13px;
"
>
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select
id="store-sort"
onchange="renderStore()"
style="
padding: 6px 8px;
background: var(--raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--txt);
font-size: 13px;
"
>
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div
id="store-upload-zone"
onclick="document.getElementById('store-upload-input').click()"
ondragover="
event.preventDefault();
this.classList.add('drag-over');
"
ondragleave="this.classList.remove('drag-over')"
ondrop="
event.preventDefault();
this.classList.remove('drag-over');
uploadGcode(event.dataTransfer.files[0]);
"
>
<input
type="file"
id="store-upload-input"
accept=".gcode,.bgcode"
style="display: none"
onchange="
uploadGcode(this.files[0]);
this.value = '';
"
/>
<span id="store-upload-icon"></span>
<span id="store-upload-label"
><span id="store-upload-label-prefix">GCode hierher ziehen oder </span
><u id="store-upload-label-browse">durchsuchen</u></span
>
<span id="store-upload-status" style="display: none"></span>
</div>
<div
id="store-empty"
style="
display: none;
color: var(--txt2);
text-align: center;
padding: 40px 0;
font-size: 14px;
"
></div>
<div
id="store-grid"
style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
"
></div>
</div>
<!-- ═══ GCODE STORE ═══ -->
<div class="panel" id="panel-store">
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<select id="store-filter" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="all" id="sf-all">Alle</option>
<option value="completed" id="sf-ok">✓ Erfolgreich</option>
<option value="failed" id="sf-err">✗ Fehler</option>
<option value="never" id="sf-new">Neu</option>
</select>
<select id="store-sort" onchange="renderStore()"
style="padding:6px 8px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
<option value="date_desc" id="ss-date">↓ Datum</option>
<option value="name_asc" id="ss-name">AZ Name</option>
<option value="duration_asc" id="ss-dur">⏱ Druckzeit</option>
</select>
</div>
<div id="store-upload-zone" onclick="document.getElementById('store-upload-input').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="event.preventDefault();this.classList.remove('drag-over');uploadGcode(event.dataTransfer.files[0])">
<input type="file" id="store-upload-input" accept=".gcode,.bgcode"
style="display:none" onchange="uploadGcode(this.files[0]);this.value=''">
<span id="store-upload-icon"></span>
<span id="store-upload-label"><span id="store-upload-label-prefix">GCode hierher ziehen oder </span><u id="store-upload-label-browse">durchsuchen</u></span>
<span id="store-upload-status" style="display:none"></span>
</div>
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-select-bar" style="display:none;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px">
<input type="checkbox" id="store-select-all" onchange="storeToggleSelectAll(this.checked)">
<span id="store-lbl-select-all">Alle auswählen</span>
</label>
<span id="store-selected-count" style="color:var(--txt2);font-size:13px"></span>
<button id="store-delete-selected-btn" disabled onclick="storeDeleteSelected()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer">
🗑 <span id="store-lbl-delete-selected">Auswahl löschen</span></button>
<button onclick="storeExitSelectMode()"
style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">
<span id="store-lbl-exit-select">Abbrechen</span></button>
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div>
</div>
<div class="panel" id="panel-console">
<div class="card">

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%}

View File

@ -244,108 +244,74 @@ main {
}
/* ── CARD ── */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
transition:
box-shadow 0.15s,
transform 0.15s;
}
.card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
transform: translateY(-1px);
}
.card-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--txt2);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title span {
font-size: 14px;
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
padding:18px;transition:box-shadow .15s,transform .15s;container-type:inline-size}
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
margin-bottom:14px;display:flex;align-items:center;gap:8px}
.card-title span{font-size:14px}
/* ── DASHBOARD FREE GRID (GridStack) ── */
/* .grid-stack-item-content is already position:absolute + fills the cell
(GridStack's own CSS). The card inside just needs to fill that box — it
must NOT be position:absolute itself, or it can sit above/intercept
GridStack's resize-handle hit area and drag listeners. */
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
the cell smaller than the content needs. */
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box}
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
as the user starts dragging, throwing off GridStack's position math. Kill
the hover transform for dashboard cards specifically. */
.grid-stack-item-content>.card:hover{transform:none}
/* Edit mode: dashed outline + grab cursor on each item */
#dash-grid.editing .grid-stack-item-content>.card{cursor:grab;
outline:1px dashed var(--accent);outline-offset:-1px;user-select:none}
/* GridStack placeholder styled to theme */
.grid-stack>.grid-stack-placeholder>.placeholder-content{
background:rgba(120,150,255,.12);border:1px dashed var(--accent);border-radius:12px}
/* Resize handles only visible in edit mode */
#dash-grid:not(.editing) .ui-resizable-handle{display:none!important}
/* Per-card controls (hide button) */
.dash-card-controls{display:none;position:absolute;top:8px;right:8px;gap:4px;z-index:6}
#dash-grid.editing .dash-card-controls{display:flex}
.dash-card-ctrl-btn{width:24px;height:24px;border-radius:6px;border:1px solid var(--border);
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:12px;
display:flex;align-items:center;justify-content:center;line-height:1}
.dash-card-ctrl-btn:hover{color:var(--accent);border-color:var(--accent)}
#dash-hidden-bar{display:none;flex-wrap:wrap;gap:8px;margin-top:12px;padding:10px;
border:1px dashed var(--border);border-radius:10px}
#dash-hidden-bar.show{display:flex}
.dash-hidden-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px;
background:var(--raised);border:1px solid var(--border);border-radius:20px;color:var(--txt2)}
.dash-hidden-chip button{background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0}
@media(max-width:768px){
#dash-toolbar{display:none}
}
/* ── HERO ── */
.hero {
grid-column: 1/-1;
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
}
}
.cam-wrap {
background: #0a0a0e;
border-radius: 10px;
overflow: hidden;
min-height: 180px;
max-height: 320px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.cam-wrap img,
.cam-wrap video {
width: 100%;
max-height: 320px;
height: auto;
display: block;
object-fit: contain;
}
.cam-placeholder {
color: var(--txt2);
font-size: 13px;
text-align: center;
padding: 20px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cam-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.15);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: none;
}
.cam-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
padding: 14px;
}
.cam-toggle {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
backdrop-filter: blur(4px);
}
.cam-toggle:hover {
background: rgba(0, 0, 0, 0.7);
}
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
@media(max-width:900px){.hero{grid-template-columns:1fr}}
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain;
-webkit-user-drag:none;user-select:none}
/* Inside the dashboard grid the camera card can be resized taller than the
default 320px cap — flex column: title row keeps its height, cam view
flexes to fill whatever cell height the user chose. */
.grid-stack-item-content>#card-camera{display:flex;flex-direction:column}
.grid-stack-item-content .cam-wrap{flex:1;min-height:0;max-height:none}
.grid-stack-item-content .cam-wrap img,.grid-stack-item-content .cam-wrap video{max-height:100%;height:100%}
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
@keyframes spin{to{transform:rotate(360deg)}}
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;display:none}
.cam-overlay{position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.75));padding:14px}
.cam-toggle{position:absolute;top:10px;right:10px;background:rgba(0,0,0,.5);
border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px;
padding:6px 10px;cursor:pointer;font-size:12px;backdrop-filter:blur(4px)}
.cam-toggle:hover{background:rgba(0,0,0,.7)}
/* ── PROGRESS ── */
.hero-info {
@ -527,82 +493,27 @@ main {
color: var(--txt);
}
/* ── TEMPS ── */
.temp-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.temp-card-inner {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.temp-block {
background: var(--raised);
border-radius: 10px;
padding: 14px;
position: relative;
}
.temp-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--txt2);
margin-bottom: 6px;
}
.temp-row {
display: flex;
align-items: baseline;
gap: 6px;
}
.temp-val {
font-size: 30px;
font-weight: 700;
font-family: var(--mono);
}
.temp-unit {
font-size: 14px;
color: var(--txt2);
}
.temp-target {
font-size: 11px;
color: var(--txt2);
margin-top: 2px;
}
.temp-arc {
position: absolute;
top: 12px;
right: 12px;
}
.temp-edit {
display: flex;
gap: 6px;
margin-top: 10px;
}
.temp-input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--txt);
border-radius: 6px;
padding: 5px 8px;
font-size: 13px;
font-family: var(--mono);
width: 70px;
}
.temp-input:focus {
outline: none;
border-color: var(--accent);
}
.chart-wrap {
margin-top: 12px;
}
canvas.tchart {
width: 100%;
height: 60px;
display: block;
border-radius: 6px;
background: var(--raised);
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@container (max-width:340px){
.temp-card-inner{grid-template-columns:1fr}
.temp-val{font-size:24px}
.temp-edit{flex-wrap:wrap}
.temp-input{width:100%}
}
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
.temp-row{display:flex;align-items:baseline;gap:6px}
.temp-val{font-size:30px;font-weight:700;font-family:var(--mono)}
.temp-unit{font-size:14px;color:var(--txt2)}
.temp-target{font-size:11px;color:var(--txt2);margin-top:2px}
.temp-arc{position:absolute;top:12px;right:12px}
.temp-edit{display:flex;gap:6px;margin-top:10px}
.temp-input{background:var(--bg);border:1px solid var(--border);color:var(--txt);
border-radius:6px;padding:5px 8px;font-size:13px;font-family:var(--mono);width:70px}
.temp-input:focus{outline:none;border-color:var(--accent)}
.chart-wrap{margin-top:12px}
canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:var(--raised)}
/* ── MOTION ── */
.joypad {

View File

@ -120,6 +120,7 @@
"lbl_feed": "Einziehen",
"lbl_layers": "Layer",
"lbl_light": "💡 Licht",
"lbl_pause_reason": "Druck pausiert:",
"lbl_remaining": "Restzeit:",
"lbl_slicer_time": "Slicer-Schätzung:",
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
@ -270,6 +271,18 @@
"settings_poll": "Poll-Intervall (Sekunden)",
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
"dash_edit": "Dashboard anpassen",
"dash_done": "Fertig",
"dash_reset": "Zurücksetzen",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Desktop breit",
"dash_hidden_cards": "Ausgeblendete Karten",
"dash_show": "Einblenden",
"dash_hide": "Ausblenden",
"dash_toggle_width": "Breite umschalten",
"dash_save_preset": "Als Preset speichern",
"dash_preset_name_prompt": "Preset-Name:",
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
"settings_print": "Druckeinstellungen",
"settings_printer_ip": "Drucker-IP",
@ -322,6 +335,8 @@
"ss_dur": "⏱ Druckzeit",
"ss_name": "AZ Name",
"store_delete_confirm": "Datei löschen?",
"store_delete_selected": "Auswahl löschen",
"store_delete_selected_confirm": "{n} ausgewählte Dateien löschen?",
"store_download": "⬇ Download",
"store_empty": "Noch keine Dateien hochgeladen.",
"store_estimate": "Schätzung",
@ -330,6 +345,9 @@
"store_print": "▶ Drucken",
"store_print_confirm": "Datei drucken?",
"store_refresh": "↻ Aktualisieren",
"store_select_all": "Alle auswählen",
"store_selected_count": "{n} ausgewählt",
"store_exit_select": "Abbrechen",
"store_search_placeholder": "🔍 Suche…",
"store_upload_busy": "⏳ Hochladen…",
"store_upload_error": "✗ {error}",

View File

@ -119,6 +119,7 @@
"lbl_feed": "Load",
"lbl_layers": "Layer",
"lbl_light": "💡 Light",
"lbl_pause_reason": "Print paused:",
"lbl_remaining": "Remaining:",
"lbl_slicer_time": "Slicer estimate:",
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
@ -267,6 +268,18 @@
"settings_poll": "Poll Interval (seconds)",
"settings_poll_interval_hint": "How often the bridge queries printer status",
"settings_verbose_http_log": "Log every HTTP request (verbose)",
"dash_edit": "Customize dashboard",
"dash_done": "Done",
"dash_reset": "Reset",
"dash_preset_standard": "Standard",
"dash_preset_wide89": "Wide desktop",
"dash_hidden_cards": "Hidden cards",
"dash_show": "Show",
"dash_hide": "Hide",
"dash_toggle_width": "Toggle width",
"dash_save_preset": "Save as preset",
"dash_preset_name_prompt": "Preset name:",
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
"settings_poll_interval_label": "Poll Interval (seconds)",
"settings_print": "Print Settings",
"settings_printer_ip": "Printer IP",
@ -317,6 +330,8 @@
"ss_dur": "⏱ Print time",
"ss_name": "AZ Name",
"store_delete_confirm": "Delete file?",
"store_delete_selected": "Delete Selected",
"store_delete_selected_confirm": "Delete {n} selected files?",
"store_download": "⬇ Download",
"store_empty": "No files uploaded yet.",
"store_estimate": "Estimate",
@ -325,6 +340,9 @@
"store_print": "▶ Print",
"store_print_confirm": "Print file?",
"store_refresh": "↻ Refresh",
"store_select_all": "Select All",
"store_selected_count": "{n} selected",
"store_exit_select": "Cancel",
"store_search_placeholder": "🔍 Search…",
"store_upload_busy": "⏳ Uploading…",
"store_upload_error": "✗ {error}",

View File

@ -120,6 +120,7 @@
"lbl_feed": "Cargar",
"lbl_layers": "Capa",
"lbl_light": "💡 Luz",
"lbl_pause_reason": "Impresión pausada:",
"lbl_remaining": "Restante:",
"lbl_slicer_time": "Estimación del slicer:",
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
@ -270,6 +271,18 @@
"settings_poll": "Intervalo de sondeo (segundos)",
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
"dash_edit": "Personalizar panel",
"dash_done": "Listo",
"dash_reset": "Restablecer",
"dash_preset_standard": "Estándar",
"dash_preset_wide89": "Escritorio ancho",
"dash_hidden_cards": "Tarjetas ocultas",
"dash_show": "Mostrar",
"dash_hide": "Ocultar",
"dash_toggle_width": "Cambiar ancho",
"dash_save_preset": "Guardar como preset",
"dash_preset_name_prompt": "Nombre del preset:",
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
"settings_print": "Ajustes de impresión",
"settings_printer_ip": "IP de impresora",
@ -322,6 +335,8 @@
"ss_dur": "⏱ Tiempo de impresión",
"ss_name": "AZ Nombre",
"store_delete_confirm": "¿Eliminar archivo?",
"store_delete_selected": "Eliminar seleccionados",
"store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados?",
"store_download": "⬇ Descargar",
"store_empty": "Aún no hay archivos subidos.",
"store_estimate": "Estimación",
@ -330,6 +345,9 @@
"store_print": "▶ Imprimir",
"store_print_confirm": "¿Imprimir archivo?",
"store_refresh": "↻ Actualizar",
"store_select_all": "Seleccionar todo",
"store_selected_count": "{n} seleccionados",
"store_exit_select": "Cancelar",
"store_search_placeholder": "🔍 Buscar…",
"store_upload_busy": "⏳ Subiendo…",
"store_upload_error": "✗ {error}",

View File

@ -121,6 +121,7 @@
"lbl_feed": "Charger",
"lbl_layers": "Couche",
"lbl_light": "💡 Lumière",
"lbl_pause_reason": "Impression en pause :",
"lbl_remaining": "Restant :",
"lbl_slicer_time": "Estimation slicer :",
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
@ -321,6 +322,8 @@
"ss_dur": "⏱ Durée d'impression",
"ss_name": "AZ Nom",
"store_delete_confirm": "Supprimer le fichier ?",
"store_delete_selected": "Supprimer la sélection",
"store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés ?",
"store_download": "⬇ Télécharger",
"store_empty": "Aucun fichier uploadé.",
"store_estimate": "Estimation",
@ -329,6 +332,9 @@
"store_print": "▶ Imprimer",
"store_print_confirm": "Imprimer le fichier ?",
"store_refresh": "↻ Actualiser",
"store_select_all": "Tout sélectionner",
"store_selected_count": "{n} sélectionné(s)",
"store_exit_select": "Annuler",
"store_search_placeholder": "🔍 Rechercher…",
"store_upload_busy": "⏳ Envoi en cours…",
"store_upload_error": "✗ {error}",

View File

@ -121,6 +121,7 @@
"lbl_feed": "Carica",
"lbl_layers": "Layer",
"lbl_light": "💡 Luce",
"lbl_pause_reason": "Stampa in pausa:",
"lbl_remaining": "Rimanente:",
"lbl_slicer_time": "Stima slicer:",
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
@ -320,6 +321,8 @@
"ss_dur": "⏱ Tempo di stampa",
"ss_name": "Nome AZ",
"store_delete_confirm": "Eliminare il file?",
"store_delete_selected": "Elimina selezionati",
"store_delete_selected_confirm": "Eliminare {n} file selezionati?",
"store_download": "⬇ Scarica",
"store_empty": "Nessun file caricato.",
"store_estimate": "Stima",
@ -328,6 +331,9 @@
"store_print": "▶ Stampa",
"store_print_confirm": "Stampare il file?",
"store_refresh": "↻ Aggiorna",
"store_select_all": "Seleziona tutto",
"store_selected_count": "{n} selezionati",
"store_exit_select": "Annulla",
"store_search_placeholder": "🔍 Cerca…",
"store_upload_busy": "⏳ Caricamento in corso…",
"store_upload_error": "✗ {error}",

View File

@ -120,6 +120,7 @@
"lbl_feed": "进料",
"lbl_layers": "层",
"lbl_light": "💡 灯光",
"lbl_pause_reason": "打印已暂停:",
"lbl_remaining": "剩余时间:",
"lbl_slicer_time": "切片预估:",
"lbl_spoolman_sync_rate": "同步频率0=打印结束)",
@ -270,6 +271,18 @@
"settings_poll": "轮询间隔(秒)",
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
"dash_edit": "自定义仪表盘",
"dash_done": "完成",
"dash_reset": "重置",
"dash_preset_standard": "标准",
"dash_preset_wide89": "宽屏桌面",
"dash_hidden_cards": "隐藏的卡片",
"dash_show": "显示",
"dash_hide": "隐藏",
"dash_toggle_width": "切换宽度",
"dash_save_preset": "另存为预设",
"dash_preset_name_prompt": "预设名称:",
"dash_preset_delete_confirm": "删除预设 \"{name}\"",
"settings_poll_interval_label": "轮询间隔(秒)",
"settings_print": "打印设置",
"settings_printer_ip": "打印机 IP",
@ -322,6 +335,8 @@
"ss_dur": "⏱ 打印时间",
"ss_name": "AZ 名称",
"store_delete_confirm": "删除文件?",
"store_delete_selected": "删除所选",
"store_delete_selected_confirm": "删除已选择的 {n} 个文件?",
"store_download": "⬇ 下载",
"store_empty": "尚未上传文件。",
"store_estimate": "估算",
@ -330,6 +345,9 @@
"store_print": "▶ 打印",
"store_print_confirm": "打印文件?",
"store_refresh": "↻ 刷新",
"store_select_all": "全选",
"store_selected_count": "已选择 {n} 个",
"store_exit_select": "取消",
"store_search_placeholder": "🔍 搜索…",
"store_upload_busy": "⏳ 上传中…",
"store_upload_error": "✗ {error}",