Erweitere die USB-Port-Konfiguration mit Bezeichnungen und verbessere das Layout der Benutzeroberfläche

This commit is contained in:
2026-05-09 01:06:19 +02:00
parent 37bfd79424
commit 394b887f70

451
app.py
View File

@@ -35,7 +35,8 @@ WIFI_BOOT_WAIT = 25 # Sekunden warten beim Start bevor AP gestartet wird
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
# USB # USB
'source_port': None, 'dest_port': None, 'source_port': None, 'source_label': '',
'dest_port': None, 'dest_label': '',
'folder_format': '%Y-%m-%d', 'add_time': True, 'folder_format': '%Y-%m-%d', 'add_time': True,
'subfolder': True, 'auto_copy': True, 'subfolder': True, 'auto_copy': True,
# WiFi # WiFi
@@ -522,6 +523,8 @@ def r_wifi_status():
# ── HTML Template ───────────────────────────────────────────────────────────── # ── HTML Template ─────────────────────────────────────────────────────────────
# ── HTML Template ─────────────────────────────────────────────────────────────
HTML = r"""<!DOCTYPE html> HTML = r"""<!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -534,11 +537,10 @@ HTML = r"""<!DOCTYPE html>
body{background:var(--bg);color:var(--txt);font-family:system-ui,sans-serif;padding:1rem 1rem 4rem;min-height:100vh} body{background:var(--bg);color:var(--txt);font-family:system-ui,sans-serif;padding:1rem 1rem 4rem;min-height:100vh}
h1{font-size:1.35rem;font-weight:700;display:flex;align-items:center;gap:.5rem;margin-bottom:1.25rem} h1{font-size:1.35rem;font-weight:700;display:flex;align-items:center;gap:.5rem;margin-bottom:1.25rem}
h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.8rem} h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.8rem}
.wrap{max-width:940px;margin:0 auto;display:grid;gap:.85rem;grid-template-columns:1fr} .wrap{max-width:960px;margin:0 auto;display:grid;gap:.85rem;grid-template-columns:1fr}
@media(min-width:600px){.wrap{grid-template-columns:1fr 1fr}} @media(min-width:600px){.wrap{grid-template-columns:1fr 1fr}}
.card{background:var(--s1);border:1px solid var(--brd);border-radius:.8rem;padding:1.1rem} .card{background:var(--s1);border:1px solid var(--brd);border-radius:.8rem;padding:1.1rem}
.span2{grid-column:1/-1} .span2{grid-column:1/-1}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.38rem .82rem;border:1px solid var(--brd);border-radius:.4rem;background:transparent;color:var(--txt);font-size:.84rem;cursor:pointer;transition:.15s;white-space:nowrap} .btn{display:inline-flex;align-items:center;gap:.3rem;padding:.38rem .82rem;border:1px solid var(--brd);border-radius:.4rem;background:transparent;color:var(--txt);font-size:.84rem;cursor:pointer;transition:.15s;white-space:nowrap}
.btn:hover{border-color:var(--acc);color:var(--acc)} .btn:hover{border-color:var(--acc);color:var(--acc)}
.btn:disabled{opacity:.4;cursor:default} .btn:disabled{opacity:.4;cursor:default}
@@ -550,27 +552,10 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
.btn.danger:hover{background:rgba(239,68,68,.1)} .btn.danger:hover{background:rgba(239,68,68,.1)}
.btn.sm{padding:.25rem .55rem;font-size:.75rem} .btn.sm{padding:.25rem .55rem;font-size:.75rem}
.btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.8rem} .btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.8rem}
/* Progress */
.prog-wrap{margin:.6rem 0 .3rem;height:6px;background:var(--bg);border-radius:3px;overflow:hidden} .prog-wrap{margin:.6rem 0 .3rem;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.prog-bar{height:100%;background:var(--acc);border-radius:3px;transition:width .4s ease} .prog-bar{height:100%;background:var(--acc);border-radius:3px;transition:width .4s ease}
.prog-info{font-size:.78rem;color:var(--mut);min-height:1.1rem} .prog-info{font-size:.78rem;color:var(--mut);min-height:1.1rem}
/* Status */
.st-ok{color:var(--grn)}.st-run{color:var(--acc)}.st-err{color:var(--red)}.st-idle{color:var(--mut)} .st-ok{color:var(--grn)}.st-run{color:var(--acc)}.st-err{color:var(--red)}.st-idle{color:var(--mut)}
/* Devices */
.dev-list{display:flex;flex-direction:column;gap:.5rem}
.dev{padding:.65rem .85rem;border:1px solid var(--brd);border-radius:.5rem;transition:.15s}
.dev:hover{border-color:var(--acc)}
.dev.src{border-color:var(--grn);background:rgba(34,197,94,.06)}
.dev.dst{border-color:var(--acc);background:rgba(59,130,246,.06)}
.dev-top{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.dev-name{font-weight:600;font-size:.9rem}
.badge{font-size:.65rem;font-weight:700;text-transform:uppercase;padding:.12rem .42rem;border-radius:.25rem}
.b-src{background:rgba(34,197,94,.15);color:var(--grn)}
.b-dst{background:rgba(59,130,246,.15);color:var(--acc)}
.dev-meta{font-size:.74rem;color:var(--mut);display:flex;gap:.4rem;flex-wrap:wrap;margin:.25rem 0}
.dev-meta span{background:var(--bg);padding:.1rem .35rem;border-radius:.2rem}
.dev-acts{display:flex;gap:.4rem;margin-top:.35rem}
/* Form fields */
.field{margin-bottom:.8rem} .field{margin-bottom:.8rem}
.field label{display:block;font-size:.81rem;color:var(--mut);margin-bottom:.3rem} .field label{display:block;font-size:.81rem;color:var(--mut);margin-bottom:.3rem}
.field input,.field select{width:100%;padding:.44rem .65rem;background:var(--bg);border:1px solid var(--brd);border-radius:.4rem;color:var(--txt);font-size:.88rem} .field input,.field select{width:100%;padding:.44rem .65rem;background:var(--bg);border:1px solid var(--brd);border-radius:.4rem;color:var(--txt);font-size:.88rem}
@@ -578,31 +563,40 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
.field input[type=password]{letter-spacing:.05em} .field input[type=password]{letter-spacing:.05em}
.tog{display:flex;align-items:center;gap:.5rem;margin-bottom:.65rem;cursor:pointer;user-select:none;font-size:.88rem} .tog{display:flex;align-items:center;gap:.5rem;margin-bottom:.65rem;cursor:pointer;user-select:none;font-size:.88rem}
.tog input{accent-color:var(--acc);width:16px;height:16px;cursor:pointer} .tog input{accent-color:var(--acc);width:16px;height:16px;cursor:pointer}
/* Log */
.log-box{font-family:ui-monospace,monospace;font-size:.76rem;max-height:220px;overflow-y:auto} .log-box{font-family:ui-monospace,monospace;font-size:.76rem;max-height:220px;overflow-y:auto}
.log-entry{display:flex;gap:.5rem;padding:.2rem 0;border-bottom:1px solid rgba(51,65,85,.5)} .log-entry{display:flex;gap:.5rem;padding:.2rem 0;border-bottom:1px solid rgba(51,65,85,.5)}
.log-t{color:var(--mut);flex-shrink:0} .log-t{color:var(--mut);flex-shrink:0}
.empty{color:var(--mut);font-size:.86rem;padding:.25rem 0} .empty{color:var(--mut);font-size:.86rem;padding:.25rem 0}
/* WiFi status bar */
.wifi-bar{display:flex;align-items:center;gap:.6rem;padding:.55rem .85rem;border-radius:.5rem;border:1px solid var(--brd);background:var(--s2);font-size:.84rem;flex-wrap:wrap} .wifi-bar{display:flex;align-items:center;gap:.6rem;padding:.55rem .85rem;border-radius:.5rem;border:1px solid var(--brd);background:var(--s2);font-size:.84rem;flex-wrap:wrap}
.wifi-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0} .wifi-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.wifi-dot.green{background:var(--grn)} .wifi-dot.green{background:var(--grn)}.wifi-dot.blue{background:var(--pur)}.wifi-dot.grey{background:var(--mut)}
.wifi-dot.blue{background:var(--pur)}
.wifi-dot.grey{background:var(--mut)}
.wifi-info{flex:1;min-width:0}
.wifi-ip{font-family:monospace;font-size:.8rem;color:var(--mut)} .wifi-ip{font-family:monospace;font-size:.8rem;color:var(--mut)}
/* Tab strip for config */
.tabs{display:flex;gap:.25rem;margin-bottom:.9rem;border-bottom:1px solid var(--brd);padding-bottom:.5rem} .tabs{display:flex;gap:.25rem;margin-bottom:.9rem;border-bottom:1px solid var(--brd);padding-bottom:.5rem}
.tab{padding:.3rem .7rem;border-radius:.35rem;font-size:.82rem;cursor:pointer;color:var(--mut);transition:.15s} .tab{padding:.3rem .7rem;border-radius:.35rem;font-size:.82rem;cursor:pointer;color:var(--mut);transition:.15s}
.tab.active{background:var(--acc);color:#fff} .tab.active{background:var(--acc);color:#fff}
.tab-pane{display:none}.tab-pane.active{display:block} .tab-pane{display:none}.tab-pane.active{display:block}
/* Network list */ .net-list{display:flex;flex-direction:column;gap:.35rem;max-height:200px;overflow-y:auto;margin-top:.5rem}
.net-list{display:flex;flex-direction:column;gap:.35rem;max-height:220px;overflow-y:auto;margin-top:.5rem}
.net-item{display:flex;align-items:center;gap:.5rem;padding:.35rem .55rem;border:1px solid var(--brd);border-radius:.4rem;cursor:pointer;transition:.15s;font-size:.84rem} .net-item{display:flex;align-items:center;gap:.5rem;padding:.35rem .55rem;border:1px solid var(--brd);border-radius:.4rem;cursor:pointer;transition:.15s;font-size:.84rem}
.net-item:hover{border-color:var(--acc);background:rgba(59,130,246,.05)} .net-item:hover{border-color:var(--acc);background:rgba(59,130,246,.05)}
.net-signal{font-size:.72rem;color:var(--mut);margin-left:auto} .net-signal{font-size:.72rem;color:var(--mut);margin-left:auto}
.flash{font-size:.78rem;padding:.25rem 0;min-height:1.2rem} .flash{font-size:.78rem;padding:.25rem 0;min-height:1.2rem}
.flash.ok{color:var(--grn)}.flash.err{color:var(--red)} .flash.ok{color:var(--grn)}.flash.err{color:var(--red)}
/* Port Slots */
.port-grid{display:grid;grid-template-columns:1fr 1fr;gap:.85rem}
@media(max-width:599px){.port-grid{grid-template-columns:1fr}}
.port-slot{border:2px solid var(--brd);border-radius:.7rem;padding:1rem;transition:border-color .2s}
.port-slot.has-src{border-color:var(--grn)}
.port-slot.has-dst{border-color:var(--acc)}
.port-role{font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;padding:.18rem .5rem;border-radius:.25rem;display:inline-block;margin-bottom:.65rem}
.port-role.src{background:rgba(34,197,94,.15);color:var(--grn)}
.port-role.dst{background:rgba(59,130,246,.15);color:var(--acc)}
.port-status{display:flex;align-items:center;gap:.65rem;padding:.65rem .8rem;background:var(--bg);border-radius:.5rem;margin-bottom:.8rem;min-height:54px}
.pdot{width:10px;height:10px;border-radius:50%;flex-shrink:0;transition:.3s}
.pdot.on{background:var(--grn);box-shadow:0 0 6px var(--grn)}
.pdot.off{background:var(--brd)}
.port-dev-name{font-weight:600;font-size:.9rem;line-height:1.3}
.port-dev-sub{font-size:.73rem;color:var(--mut);font-family:monospace;margin-top:.1rem}
.port-hint{font-size:.73rem;color:var(--mut);margin-top:.65rem;padding:.5rem .65rem;background:var(--bg);border-radius:.4rem;border-left:3px solid var(--brd)}
</style> </style>
</head> </head>
<body> <body>
@@ -611,12 +605,12 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
<!-- Header --> <!-- Header -->
<div class="span2" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem"> <div class="span2" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem">
<h1 style="margin:0"> <h1 style="margin:0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
PiCopy PiCopy
</h1> </h1>
<div id="wifi-bar" class="wifi-bar" style="max-width:340px"> <div class="wifi-bar" style="max-width:340px">
<div class="wifi-dot grey" id="wifi-dot"></div> <div class="wifi-dot grey" id="wifi-dot"></div>
<div class="wifi-info"> <div style="flex:1;min-width:0">
<div id="wifi-mode-txt" style="font-weight:600">Verbinde…</div> <div id="wifi-mode-txt" style="font-weight:600">Verbinde…</div>
<div id="wifi-ip" class="wifi-ip"></div> <div id="wifi-ip" class="wifi-ip"></div>
</div> </div>
@@ -639,10 +633,69 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
</div> </div>
</div> </div>
<!-- USB Geräte --> <!-- USB Port Konfiguration -->
<div class="card span2"> <div class="card span2">
<h2>Verbundene USB-Geräte</h2> <h2>USB Port Konfiguration</h2>
<div id="dev-list" class="dev-list"><div class="empty">Lade…</div></div>
<div class="port-grid">
<!-- QUELLE -->
<div class="port-slot" id="slot-src">
<div class="port-role src">Quelle</div>
<div class="port-status">
<div class="pdot off" id="src-dot"></div>
<div style="min-width:0">
<div class="port-dev-name" id="src-dev-name">Nicht verbunden</div>
<div class="port-dev-sub" id="src-dev-sub">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung (frei wählbar)</label>
<input type="text" id="src-label" placeholder="z.B. Kamera-Stick">
</div>
<div class="field">
<label>Port zuweisen — verbundenes Gerät wählen</label>
<select id="src-select">
<option value="">— Gerät einstecken &amp; hier wählen —</option>
</select>
</div>
<button class="btn sec" style="width:100%" onclick="assignPort('source')">&#10003;&nbsp;Als feste Quelle speichern</button>
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
<div class="port-hint">Stecke den USB-Stick in den gewünschten Port, wähle ihn hier aus und klicke Speichern. PiCopy merkt sich diesen physischen Port dauerhaft.</div>
</div>
<!-- ZIEL -->
<div class="port-slot" id="slot-dst">
<div class="port-role dst">Ziel</div>
<div class="port-status">
<div class="pdot off" id="dst-dot"></div>
<div style="min-width:0">
<div class="port-dev-name" id="dst-dev-name">Nicht verbunden</div>
<div class="port-dev-sub" id="dst-dev-sub">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung (frei wählbar)</label>
<input type="text" id="dst-label" placeholder="z.B. Backup-Laufwerk">
</div>
<div class="field">
<label>Port zuweisen — verbundenes Gerät wählen</label>
<select id="dst-select">
<option value="">— Gerät einstecken &amp; hier wählen —</option>
</select>
</div>
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">&#10003;&nbsp;Als festes Ziel speichern</button>
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
<div class="port-hint">Stecke das Ziel-Laufwerk in den gewünschten Port, wähle es aus und klicke Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
</div>
</div>
<!-- Weitere verbundene Geräte (nicht zugewiesen) -->
<div id="unassigned-wrap" style="margin-top:.85rem;display:none">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.5rem">Weitere verbundene Geräte (noch nicht zugewiesen)</div>
<div id="unassigned-list" style="display:flex;flex-direction:column;gap:.35rem"></div>
</div>
</div> </div>
<!-- Kopier-Einstellungen --> <!-- Kopier-Einstellungen -->
@@ -651,15 +704,15 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
<div class="field"> <div class="field">
<label>Ordner-Datumsformat</label> <label>Ordner-Datumsformat</label>
<select id="c-fmt"> <select id="c-fmt">
<option value="%Y-%m-%d">JJJJ-MM-TT (2024-01-15)</option> <option value="%Y-%m-%d">JJJJ-MM-TT &nbsp;(2024-01-15)</option>
<option value="%Y%m%d">JJJJMMTT (20240115)</option> <option value="%Y%m%d">JJJJMMTT &nbsp;(20240115)</option>
<option value="%d-%m-%Y">TT-MM-JJJJ (15-01-2024)</option> <option value="%d-%m-%Y">TT-MM-JJJJ &nbsp;(15-01-2024)</option>
<option value="%Y/%m/%d">JJJJ/MM/TT (Unterordner)</option> <option value="%Y/%m/%d">JJJJ/MM/TT &nbsp;(Unterordner)</option>
</select> </select>
</div> </div>
<label class="tog"><input type="checkbox" id="c-time"><span>Uhrzeit im Ordnernamen</span></label> <label class="tog"><input type="checkbox" id="c-time"><span>Uhrzeit im Ordnernamen</span></label>
<label class="tog"><input type="checkbox" id="c-sub"><span>Unterordner pro Quelle</span></label> <label class="tog"><input type="checkbox" id="c-sub"><span>Unterordner pro Quelle (nach Gerätebezeichnung)</span></label>
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren bei USB-Verbindung</span></label> <label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren wenn Quelle &amp; Ziel verbunden</span></label>
<button class="btn pri" onclick="saveCopyCfg()">&#10003;&nbsp;Speichern</button> <button class="btn pri" onclick="saveCopyCfg()">&#10003;&nbsp;Speichern</button>
<div id="copy-cfg-msg" class="flash ok" style="display:none">Gespeichert!</div> <div id="copy-cfg-msg" class="flash ok" style="display:none">Gespeichert!</div>
</div> </div>
@@ -668,20 +721,16 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
<div class="card"> <div class="card">
<h2>WiFi-Einstellungen</h2> <h2>WiFi-Einstellungen</h2>
<div class="tabs"> <div class="tabs">
<div class="tab active" onclick="showTab('tab-client')">Heimnetz</div> <div class="tab active" onclick="switchTab('tab-client','tab-ap')">Heimnetz</div>
<div class="tab" onclick="showTab('tab-ap')">Hotspot (AP)</div> <div class="tab" onclick="switchTab('tab-ap','tab-client')">Hotspot (AP)</div>
</div> </div>
<!-- Client WiFi Tab -->
<div id="tab-client" class="tab-pane active"> <div id="tab-client" class="tab-pane active">
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem"> <div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">Heimnetz für die Router-Verbindung. Wenn nicht erreichbar, startet PiCopy automatisch einen Hotspot.</div>
WLAN für die Verbindung mit deinem Router. Wenn nicht erreichbar, startet PiCopy automatisch einen eigenen Hotspot.
</div>
<div class="field"> <div class="field">
<label>Netzwerk (SSID)</label> <label>Netzwerk (SSID)</label>
<div style="display:flex;gap:.4rem"> <div style="display:flex;gap:.4rem">
<input type="text" id="w-ssid" placeholder="WLAN-Name"> <input type="text" id="w-ssid" placeholder="WLAN-Name">
<button class="btn sm" onclick="scanNetworks()" title="Netzwerke suchen">&#128268;</button> <button class="btn sm" onclick="scanNetworks()">&#128268;</button>
</div> </div>
</div> </div>
<div id="net-list" class="net-list" style="display:none"></div> <div id="net-list" class="net-list" style="display:none"></div>
@@ -692,13 +741,8 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
<button class="btn pri" onclick="connectWifi()">&#128268;&nbsp;Verbinden &amp; Speichern</button> <button class="btn pri" onclick="connectWifi()">&#128268;&nbsp;Verbinden &amp; Speichern</button>
<div id="wifi-flash" class="flash" style="margin-top:.4rem"></div> <div id="wifi-flash" class="flash" style="margin-top:.4rem"></div>
</div> </div>
<!-- AP Tab -->
<div id="tab-ap" class="tab-pane"> <div id="tab-ap" class="tab-pane">
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem"> <div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">Dieser Hotspot startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP des Pi im Hotspot-Modus: <b>10.42.0.1:8080</b></div>
Der Hotspot wird automatisch gestartet wenn kein Heimnetz erreichbar ist.<br>
IP: <b>10.42.0.1</b> &nbsp;·&nbsp; Port: <b>8080</b>
</div>
<div class="field"> <div class="field">
<label>Hotspot-Name (SSID)</label> <label>Hotspot-Name (SSID)</label>
<input type="text" id="ap-ssid" placeholder="PiCopy"> <input type="text" id="ap-ssid" placeholder="PiCopy">
@@ -712,85 +756,138 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
</div> </div>
</div> </div>
<!-- Log --> <!-- Protokoll -->
<div class="card span2"> <div class="card span2">
<h2>Protokoll</h2> <h2>Protokoll</h2>
<div id="log-box" class="log-box"><div class="empty">Noch keine Einträge</div></div> <div id="log-box" class="log-box"><div class="empty">Noch keine Einträge</div></div>
</div> </div>
</div><!-- /wrap --> </div>
<script> <script>
let cfg = {}, devs = []; let cfg = {}, devs = [];
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const api = async (path, method='GET', body=null) => { const api = async (path, method='GET', body=null) => {
const o = {method, headers:{'Content-Type':'application/json'}}; const o = {method, headers:{'Content-Type':'application/json'}};
if (body) o.body = JSON.stringify(body); if (body) o.body = JSON.stringify(body);
const r = await fetch('/api'+path, o); return (await fetch('/api'+path, o)).json();
return r.json();
}; };
// ── Tab navigation ─────────────────────────────────────────────────────── // ── Tabs ──────────────────────────────────────────────────────────────────
function showTab(id) { function switchTab(show, hide) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); $(show).classList.add('active'); $(hide).classList.remove('active');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab').forEach(t =>
$(id).classList.add('active'); t.classList.toggle('active', t.textContent.trim() === (show==='tab-client' ? 'Heimnetz' : 'Hotspot (AP)'))
const idx = ['tab-client','tab-ap'].indexOf(id); );
document.querySelectorAll('.tab')[idx]?.classList.add('active');
} }
// ── Devices ────────────────────────────────────────────────────────────── // ── Port Slots ────────────────────────────────────────────────────────────
async function refreshDevices() { async function refreshDevices() {
devs = await api('/devices'); devs = await api('/devices');
renderDevs(); renderPortSlots();
renderUnassigned();
populateSelects();
} }
function renderDevs() { function renderPortSlots() {
const el = $('dev-list'); renderSlot('src', cfg.source_port, cfg.source_label);
if (!devs.length) { renderSlot('dst', cfg.dest_port, cfg.dest_label);
el.innerHTML = '<div class="empty">Keine USB-Speichergeräte gefunden. Gerät einstecken und "Neu laden" klicken.</div>'; }
return;
function renderSlot(role, port, label) {
const isSrc = role === 'src';
const dev = devs.find(d => d.usb_port === port);
const dot = $(role+'-dot');
const nameEl = $(role+'-dev-name');
const subEl = $(role+'-dev-sub');
const slotEl = $('slot-'+role);
const lblEl = $(role+'-label');
slotEl.classList.toggle('has-src', isSrc && !!port);
slotEl.classList.toggle('has-dst', !isSrc && !!port);
if (dev) {
dot.className = 'pdot on';
nameEl.textContent = dev.label || dev.device;
subEl.textContent = 'Port ' + port + (dev.size ? ' · ' + dev.size : '') + (dev.mount ? ' · ' + dev.mount : '');
} else if (port) {
dot.className = 'pdot off';
nameEl.textContent = label || 'Nicht verbunden';
subEl.textContent = 'Konfigurierter Port: ' + port + (label ? ' · ' + label : '');
} else {
dot.className = 'pdot off';
nameEl.textContent = 'Nicht verbunden';
subEl.textContent = 'Kein Port konfiguriert';
} }
el.innerHTML = devs.map(d => {
const isSrc = d.usb_port === cfg.source_port; if (lblEl && !lblEl.dataset.dirty) lblEl.value = label || '';
const isDst = d.usb_port === cfg.dest_port;
const cls = isSrc ? 'src' : isDst ? 'dst' : '';
const badge = isSrc ? '<span class="badge b-src">&#10003; Quelle</span>'
: isDst ? '<span class="badge b-dst">&#10003; Ziel</span>' : '';
return `<div class="dev ${cls}">
<div class="dev-top">
<span class="dev-name">${d.label||d.device}</span>${badge}
</div>
<div class="dev-meta">
<span>${d.device}</span>
<span>Port: <b>${d.usb_port||'?'}</b></span>
<span>${d.size}</span>
${d.mount?'<span>'+d.mount+'</span>':''}
</div>
<div class="dev-acts">
<button class="btn sm sec" onclick="assign('${d.usb_port}','source')">Als Quelle</button>
<button class="btn sm" onclick="assign('${d.usb_port}','dest')">Als Ziel</button>
</div>
</div>`;
}).join('');
} }
async function assign(port, role) { function populateSelects() {
if (role === 'source') cfg.source_port = port; const opts = devs.map(d =>
else cfg.dest_port = port; `<option value="${d.usb_port}">${d.label||d.device} — Port ${d.usb_port||'?'} (${d.size})</option>`
await api('/config', 'POST', cfg); ).join('');
renderDevs(); ['src-select','dst-select'].forEach(id => {
const el = $(id), prev = el.value;
el.innerHTML = '<option value="">— Gerät einstecken &amp; hier wählen —</option>' + opts;
if (prev && devs.find(d => d.usb_port === prev)) el.value = prev;
});
} }
function renderUnassigned() {
const list = devs.filter(d => d.usb_port !== cfg.source_port && d.usb_port !== cfg.dest_port);
const wrap = $('unassigned-wrap');
if (!list.length) { wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
$('unassigned-list').innerHTML = list.map(d => `
<div style="display:flex;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg);border-radius:.45rem;font-size:.84rem">
<div style="width:8px;height:8px;border-radius:50%;background:var(--ylw);flex-shrink:0"></div>
<span style="font-weight:600">${d.label||d.device}</span>
<span style="color:var(--mut);font-size:.74rem">${d.device} · Port ${d.usb_port||'?'} · ${d.size}</span>
</div>`).join('');
}
// ── Assign port ───────────────────────────────────────────────────────────
async function assignPort(role) {
const isSrc = role === 'source';
const selId = isSrc ? 'src-select' : 'dst-select';
const lblId = isSrc ? 'src-label' : 'dst-label';
const flashId = isSrc ? 'src-flash' : 'dst-flash';
const portKey = isSrc ? 'source_port' : 'dest_port';
const labelKey = isSrc ? 'source_label': 'dest_label';
const port = $(selId).value;
const label = $(lblId).value.trim();
if (!port) { flash(flashId,'err','Bitte zuerst ein Gerät aus der Liste wählen.'); return; }
const otherPort = isSrc ? cfg.dest_port : cfg.source_port;
if (port === otherPort) {
flash(flashId,'err','Dieser Port ist bereits als '+(isSrc?'Ziel':'Quelle')+' konfiguriert!'); return;
}
cfg[portKey] = port;
cfg[labelKey] = label;
$(lblId).dataset.dirty = '';
await api('/config','POST',cfg);
flash(flashId,'ok','Gespeichert — Port '+port+' ist jetzt feste '+(isSrc?'Quelle':'Ziel')+'.');
renderPortSlots();
renderUnassigned();
}
['src-label','dst-label'].forEach(id => {
window.addEventListener('DOMContentLoaded', () => {
const el = $(id);
if (el) el.addEventListener('input', () => { el.dataset.dirty = '1'; });
});
});
// ── Copy ────────────────────────────────────────────────────────────────── // ── Copy ──────────────────────────────────────────────────────────────────
async function startCopy() { async function startCopy() {
const r = await api('/copy/start', 'POST'); const r = await api('/copy/start','POST');
if (r.error) alert('Fehler: ' + r.error); if (r.error) alert('Fehler: '+r.error);
} }
async function cancelCopy() { await api('/copy/cancel', 'POST'); } async function cancelCopy() { await api('/copy/cancel','POST'); }
// ── Config ──────────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────────
async function loadCfg() { async function loadCfg() {
cfg = await api('/config'); cfg = await api('/config');
$('c-fmt').value = cfg.folder_format || '%Y-%m-%d'; $('c-fmt').value = cfg.folder_format || '%Y-%m-%d';
@@ -806,138 +903,102 @@ async function saveCopyCfg() {
cfg.add_time = $('c-time').checked; cfg.add_time = $('c-time').checked;
cfg.subfolder = $('c-sub').checked; cfg.subfolder = $('c-sub').checked;
cfg.auto_copy = $('c-auto').checked; cfg.auto_copy = $('c-auto').checked;
await api('/config', 'POST', cfg); await api('/config','POST',cfg);
flash('copy-cfg-msg', 'ok', 'Gespeichert!'); flash('copy-cfg-msg','ok','Gespeichert!');
} }
// ── WiFi ────────────────────────────────────────────────────────────────── // ── WiFi ──────────────────────────────────────────────────────────────────
async function scanNetworks() { async function scanNetworks() {
$('net-list').style.display = 'flex'; $('net-list').style.display='flex';
$('net-list').innerHTML = '<div class="empty">Suche Netzwerke…</div>'; $('net-list').innerHTML='<div class="empty">Suche Netzwerke…</div>';
const nets = await api('/wifi/scan'); const nets = await api('/wifi/scan');
if (!nets.length) { if (!nets.length) { $('net-list').innerHTML='<div class="empty">Keine Netzwerke gefunden</div>'; return; }
$('net-list').innerHTML = '<div class="empty">Keine Netzwerke gefunden</div>';
return;
}
$('net-list').innerHTML = nets.map(n => { $('net-list').innerHTML = nets.map(n => {
const bars = n.signal > 66 ? '▂▄▆█' : n.signal > 33 ? '▂▄▆░' : '▂▄░░'; const b = n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
return `<div class="net-item" onclick="selectNet('${n.ssid.replace(/'/g,"\\'")}')"> return `<div class="net-item" onclick="selectNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-signal">${b} ${n.signal}%</span></div>`;
<span>${n.ssid}</span>
<span class="net-signal">${bars} ${n.signal}%</span>
</div>`;
}).join(''); }).join('');
} }
function selectNet(ssid) { $('w-ssid').value=ssid; $('net-list').style.display='none'; $('w-pw').focus(); }
function selectNet(ssid) {
$('w-ssid').value = ssid;
$('net-list').style.display = 'none';
$('w-pw').focus();
}
async function connectWifi() { async function connectWifi() {
const ssid = $('w-ssid').value.trim(); const ssid=$('w-ssid').value.trim(), pw=$('w-pw').value;
const pw = $('w-pw').value;
if (!ssid) { flash('wifi-flash','err','Bitte SSID eingeben'); return; } if (!ssid) { flash('wifi-flash','err','Bitte SSID eingeben'); return; }
flash('wifi-flash','ok','Verbinde… (kann 30s dauern)'); flash('wifi-flash','ok','Verbinde… (kann 30s dauern)');
const r = await api('/wifi/connect', 'POST', {ssid, password: pw}); const r = await api('/wifi/connect','POST',{ssid,password:pw});
if (r.error) flash('wifi-flash','err',r.error); if (r.error) flash('wifi-flash','err',r.error);
else flash('wifi-flash','ok','Verbindungsversuch gestartet. Bei Erfolg erscheint neue IP oben.'); else flash('wifi-flash','ok','Gestartet. Bei Erfolg erscheint oben die neue IP.');
} }
async function saveAP() { async function saveAP() {
const ssid = $('ap-ssid').value.trim(); const ssid=$('ap-ssid').value.trim(), pw=$('ap-pw').value;
const pw = $('ap-pw').value;
if (!ssid) { flash('ap-flash','err','SSID fehlt'); return; } if (!ssid) { flash('ap-flash','err','SSID fehlt'); return; }
if (pw.length < 8) { flash('ap-flash','err','Passwort min. 8 Zeichen'); return; } if (pw.length<8) { flash('ap-flash','err','Passwort min. 8 Zeichen'); return; }
const r = await api('/wifi/ap', 'POST', {ssid, password: pw}); const r = await api('/wifi/ap','POST',{ssid,password:pw});
if (r.error) flash('ap-flash','err',r.error); if (r.error) flash('ap-flash','err',r.error);
else flash('ap-flash','ok','Gespeichert! Hotspot wird neu gestartet.'); else flash('ap-flash','ok','Gespeichert! Hotspot wird neu gestartet.');
} }
// ── Poll status ──────────────────────────────────────────────────────────── // ── Poll ──────────────────────────────────────────────────────────────────
async function poll() { async function poll() {
try { try {
const s = await api('/status'); const {copy:c, wifi:w} = await api('/status');
const c = s.copy, w = s.wifi;
// WiFi bar // WiFi bar
const dot = $('wifi-dot'); const dot=$('wifi-dot'), mTxt=$('wifi-mode-txt'), ip=$('wifi-ip');
const modeTxt = $('wifi-mode-txt'); if (w.mode==='client'){
const ipTxt = $('wifi-ip'); dot.className='wifi-dot green';
if (w.mode === 'client') { mTxt.innerHTML='&#128268; '+(w.ssid||'Verbunden');
dot.className = 'wifi-dot green'; ip.textContent=w.ip||'';
modeTxt.textContent = '&#128268; ' + (w.ssid || 'Verbunden'); } else if (w.mode==='ap'){
modeTxt.innerHTML = '&#128268; ' + (w.ssid || 'Verbunden'); dot.className='wifi-dot blue';
ipTxt.textContent = w.ip || ''; mTxt.innerHTML='&#128246; Hotspot: '+(w.ssid||'PiCopy');
} else if (w.mode === 'ap') { ip.textContent='10.42.0.1 · Port 8080';
dot.className = 'wifi-dot blue';
modeTxt.innerHTML = '&#128246; Hotspot: ' + (w.ssid || 'PiCopy');
ipTxt.textContent = '10.42.0.1 (Direkt verbinden)';
} else { } else {
dot.className = 'wifi-dot grey'; dot.className='wifi-dot grey';
modeTxt.textContent = 'Kein WLAN'; mTxt.textContent='Kein WLAN'; ip.textContent='';
ipTxt.textContent = '';
} }
// Copy status // Copy status
const txt = $('st-text'), bar = $('prog-bar'), wrap = $('prog-wrap'); const txt=$('st-text'), bar=$('prog-bar'), wrap=$('prog-wrap');
const info = $('prog-info'), sum = $('st-summary'); const info=$('prog-info'), sum=$('st-summary');
const bStart = $('btn-start'), bCancel = $('btn-cancel'); const bS=$('btn-start'), bC=$('btn-cancel');
if (c.running){
if (c.running) { txt.className='st-run'; txt.textContent='Kopiert… '+c.progress+'%';
txt.className = 'st-run'; wrap.style.display='block'; bar.style.width=c.progress+'%';
txt.textContent = 'Kopiert… ' + c.progress + '%'; info.textContent=c.current?c.done+' / '+c.total+''+c.current:'';
wrap.style.display = 'block'; sum.textContent=''; bS.style.display='none'; bC.style.display='';
bar.style.width = c.progress + '%';
info.textContent = c.current ? c.done + '/' + c.total + '' + c.current : '';
sum.textContent = '';
bStart.style.display = 'none';
bCancel.style.display = '';
} else { } else {
bStart.style.display = ''; bS.style.display=''; bC.style.display='none'; info.textContent='';
bCancel.style.display = 'none'; if (c.error){
info.textContent = ''; txt.className='st-err'; txt.textContent='Fehler: '+c.error;
if (c.error) { wrap.style.display='none'; sum.textContent='';
txt.className = 'st-err'; } else if (c.last_copy){
txt.textContent = 'Fehler: ' + c.error; txt.className='st-ok'; txt.textContent='&#10003; Abgeschlossen';
wrap.style.display = 'none'; wrap.style.display='block'; bar.style.width='100%';
sum.textContent = ''; sum.textContent=c.total+' Dateien · '+new Date(c.last_copy).toLocaleString('de-DE');
} else if (c.last_copy) {
txt.className = 'st-ok';
txt.textContent = '✓ Abgeschlossen';
wrap.style.display = 'block';
bar.style.width = '100%';
sum.textContent = c.total + ' Dateien kopiert · ' + new Date(c.last_copy).toLocaleString('de-DE');
} else { } else {
txt.className = 'st-idle'; txt.className='st-idle'; txt.textContent='Bereit';
txt.textContent = 'Bereit'; wrap.style.display='none'; sum.textContent='';
wrap.style.display = 'none';
sum.textContent = '';
} }
} }
// Log // Log
if (c.logs && c.logs.length) { if (c.logs&&c.logs.length)
$('log-box').innerHTML = c.logs.slice().reverse().map(l => $('log-box').innerHTML=c.logs.slice().reverse().map(l=>
`<div class="log-entry"><span class="log-t">${l.t}</span><span>${l.m}</span></div>` `<div class="log-entry"><span class="log-t">${l.t}</span><span>${l.m}</span></div>`).join('');
).join(''); } catch(e){}
}
} catch(e) {}
} }
function flash(id, cls, msg) { function flash(id, cls, msg) {
const el = $(id); const el=$(id); el.className='flash '+cls; el.textContent=msg; el.style.display='block';
el.className = 'flash ' + cls; if (cls==='ok') setTimeout(()=>el.style.display='none',3500);
el.textContent = msg;
el.style.display = 'block';
if (cls === 'ok') setTimeout(() => el.style.display='none', 3500);
} }
(async () => { (async()=>{
await loadCfg(); await loadCfg();
await refreshDevices(); await refreshDevices();
setInterval(poll, 1500); setInterval(poll, 1500);
setInterval(refreshDevices, 12000); setInterval(refreshDevices, 8000);
poll(); poll();
})(); })();
</script> </script>