FileBrowser

This commit is contained in:
chk
2026-06-14 21:45:22 +02:00
parent 551dded4dc
commit 47db676b51
5 changed files with 743 additions and 0 deletions

246
public/index.css Normal file
View File

@@ -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); }

324
public/index.html Normal file
View File

@@ -0,0 +1,324 @@
<!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="Liste neu laden">⟳ Aktualisieren</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>
</tr>
</thead>
<tbody id="lines-body">
<tr><td colspan="3" class="empty-hint" style="padding:10px">Kein aktives Programm</td></tr>
</tbody>
</table>
</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;
// ─── 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');
// ─── 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()) };
}
// ─── Programmliste rendern ───────────────────────────────────────────────────
function renderProgList(programs, activeId) {
elProgCount.textContent = programs.length;
if (!programs.length) {
elProgList.innerHTML = '<span class="empty-hint">Keine Programme gefunden</span>';
return;
}
elProgList.innerHTML = programs
.map(p => {
const isActive = p.id === activeId;
return `<div class="prog-item${isActive ? ' is-active' : ''}" data-id="${esc(p.id)}">
<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>
<button class="btn-del" data-id="${esc(p.id)}" title="Löschen">✕</button>
</div>`;
}).join('');
// Klick auf Zeile → FLoad (Programm aktivieren)
elProgList.querySelectorAll('.prog-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.classList.contains('btn-del')) return;
loadProgram(el.dataset.id);
});
});
// Klick auf Löschen-Button
elProgList.querySelectorAll('.btn-del').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
deleteProgram(btn.dataset.id);
});
});
}
// ─── Zeilen-Tabelle rendern ──────────────────────────────────────────────────
function renderLines(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="3" class="empty-hint" style="padding:10px">${
hasProgram ? 'Programm ist leer' : 'Kein aktives Programm — links ein Programm anklicken'
}</td></tr>`;
return;
}
elLinesBody.innerHTML = lines.map((line, i) => {
const { code, ts } = parseLine(line);
const isCursor = i === cursor;
return `<tr class="${isCursor ? 'cursor-row' : ''}">
<td class="idx">${i}</td>
<td class="line">${isCursor ? '▶ ' : ''}${esc(code)}</td>
<td class="ts">${esc(ts)}</td>
</tr>`;
}).join('');
// Zur Cursor-Zeile scrollen
const cursorRow = elLinesBody.querySelector('.cursor-row');
if (cursorRow) cursorRow.scrollIntoView({ block: 'nearest' });
}
// ─── Daten laden ─────────────────────────────────────────────────────────────
async function refresh() {
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);
}
}
// ─── Programm löschen ────────────────────────────────────────────────────────
async function deleteProgram(id) {
if (!confirm(`Programm '${id}' wirklich löschen?`)) return;
setStatus(elListStatus, `Lösche '${id}'…`);
try {
await apiFetch('DELETE', `/api/programs/${encodeURIComponent(id)}`);
setStatus(elListStatus, `'${id}' gelöscht`);
await refresh();
} catch (err) {
setStatus(elListStatus, `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);
}
}
// ─── Event-Listener ──────────────────────────────────────────────────────────
document.getElementById('btn-refresh').addEventListener('click', refresh);
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>