diff --git a/doc/fileBrowser.md b/doc/fileBrowser.md new file mode 100644 index 0000000..5a77eff --- /dev/null +++ b/doc/fileBrowser.md @@ -0,0 +1,153 @@ +# ROADMAP — appRobotFileservice Web-UI (Datei-Browser) + +## Ziel + +Einfache Web-Oberfläche direkt auf Port 2100, die GCode-Programme verwaltet. +Kein Framework, kein Build-Schritt — reines HTML/CSS/JS, serviert als static files. + +--- + +## Phase 1 — Datei-Browser (aktuell implementiert) + +**Dateien:** `public/index.html`, `public/index.css` + +### Funktionen + +| Feature | Status | Endpunkt | +|---|---|---| +| Programm-Liste laden | ✅ | `GET /api/programs` | +| Aktives Programm anzeigen (Zeilenliste in Grad) | ✅ | `GET /api/active` | +| Programm als aktiv setzen (FLoad) | ✅ | `PUT /api/active` | +| Programm löschen | ✅ | `DELETE /api/programs/:id` | +| Stepping: First / Prev / Next / Last | ✅ | `POST /api/active/first|prev|next|last` | +| Programm leeren (FClear) | ✅ | `POST /api/active/clear` | +| Cursor in Tabelle hervorheben + auto-scrollen | ✅ | — | +| Auto-Refresh (5 s Polling) | ✅ | — | +| Collapse-Karten (wie appRobotHoming) | ✅ | — | + +### Was Phase 1 noch fehlt + +- **Download `.gcode`** — Endpunkt `GET /api/programs/:id/download` muss im Server + hinzugefügt werden (liefert die rohe Datei mit `Content-Disposition: attachment`). +- **Upload `.gcode`** — Datei vom Desktop in den Service laden (Phase 2). +- **Umbenennen** — `PUT /api/programs/:id` mit neuem `name`. + +--- + +## Phase 2 — Datei-Verwaltung + +### Download-Endpunkt (kleines Backend-Feature für Phase 1.5) + +``` +GET /api/programs/:id/download +→ Content-Type: text/plain +→ Content-Disposition: attachment; filename=".gcode" +→ Body: rohe .gcode-Datei +``` + +Implementierung: 1 Route in `src/routes/programs.js` + `store.gcodePath(id)`. + +### Upload + +``` +POST /api/programs/upload +Body: multipart/form-data { file: } +→ speichert unter slugify(filename) in GCodeFiles/ +``` + +Im Frontend: `` + `FormData` fetch. + +### Umbenennen / Metadaten bearbeiten + +`PUT /api/programs/:id` ist bereits vorhanden — nur das Frontend-Formular fehlt. +Inline-Eingabefeld in der Programm-Zeile, Enter → PUT. + +--- + +## Phase 3 — Verzeichnis-Navigation + +Aktuell: alles flach im `GCodeFiles/`-Ordner. +Später: Unterordner für Projekte (z. B. `GCodeFiles/greifen/`, `GCodeFiles/ablegen/`). + +### Backend + +Neue Konfiguration: `storageDir` bleibt Wurzel; zusätzlicher optionaler `subDir`-Parameter. + +``` +GET /api/programs?dir=greifen → listet GCodeFiles/greifen/ +PUT /api/active { id, dir } → lädt GCodeFiles//.gcode +POST /api/dirs { name } → legt Unterordner an +DELETE /api/dirs/:name → löscht (leer) Unterordner +``` + +Wichtig: Pfad-Traversal verhindern — `assertValidId` auf jede Pfad-Komponente anwenden. + +### Frontend + +Breadcrumb-Leiste oberhalb der Programmliste: +``` +GCodeFiles/ > greifen/ > [zurück] +``` + +Doppelklick auf Ordner-Zeile → navigiert hinein. +Klick auf Breadcrumb-Segment → navigiert zurück. + +--- + +## Phase 4 — Live-Updates (WebSocket oder SSE) + +Aktuell: Polling alle 5 s. +Besser: Server-Sent Events (SSE) oder WebSocket, damit die UI sofort +reagiert wenn der Driver per FPoint einen neuen Punkt schreibt. + +### Option A — Server-Sent Events (einfach, read-only) + +``` +GET /api/events +→ text/event-stream +→ event: active-changed\ndata: \n\n +``` + +Der Fileservice emittiert nach jedem `_persist()` ein SSE-Event. +Frontend: `new EventSource('/api/events')` statt `setInterval`. + +### Option B — WebSocket (bidirektional, für spätere Steuerung) + +Erlaubt auch WS-basierte FCode-Befehle direkt von der Web-UI. +Aufwändiger, aber konsistent mit dem Driver-Pattern. + +**Empfehlung für Phase 4:** SSE — eine Zeile Mehraufwand gegenüber Polling, +kein zusätzliches Protokoll. + +--- + +## Phase 5 — Inline-Editor + +Einzelne Zeilen direkt im Browser bearbeiten. + +``` +PUT /api/active/lines/:index { line: "G90 G1 x0 y300 z0 a90.00 …" } +DELETE /api/active/lines/:index +POST /api/active/lines { line, atIndex } +``` + +Alle drei Endpunkte sind bereits im Backend implementiert. +Frontend: Klick auf Tabellenzeile → `` erscheint inline; Escape abbricht, Enter speichert. + +--- + +## Datei-Übersicht (nach Phase 1) + +``` +appRobotFileservice/ + public/ + index.html HTML-Struktur + JavaScript (inline) + index.css Design-System (identisch mit appRobotHoming/styles.css) + doc/ + fileBrowser_ROADMAP.md diese Datei + src/ + server.js +express.static('public/') + routes/ + programs.js TODO Phase 2: /download-Endpunkt + active.js komplett +``` diff --git a/public/index.css b/public/index.css new file mode 100644 index 0000000..cd12e1f --- /dev/null +++ b/public/index.css @@ -0,0 +1,246 @@ +:root { + --bg: #0b1220; + --panel: #132c44; + --border: #0e1822; + --text: #e0e6ed; + --muted: #9aa6b2; + --accent: #a4bbd4; + --active: #1e3a5f; + --danger: #7f1d1d; + --danger-border: #991b1b; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + min-height: 100%; + font-family: Arial, sans-serif; + font-size: 14px; + background: linear-gradient(to bottom, #dddddd -20%, var(--bg) 130%); + color: var(--text); +} + +body { padding: 16px; } + +/* ===== HEADER ===== */ +.app-header { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 16px; +} +.app-header h1 { + font-size: 16px; + font-weight: 600; + color: var(--accent); +} +.app-header .subtitle { + font-size: 12px; + color: var(--muted); +} + +/* ===== GRID ===== */ +.sections { + display: grid; + grid-template-columns: 320px 1fr; + gap: 16px; + align-items: start; +} +@media (max-width: 760px) { + .sections { grid-template-columns: 1fr; } +} + +/* ===== CARD ===== */ +.section { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; +} +.section h2 { + font-size: 13px; + font-weight: 600; + color: var(--accent); + margin-bottom: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} +.section h2 .badge { + font-size: 11px; + font-weight: normal; + color: var(--muted); + background: #1e293b; + padding: 1px 7px; + border-radius: 10px; +} + +/* ===== PROGRAM LIST ===== */ +#program-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.prog-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: background 0.1s; +} +.prog-item:hover { + background: #1e293b; + border-color: #334155; +} +.prog-item.is-active { + background: var(--active); + border-color: var(--accent); +} +.prog-item .prog-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.prog-item .prog-id { + font-size: 11px; + color: var(--muted); +} +.prog-item .prog-count { + font-size: 11px; + color: var(--muted); + white-space: nowrap; +} +.prog-item .btn-del { + background: none; + border: 1px solid transparent; + color: var(--muted); + border-radius: 4px; + padding: 1px 5px; + cursor: pointer; + font-size: 13px; + opacity: 0; + transition: opacity 0.1s; +} +.prog-item:hover .btn-del, +.prog-item.is-active .btn-del { opacity: 1; } +.prog-item .btn-del:hover { + color: #fca5a5; + border-color: var(--danger-border); + background: var(--danger); +} + +.empty-hint { + font-size: 12px; + color: var(--muted); + padding: 8px 2px; +} + +/* ===== CONTROLS ===== */ +.controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} +button { + background: #1e293b; + color: var(--text); + border: 1px solid #334155; + padding: 6px 13px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: border-color 0.15s; +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { opacity: 0.4; cursor: not-allowed; } +button.primary { + background: var(--active); + border-color: var(--accent); +} + +/* ===== ACTIVE INFO BAR ===== */ +.active-bar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 12px; + font-size: 12px; +} +.active-bar .kv { display: flex; flex-direction: column; } +.active-bar .kv .k { color: var(--muted); font-size: 11px; } +.active-bar .kv .v { color: var(--accent); font-weight: 600; } +.active-bar .sep { width: 1px; height: 30px; background: #1f2937; } + +/* ===== GCODE TABLE ===== */ +.table-wrap { + overflow-x: auto; + border: 1px solid #1f2937; + border-radius: 6px; + max-height: 600px; + overflow-y: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} +table th { + background: #1e293b; + color: var(--accent); + font-size: 11px; + font-weight: 600; + padding: 5px 8px; + text-align: left; + position: sticky; + top: 0; + border-bottom: 1px solid #334155; +} +table td { + padding: 4px 8px; + border-bottom: 1px solid #0e1822; + white-space: nowrap; +} +table tbody tr:nth-child(even) { background: #0f172a; } +table tbody tr:hover { background: #1e293b; } +table tbody tr.cursor-row { + background: var(--active); + border-left: 2px solid var(--accent); +} +table td.idx { color: var(--muted); width: 40px; } +table td.line { color: var(--text); } +table td.ts { color: var(--muted); font-size: 11px; } + +/* ===== STATUS / SPINNER ===== */ +.status-line { + font-size: 12px; + color: var(--muted); + margin-bottom: 8px; + min-height: 18px; +} +.status-line.err { color: #fca5a5; } + +/* ===== DOWNLOAD LINK ===== */ +.dl-link { + display: inline-block; + font-size: 12px; + color: var(--accent); + text-decoration: none; + padding: 4px 10px; + border: 1px solid #334155; + border-radius: 6px; + background: #1e293b; +} +.dl-link:hover { border-color: var(--accent); } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..027cb79 --- /dev/null +++ b/public/index.html @@ -0,0 +1,324 @@ + + + + + + appRobotFileservice + + + + +
+

