Files
appRobotFileservice/public/index.html
2026-06-14 22:29:49 +02:00

436 lines
19 KiB
HTML
Raw 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 class="controls">
<button id="btn-refresh" title="Aktualisieren"></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">
<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>
<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="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)
// ─── 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 + bei Dateien sofort laden
elProgList.querySelectorAll('.prog-item').forEach(el => {
el.addEventListener('click', () => {
setSelected(el.dataset.id, el.dataset.type);
if (el.dataset.type === 'file') 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`;
}
}
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();
}
// ─── Daten laden ─────────────────────────────────────────────────────────────
async function refresh() {
if (pendingDeletes.size > 0) return; // edit-Modus schützen
try {
const [programs, active] = await Promise.all([
apiFetch('GET', '/api/programs'),
apiFetch('GET', '/api/active'),
]);
renderProgList(programs.programs || [], [], active.programId);
renderLines(active);
setStatus(elListStatus, '');
setStatus(elActStatus, '');
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Programm laden (FLoad-Äquivalent) ───────────────────────────────────────
async function loadProgram(id) {
setStatus(elActStatus, `Lade '${id}'…`);
try {
const state = await apiFetch('PUT', '/api/active', { id });
renderLines(state);
await refresh();
setStatus(elActStatus, `'${id}' geladen`);
} catch (err) {
setStatus(elActStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Stepping ────────────────────────────────────────────────────────────────
async function step(endpoint) {
setStatus(elActStatus, '…');
try {
const r = await apiFetch('POST', `/api/active/${endpoint}`);
setStatus(elActStatus, `Cursor → ${r.cursor}`);
await refresh();
} catch (err) {
setStatus(elActStatus, `Fehler: ${err.message}`, true);
}
}
// ─── 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 {
for (const i of indices) {
await apiFetch('DELETE', `/api/active/lines/${i}`);
}
pendingDeletes.clear();
setStatus(elActStatus, `${indices.length} Zeile(n) gelöscht`);
await refresh();
} catch (err) {
setStatus(elActStatus, `Fehler: ${err.message}`, true);
document.getElementById('btn-save-delete').disabled = false;
}
}
// ─── Programm löschen (aus Toolbar) ─────────────────────────────────────────
async function deleteSelected() {
if (!selectedId) return;
if (selectedType === 'folder') {
alert(`Ordner-Löschung ist noch nicht implementiert.\n('${selectedId}')`);
return;
}
if (!confirm(`Programm '${selectedId}' wirklich löschen?`)) return;
setStatus(elListStatus, `Lösche '${selectedId}'…`);
try {
await apiFetch('DELETE', `/api/programs/${encodeURIComponent(selectedId)}`);
setStatus(elListStatus, `'${selectedId}' gelöscht`);
selectedId = null;
selectedType = null;
elBtnDeleteSelected.disabled = true;
await refresh();
} catch (err) {
setStatus(elListStatus, `Fehler: ${err.message}`, true);
}
}
// ─── Neuen Ordner anlegen ────────────────────────────────────────────────────
async function createFolder() {
alert('Ordner-Verwaltung ist noch nicht implementiert.\n(kommt in Phase 3 des Roadmaps)');
}
// ─── Event-Listener ──────────────────────────────────────────────────────────
document.getElementById('btn-refresh').addEventListener('click', refresh);
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);
// 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 ───────────────────────────────────────────────────────────────────
refresh();
setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>