Files
appRobotFileservice/public/index.html
2026-06-15 09:22:41 +02:00

567 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>appRobotFileservice</title>
<link rel="stylesheet" href="/index.css" />
</head>
<body>
<div class="app-header">
<h1>appRobotFileservice</h1>
<span class="subtitle">Datei-Browser · GCodeFiles/</span>
</div>
<div class="sections">
<!-- ===== LINKE SPALTE: Programm-Liste ===== -->
<div class="section">
<h2>
Programme
<span class="badge" id="prog-count"></span>
</h2>
<div id="breadcrumb" class="breadcrumb"></div>
<div class="controls">
<button id="btn-refresh" title="Aktualisieren"></button>
<button id="btn-new-file" title="Neue Datei erstellen">📄+</button>
<button id="btn-new-folder" title="Neuen Ordner erstellen">📁+</button>
<button id="btn-delete-selected" title="Ausgewähltes löschen" disabled>🗑</button>
</div>
<div class="status-line" id="list-status"></div>
<div id="program-list"><span class="empty-hint">Lade…</span></div>
</div>
<!-- ===== RECHTE SPALTE: Aktives Programm + Inhalt ===== -->
<div class="section">
<h2>
Aktives Programm
<span class="badge" id="active-name"></span>
</h2>
<!-- Info-Zeile -->
<div class="active-bar" id="active-bar" style="display:none">
<div class="kv"><span class="k">ID</span><span class="v" id="ai-id"></span></div>
<div class="sep"></div>
<div class="kv"><span class="k">Zeilen</span><span class="v" id="ai-count"></span></div>
<div class="sep"></div>
<div class="kv"><span class="k">Cursor</span><span class="v" id="ai-cursor"></span></div>
<div class="sep"></div>
<div class="kv"><span class="k">Version</span><span class="v" id="ai-version"></span></div>
</div>
<div class="controls-row">
<div class="ctrl-group">
<button id="btn-first" disabled title="Erste Zeile">|◀ First</button>
<button id="btn-prev" disabled title="Schritt zurück">◀ Prev</button>
<button id="btn-next" disabled title="Schritt vor">Next ▶</button>
<button id="btn-last" disabled title="Letzte Zeile">Last ▶|</button>
</div>
<div class="ctrl-group">
<button id="btn-clear" disabled title="Programm leeren">✕ Clear</button>
<a id="btn-download" class="dl-link" style="display:none" download>⬇ .gcode</a>
</div>
<div class="ctrl-group">
<div class="toggle-group">
<button id="btn-mode-nav" class="toggle active" title="Nur Cursor bewegen">↕ Navigieren</button>
<button id="btn-mode-send" class="toggle" title="Zeile an Roboter senden" disabled>▶ Senden</button>
</div>
</div>
</div>
<div class="status-line" id="active-status"></div>
<div class="table-wrap">
<table id="lines-table">
<thead>
<tr>
<th class="idx">#</th>
<th class="line">G-Code (gespeichert in Grad)</th>
<th class="ts">Zeitstempel</th>
<th class="del-col"></th>
</tr>
</thead>
<tbody id="lines-body">
<tr><td colspan="4" class="empty-hint" style="padding:10px">Kein aktives Programm</td></tr>
</tbody>
</table>
</div>
<div id="edit-bar" class="edit-bar" style="display:none">
<button id="btn-cancel-delete">✗ Abbrechen</button>
<button id="btn-save-delete" class="danger-confirm">✓ Speichern</button>
<span id="edit-bar-info" class="edit-bar-info"></span>
</div>
</div>
</div><!-- .sections -->
<script>
// ─── Konfiguration ───────────────────────────────────────────────────────────
const API = ''; // leer → gleicher Origin; überschreib mit 'http://…:2100'
const REFRESH_MS = 5000; // Auto-Refresh-Intervall
// ─── Zustand ─────────────────────────────────────────────────────────────────
let currentActiveId = null;
let selectedId = null; // in der Liste ausgewähltes Item (unabhängig vom aktiven Programm)
let selectedType = null; // 'file' | 'folder'
let pendingDeletes = new Set(); // Indices markierter Zeilen (noch nicht gespeichert)
let cachedState = null; // letzter bekannter Serverzustand (für Abbrechen)
let currentDir = ''; // aktuelles Verzeichnis (relativer Pfad, z. B. 'training/run1')
let currentPath = []; // Segmente des currentDir als Array (für Breadcrumb)
let sendMode = false; // false = Navigieren, true = Senden an Driver
// ─── DOM-Referenzen ───────────────────────────────────────────────────────────
const elProgCount = document.getElementById('prog-count');
const elProgList = document.getElementById('program-list');
const elListStatus = document.getElementById('list-status');
const elActiveName = document.getElementById('active-name');
const elActiveBar = document.getElementById('active-bar');
const elAiId = document.getElementById('ai-id');
const elAiCount = document.getElementById('ai-count');
const elAiCursor = document.getElementById('ai-cursor');
const elAiVersion = document.getElementById('ai-version');
const elLinesBody = document.getElementById('lines-body');
const elActStatus = document.getElementById('active-status');
const elBtnFirst = document.getElementById('btn-first');
const elBtnPrev = document.getElementById('btn-prev');
const elBtnNext = document.getElementById('btn-next');
const elBtnLast = document.getElementById('btn-last');
const elBtnClear = document.getElementById('btn-clear');
const elBtnDownload = document.getElementById('btn-download');
const elBtnDeleteSelected = document.getElementById('btn-delete-selected');
// ─── API-Helpers ─────────────────────────────────────────────────────────────
async function apiFetch(method, path, body) {
const opts = { method, headers: {} };
if (body !== undefined) {
opts.headers['content-type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(API + path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(data.message || res.statusText), { code: data.code, status: res.status });
return data;
}
function setStatus(el, msg, isErr = false) {
el.textContent = msg;
el.className = 'status-line' + (isErr ? ' err' : '');
}
// ─── Zeitstempel lesbar machen ───────────────────────────────────────────────
function fmtTs(raw) {
if (!raw) return '';
const n = Number(raw);
if (!Number.isFinite(n) || n < 1e12) return raw;
return new Date(n).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'medium' });
}
// ─── Zeile in Code + Timestamp zerlegen ──────────────────────────────────────
function parseLine(line) {
const idx = line.indexOf(';');
if (idx === -1) return { code: line.trim(), ts: '' };
return { code: line.slice(0, idx).trim(), ts: fmtTs(line.slice(idx + 1).trim()) };
}
// ─── Selection-State aktualisieren ──────────────────────────────────────────
function setSelected(id, type) {
selectedId = id;
selectedType = type;
// Alle Items neu markieren
elProgList.querySelectorAll('.prog-item').forEach(el => {
el.classList.toggle('is-selected', el.dataset.id === id);
});
elBtnDeleteSelected.disabled = !id;
}
// ─── Programmliste rendern ───────────────────────────────────────────────────
function renderProgList(programs, folders, activeId) {
const total = folders.length + programs.length;
elProgCount.textContent = total;
if (!total) {
elProgList.innerHTML = '<span class="empty-hint">Keine Programme gefunden</span>';
elBtnDeleteSelected.disabled = true;
return;
}
// Ordner zuerst, dann Dateien
const folderHtml = folders.map(f => `
<div class="prog-item is-folder" data-id="${esc(f.id)}" data-type="folder">
<span class="prog-name" title="${esc(f.name)}">📁 ${esc(f.name)}</span>
</div>`).join('');
const fileHtml = programs.map(p => {
const isActive = p.id === activeId;
const isSel = p.id === selectedId;
return `<div class="prog-item${isActive ? ' is-active' : ''}${isSel ? ' is-selected' : ''}" data-id="${esc(p.id)}" data-type="file">
<span class="prog-name" title="${esc(p.name)}">📄 ${esc(p.name)}</span>
<span class="prog-id">${esc(p.id)}</span>
<span class="prog-count">${p.lineCount} Z.</span>
</div>`;
}).join('');
elProgList.innerHTML = folderHtml + fileHtml;
// Klick → auswählen; Datei laden; Ordner navigieren
elProgList.querySelectorAll('.prog-item').forEach(el => {
el.addEventListener('click', () => {
if (el.dataset.type === 'folder') {
navigateInto(el.dataset.id);
} else {
setSelected(el.dataset.id, el.dataset.type);
loadProgram(el.dataset.id);
}
});
});
}
// ─── Edit-Bar (Abbrechen / Speichern) ───────────────────────────────────────
const elEditBar = document.getElementById('edit-bar');
const elEditBarInfo = document.getElementById('edit-bar-info');
function updateEditBar() {
const n = pendingDeletes.size;
if (n === 0) {
elEditBar.style.display = 'none';
} else {
elEditBar.style.display = 'flex';
elEditBarInfo.textContent = `${n} Zeile${n === 1 ? '' : 'n'} zum Löschen markiert`;
document.getElementById('btn-save-delete').disabled = false; // nach erfolgreichem Save zurücksetzen
}
}
function markDelete(index) {
if (pendingDeletes.has(index)) {
pendingDeletes.delete(index);
} else {
pendingDeletes.add(index);
}
// Nur die betroffene Zeile neu stylen statt ganzer Tabelle
const row = elLinesBody.querySelector(`tr[data-index="${index}"]`);
if (row) row.classList.toggle('pending-delete', pendingDeletes.has(index));
updateEditBar();
}
// ─── Zeilen-Tabelle rendern ──────────────────────────────────────────────────
function renderLines(state) {
cachedState = state;
const { programId, cursor, lineCount, lines = [], version } = state;
currentActiveId = programId;
elActiveName.textContent = programId || '';
const hasProgram = !!programId;
// Info-Bar
if (hasProgram) {
elActiveBar.style.display = 'flex';
elAiId.textContent = programId;
elAiCount.textContent = lineCount;
elAiCursor.textContent = cursor;
elAiVersion.textContent = version;
} else {
elActiveBar.style.display = 'none';
}
// Stepping-Buttons
const hasLines = lineCount > 0;
elBtnFirst.disabled = !hasProgram || !hasLines || cursor === 0;
elBtnPrev.disabled = !hasProgram || !hasLines || cursor === 0;
elBtnNext.disabled = !hasProgram || !hasLines || cursor >= lineCount - 1;
elBtnLast.disabled = !hasProgram || !hasLines || cursor >= lineCount - 1;
elBtnClear.disabled = !hasProgram;
// Download-Link
if (programId) {
elBtnDownload.href = `/api/programs/${encodeURIComponent(programId)}/download`;
elBtnDownload.download = `${programId}.gcode`;
elBtnDownload.style.display = '';
} else {
elBtnDownload.style.display = 'none';
}
// Tabelle
if (!lines.length) {
elLinesBody.innerHTML = `<tr><td colspan="4" class="empty-hint" style="padding:10px">${
hasProgram ? 'Programm ist leer' : 'Kein aktives Programm — links ein Programm anklicken'
}</td></tr>`;
updateEditBar();
return;
}
elLinesBody.innerHTML = lines.map((line, i) => {
const { code, ts } = parseLine(line);
const isCursor = i === cursor;
const isPending = pendingDeletes.has(i);
return `<tr class="${isCursor ? 'cursor-row' : ''}${isPending ? ' pending-delete' : ''}" data-index="${i}">
<td class="idx">${i}</td>
<td class="line">${isCursor ? '▶ ' : ''}${esc(code)}</td>
<td class="ts">${esc(ts)}</td>
<td class="del-col"><button class="btn-row-del" data-index="${i}" title="Zeile löschen">🗑</button></td>
</tr>`;
}).join('');
// Delete-Buttons verdrahten
elLinesBody.querySelectorAll('.btn-row-del').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
markDelete(Number(btn.dataset.index));
});
});
// Zur Cursor-Zeile scrollen (nur wenn kein edit-Modus aktiv)
if (pendingDeletes.size === 0) {
const cursorRow = elLinesBody.querySelector('.cursor-row');
if (cursorRow) cursorRow.scrollIntoView({ block: 'nearest' });
}
updateEditBar();
}
// ─── Breadcrumb ──────────────────────────────────────────────────────────────
function renderBreadcrumb() {
const el = document.getElementById('breadcrumb');
let html = `<span class="bc-seg" data-depth="-1">GCodeFiles</span>`;
currentPath.forEach((seg, i) => {
html += ` / <span class="bc-seg" data-depth="${i}">${esc(seg)}</span>`;
});
el.innerHTML = html;
el.querySelectorAll('.bc-seg').forEach(span => {
span.addEventListener('click', () => {
const depth = Number(span.dataset.depth);
if (depth === -1) { currentPath = []; currentDir = ''; }
else { currentPath = currentPath.slice(0, depth + 1); currentDir = currentPath.join('/'); }
selectedId = null; selectedType = null;
elBtnDeleteSelected.disabled = true;
refresh();
});
});
}
// ─── In Ordner navigieren ────────────────────────────────────────────────────
function navigateInto(folderName) {
currentPath.push(folderName);
currentDir = currentPath.join('/');
selectedId = null; selectedType = null;
elBtnDeleteSelected.disabled = true;
refresh();
}
// ─── Daten laden ─────────────────────────────────────────────────────────────
async function refresh() {
if (pendingDeletes.size > 0) return; // edit-Modus schützen
const dirQ = currentDir ? `?dir=${encodeURIComponent(currentDir)}` : '';
try {
const [programs, folders, active] = await Promise.all([
apiFetch('GET', `/api/programs${dirQ}`),
apiFetch('GET', `/api/folders${dirQ}`),
apiFetch('GET', '/api/active'),
]);
renderBreadcrumb();
renderProgList(programs.programs || [], folders.folders || [], active.programId);
renderLines(active);
setStatus(elListStatus, '');
setStatus(elActStatus, '');
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Programm laden (FLoad-Äquivalent) ───────────────────────────────────────
async function loadProgram(id, dir) {
setStatus(elActStatus, `Lade '${id}'…`);
try {
const state = await apiFetch('PUT', '/api/active', { id, dir: dir ?? currentDir });
renderLines(state);
await refresh();
setStatus(elActStatus, `'${id}' geladen`);
} catch (err) {
setStatus(elActStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Stepping ────────────────────────────────────────────────────────────────
const STEP_BTN_IDS = ['btn-first', 'btn-prev', 'btn-next', 'btn-last'];
async function step(endpoint) {
if (sendMode) {
STEP_BTN_IDS.forEach(id => { document.getElementById(id).disabled = true; });
setStatus(elActStatus, '⏳ Sende an Roboter…');
} else {
setStatus(elActStatus, '…');
}
try {
const url = sendMode
? `/api/active/${endpoint}?execute=true`
: `/api/active/${endpoint}`;
const r = await apiFetch('POST', url);
setStatus(elActStatus, sendMode
? `✓ Ausgeführt → Cursor ${r.cursor}`
: `Cursor → ${r.cursor}`);
} catch (err) {
setStatus(elActStatus, `Fehler: ${err.message}`, true);
} finally {
await refresh(); // renderLines() setzt Buttons korrekt nach Cursor-Position
}
}
// ─── Clear ───────────────────────────────────────────────────────────────────
async function clearProgram() {
if (!currentActiveId) return;
if (!confirm(`Programm '${currentActiveId}' wirklich leeren?`)) return;
try {
const r = await apiFetch('POST', '/api/active/clear');
renderLines(r);
setStatus(elActStatus, 'Programm geleert');
} catch (err) {
setStatus(elActStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Zeilen-Löschung: Abbrechen / Speichern ─────────────────────────────────
function cancelDeletes() {
pendingDeletes.clear();
if (cachedState) renderLines(cachedState);
else updateEditBar();
}
async function saveDeletes() {
const indices = [...pendingDeletes].sort((a, b) => b - a); // höchste zuerst → kein Index-Shift
setStatus(elActStatus, `Lösche ${indices.length} Zeile(n)…`);
document.getElementById('btn-save-delete').disabled = true;
try {
let lastState;
for (const i of indices) {
lastState = await apiFetch('DELETE', `/api/active/lines/${i}`);
}
pendingDeletes.clear();
setStatus(elActStatus, `${indices.length} Zeile(n) gelöscht`);
// Sofortiges Neuzeichnen aus der DELETE-Antwort (enthält getState() mit aktualisierten Zeilen)
if (lastState) renderLines(lastState);
await refresh();
} catch (err) {
setStatus(elActStatus, `Fehler: ${err.message}`, true);
document.getElementById('btn-save-delete').disabled = false;
}
}
// ─── Neue Datei anlegen ──────────────────────────────────────────────────────
async function createFile() {
const name = prompt('Name der neuen Datei (ohne .gcode):');
if (!name || !name.trim()) return;
setStatus(elListStatus, `Erstelle '${name.trim()}'…`);
try {
const meta = await apiFetch('POST', '/api/programs', { name: name.trim(), dir: currentDir });
setStatus(elListStatus, `'${meta.id}' erstellt`);
await loadProgram(meta.id, currentDir);
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Neuen Ordner anlegen ────────────────────────────────────────────────────
async function createFolder() {
const name = prompt('Name des neuen Ordners:');
if (!name || !name.trim()) return;
const slug = name.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '');
if (!slug) { alert('Ungültiger Ordnername.'); return; }
setStatus(elListStatus, `Erstelle Ordner '${slug}'…`);
try {
await apiFetch('POST', '/api/folders', { name: slug, dir: currentDir });
setStatus(elListStatus, `Ordner '${slug}' erstellt`);
await refresh();
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Auswahl löschen (Toolbar-🗑) ────────────────────────────────────────────
async function deleteSelected() {
if (!selectedId) return;
const dirQ = currentDir ? `?dir=${encodeURIComponent(currentDir)}` : '';
if (selectedType === 'folder') {
if (!confirm(`Ordner '${selectedId}' und seinen gesamten Inhalt wirklich löschen?`)) return;
setStatus(elListStatus, `Lösche Ordner '${selectedId}'…`);
try {
await apiFetch('DELETE', `/api/folders/${encodeURIComponent(selectedId)}${dirQ}`);
setStatus(elListStatus, `Ordner '${selectedId}' gelöscht`);
selectedId = null; selectedType = null;
elBtnDeleteSelected.disabled = true;
await refresh();
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
return;
}
if (!confirm(`Programm '${selectedId}' wirklich löschen?`)) return;
setStatus(elListStatus, `Lösche '${selectedId}'…`);
try {
await apiFetch('DELETE', `/api/programs/${encodeURIComponent(selectedId)}${dirQ}`);
setStatus(elListStatus, `'${selectedId}' gelöscht`);
selectedId = null; selectedType = null;
elBtnDeleteSelected.disabled = true;
await refresh();
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Event-Listener ──────────────────────────────────────────────────────────
document.getElementById('btn-refresh').addEventListener('click', refresh);
document.getElementById('btn-new-file').addEventListener('click', createFile);
document.getElementById('btn-new-folder').addEventListener('click', createFolder);
document.getElementById('btn-cancel-delete').addEventListener('click', cancelDeletes);
document.getElementById('btn-save-delete').addEventListener('click', saveDeletes);
elBtnDeleteSelected.addEventListener('click', deleteSelected);
elBtnFirst.addEventListener('click', () => step('first'));
elBtnPrev .addEventListener('click', () => step('prev'));
elBtnNext .addEventListener('click', () => step('next'));
elBtnLast .addEventListener('click', () => step('last'));
elBtnClear.addEventListener('click', clearProgram);
// ─── Navigieren / Senden Toggle ──────────────────────────────────────────────
const elBtnModeNav = document.getElementById('btn-mode-nav');
const elBtnModeSend = document.getElementById('btn-mode-send');
elBtnModeNav.addEventListener('click', () => {
sendMode = false;
elBtnModeNav.classList.add('active');
elBtnModeSend.classList.remove('active');
setStatus(elActStatus, '');
});
elBtnModeSend.addEventListener('click', () => {
sendMode = true;
elBtnModeSend.classList.add('active');
elBtnModeNav.classList.remove('active');
setStatus(elActStatus, '');
});
// Collapse-Verhalten (wie im appRobotHoming)
document.querySelectorAll('.section h2').forEach(h2 => {
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
});
// ─── HTML escapen ────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Start ───────────────────────────────────────────────────────────────────
// Prüfen ob Driver konfiguriert ist, dann Senden-Button freigeben
apiFetch('GET', '/api/config').then(cfg => {
if (cfg.driverConfigured) {
elBtnModeSend.disabled = false;
elBtnModeSend.title = 'Zeile an Roboter senden';
} else {
elBtnModeSend.title = 'Driver WS nicht konfiguriert (DRIVER_WS_URL fehlt)';
}
}).catch(() => { /* config nicht verfügbar → Senden bleibt deaktiviert */ });
refresh();
setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>