FileBrowser
This commit is contained in:
246
public/index.css
Normal file
246
public/index.css
Normal 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
324
public/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ─── Start ───────────────────────────────────────────────────────────────────
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user