appRobotFileservice

+ Datei-Browser · GCodeFiles/ +
+ +
+ + +
+

+ Programme + +

+ +
+ +
+ +
+
Lade…
+
+ + +
+

+ Aktives Programm + +

+ + + + +
+ + + + + + +
+ +
+ +
+ + + + + + + + + + + +
#G-Code (gespeichert in Grad)Zeitstempel
Kein aktives Programm
+
+
+ +
+ + + + diff --git a/src/routes/programs.js b/src/routes/programs.js index b32ad9a..6b04c9f 100644 --- a/src/routes/programs.js +++ b/src/routes/programs.js @@ -30,6 +30,22 @@ router.get( }) ); +// GET /api/programs/:id/download — rohe .gcode-Datei als Download +router.get( + '/:id/download', + asyncH(async (req, res) => { + const id = req.params.id; + const prog = await store.read(id); // wirft 404 wenn nicht vorhanden + const filePath = store.gcodePath(id); + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${id}.gcode"`); + // sendFile braucht absoluten Pfad — wir haben ihn direkt aus dem Store + const fsp = require('fs/promises'); + const body = await fsp.readFile(filePath, 'utf8'); + res.send(body); + }) +); + // POST /api/programs (FSave) — aus aktivem Puffer ODER expliziter Inhalt. router.post( '/', diff --git a/src/server.js b/src/server.js index d398d92..72194a5 100644 --- a/src/server.js +++ b/src/server.js @@ -1,4 +1,5 @@ // Express-App der appRobotFileservice. createApp() ist test-freundlich (kein listen). +const path = require('path'); const express = require('express'); const programsRouter = require('./routes/programs'); const activeRouter = require('./routes/active'); @@ -25,6 +26,9 @@ function createApp() { app.use('/api/programs', programsRouter); app.use('/api/active', activeRouter); + // Web-UI: statische Dateien aus public/ (index.html, index.css) + app.use(express.static(path.join(__dirname, '..', 'public'))); + // Unbekannter Pfad → 404-Envelope app.use((req, res) => res.status(404).json(envelope('NOT_FOUND', 'unknown endpoint', req.path))); app.use(errorMiddleware);