Files
PiCopy/templates/index.html

1793 lines
88 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">
<title>PiCopy</title>
<style>
/* -- Reset & Tokens -- */
:root {
--bg: #0a0f1e;
--bg2: #111827;
--surf: #1a2235;
--surf2: #1f2a40;
--brd: #2a3650;
--brd2: #374766;
--txt: #e8edf5;
--sub: #8b9ab5;
--acc: #4f8ef7;
--acc2: #3b7de8;
--grn: #34d399;
--grn2: #10b981;
--red: #f87171;
--ylw: #fbbf24;
--pur: #a78bfa;
--r: .6rem;
--r2: .9rem;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100vh;padding:0 0 4rem}
/* -- Topbar -- */
.topbar{background:var(--bg2);border-bottom:1px solid var(--brd);padding:.75rem 1.5rem;display:flex;align-items:center;gap:1rem;position:sticky;top:0;z-index:100;backdrop-filter:blur(8px)}
.logo{display:flex;align-items:center;gap:.55rem;font-size:1rem;font-weight:700;letter-spacing:-.02em;color:var(--txt)}
.logo-img{height:28px;width:auto;object-fit:contain}
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:.5rem;min-width:0;overflow:hidden}
.topbar-wifi{display:flex;align-items:center;gap:.6rem;font-size:.82rem;background:var(--surf);border:1px solid var(--brd);border-radius:9999px;padding:.3rem .75rem;white-space:nowrap;min-width:0}
.wdot{width:7px;height:7px;border-radius:50%;transition:.3s;flex-shrink:0}
.wdot.c{background:var(--grn);box-shadow:0 0 6px var(--grn)}
.wdot.a{background:var(--pur)}
.wdot.d{background:var(--brd2)}
.upd-badge{display:none;align-items:center;gap:.4rem;font-size:.78rem;font-weight:600;background:rgba(251,191,36,.12);border:1px solid rgba(251,191,36,.4);color:var(--ylw);border-radius:9999px;padding:.28rem .75rem;cursor:pointer;transition:.15s;white-space:nowrap}
.upd-badge:hover{background:rgba(251,191,36,.22)}
#wifi-label{font-weight:600;color:var(--txt)}
#wifi-ip{color:var(--sub);font-family:monospace;font-size:.76rem}
/* -- Layout -- */
.page{max-width:1120px;margin:0 auto;padding:1.25rem 1.25rem 0;display:grid;gap:1rem;grid-template-columns:1fr}
@media(min-width:640px){.page{grid-template-columns:1fr 1fr}}
.col2{grid-column:1/-1}
.site-footer{max-width:1120px;margin:1.2rem auto 0;padding:0 1.25rem;color:var(--sub);font-size:.78rem;display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap}
.site-footer a{color:var(--sub);text-decoration:none;transition:color .15s}
.site-footer a:hover{color:var(--txt)}
.site-version{font-family:ui-monospace,monospace;color:var(--brd2)}
/* -- Cards -- */
.card{background:var(--surf);border:1px solid var(--brd);border-radius:var(--r2);overflow:hidden}
.card-head{display:flex;align-items:center;gap:.6rem;padding:.75rem 1.1rem;border-bottom:1px solid var(--brd);background:var(--surf2)}
.card-icon{width:28px;height:28px;border-radius:.45rem;display:flex;align-items:center;justify-content:center;font-size:.95rem;flex-shrink:0}
.card-icon.blue{background:rgba(79,142,247,.15);color:var(--acc)}
.card-icon.green{background:rgba(52,211,153,.15);color:var(--grn)}
.card-icon.pur{background:rgba(167,139,250,.15);color:var(--pur)}
.card-icon.ylw{background:rgba(251,191,36,.15);color:var(--ylw)}
.card-icon.red{background:rgba(248,113,113,.15);color:var(--red)}
.card-title{font-size:.82rem;font-weight:700;letter-spacing:.03em;color:var(--txt)}
.card-sub{font-size:.74rem;color:var(--sub);margin-left:auto}
.card-body{padding:1.1rem}
/* -- Buttons -- */
.btn{display:inline-flex;align-items:center;gap:.35rem;padding:.42rem .9rem;border:1px solid var(--brd2);border-radius:.45rem;background:transparent;color:var(--txt);font-size:.83rem;font-weight:500;cursor:pointer;transition:all .15s;white-space:nowrap;line-height:1.2}
.btn:hover{border-color:var(--acc);color:var(--acc);background:rgba(79,142,247,.07)}
.btn.pri{background:var(--acc);border-color:var(--acc);color:#fff}
.btn.pri:hover{background:var(--acc2);border-color:var(--acc2)}
.btn.grn{background:var(--grn2);border-color:var(--grn2);color:#fff}
.btn.grn:hover{background:#0ea472}
.btn.danger{border-color:var(--red);color:var(--red)}
.btn.danger:hover{background:rgba(248,113,113,.08)}
.btn.ghost{border-color:transparent;color:var(--sub)}
.btn.ghost:hover{color:var(--txt);border-color:var(--brd2)}
.btn.sm{padding:.25rem .6rem;font-size:.76rem}
.btn:disabled{opacity:.35;cursor:default;pointer-events:none}
.btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.85rem}
/* -- Progress -- */
.prog-track{height:5px;background:var(--bg);border-radius:9999px;overflow:hidden;margin:.65rem 0 .3rem}
.prog-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--grn));border-radius:9999px;transition:width .5s ease}
.prog-fill.err{background:var(--red)}
.prog-fill.done{background:var(--grn)}
.meta-row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-top:.35rem}
.pill{display:inline-flex;align-items:center;gap:.3rem;font-size:.74rem;padding:.18rem .55rem;border-radius:9999px;border:1px solid var(--brd2);color:var(--sub);background:var(--surf2)}
.pill.acc{border-color:rgba(79,142,247,.4);color:var(--acc);background:rgba(79,142,247,.08)}
.pill.grn{border-color:rgba(52,211,153,.4);color:var(--grn);background:rgba(52,211,153,.08)}
.pill.red{border-color:rgba(248,113,113,.4);color:var(--red);background:rgba(248,113,113,.08)}
/* -- Status text -- */
.st-headline{font-size:1.05rem;font-weight:700;letter-spacing:-.01em}
.st-run{color:var(--acc)}.st-ok{color:var(--grn)}.st-err{color:var(--red)}.st-idle{color:var(--sub)}
/* -- Form fields -- */
.field{margin-bottom:.85rem}
.field:last-child{margin-bottom:0}
.field label{display:block;font-size:.76rem;font-weight:600;color:var(--sub);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.35rem}
.field input,.field select,.field textarea{width:100%;padding:.48rem .7rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.45rem;color:var(--txt);font-size:.87rem;transition:border-color .15s}
.field input:focus,.field select,.field textarea:focus{outline:none;border-color:var(--acc)}
.field textarea{resize:vertical;font-family:ui-monospace,monospace}
.tog{display:flex;align-items:center;gap:.55rem;margin-bottom:.7rem;cursor:pointer;user-select:none;font-size:.87rem;color:var(--txt)}
.tog input{accent-color:var(--acc);width:16px;height:16px;cursor:pointer;flex-shrink:0}
.tog span{line-height:1.35}
.flash{font-size:.78rem;min-height:1rem;padding:.2rem 0}
.flash.ok{color:var(--grn)}.flash.err{color:var(--red)}.flash.warn{color:#f4a332}
/* -- Port Slots -- */
/* port-pair: immer echtes 1fr 1fr, unabhängig vom Explorer */
.port-pair{display:grid;grid-template-columns:1fr 1fr;gap:.85rem;align-items:start}
@media(max-width:500px){.port-pair{grid-template-columns:1fr}}
.port-slot{border:1.5px solid var(--brd);border-radius:var(--r);padding:.85rem;transition:border-color .2s}
.port-slot.src-on{border-color:var(--grn2)}
.port-slot.dst-on{border-color:var(--acc)}
.role-tag{display:inline-flex;align-items:center;gap:.3rem;font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;padding:.14rem .45rem;border-radius:9999px;margin-bottom:.6rem}
.role-tag.src{background:rgba(52,211,153,.12);color:var(--grn)}
.role-tag.dst{background:rgba(79,142,247,.12);color:var(--acc)}
.port-display{display:flex;align-items:center;gap:.6rem;padding:.6rem .75rem;background:var(--bg2);border-radius:.5rem;margin-bottom:.75rem}
.dot{width:9px;height:9px;border-radius:50%;flex-shrink:0;transition:.3s}
.dot.on{background:var(--grn);box-shadow:0 0 7px var(--grn)}
.dot.off{background:var(--brd2)}
.port-path{font-family:ui-monospace,monospace;font-size:.92rem;font-weight:700;line-height:1.2}
.port-info{font-size:.72rem;color:var(--sub);margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hint-box{font-size:.72rem;color:var(--sub);margin-top:.65rem;padding:.45rem .65rem;background:var(--bg2);border-radius:.4rem;border-left:3px solid var(--brd2);line-height:1.5}
.qr-box{display:none;margin-top:.75rem;padding:.65rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem}
.qr-row{display:flex;gap:.75rem;align-items:center}
.qr-row canvas{width:112px;height:112px;background:#fff;border-radius:.35rem;padding:.35rem;flex-shrink:0}
.qr-title{font-size:.83rem;font-weight:700}
.qr-url{font-family:ui-monospace,monospace;font-size:.76rem;color:var(--acc);margin-top:.2rem;word-break:break-all}
.qr-sub{font-size:.72rem;color:var(--sub);margin-top:.25rem;line-height:1.4}
@media(max-width:430px){.qr-row{align-items:flex-start}.qr-row canvas{width:96px;height:96px}}
/* -- Port+Explorer grid -- */
/* pex-grid: port-pair links, explorer rechts */
.pex-grid{display:grid;gap:.85rem;grid-template-columns:1fr}
@media(min-width:960px){.pex-grid{grid-template-columns:1fr auto}.pex-grid .expl-wrap{width:320px}}
.expl-wrap{border:1px solid var(--brd);border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column}
/* -- File Explorer -- */
.expl-bar{display:flex;align-items:stretch;gap:.4rem;padding:.45rem .8rem;background:var(--surf2);border-bottom:1px solid var(--brd);flex-shrink:0}
.etab{display:inline-flex;align-items:center;padding:0 .6rem;border-radius:.35rem;font-size:.76rem;font-weight:600;cursor:pointer;border:1px solid transparent;color:var(--sub);transition:.15s;white-space:nowrap}
.etab.on{background:var(--acc);color:#fff;border-color:var(--acc)}
.etab:hover:not(.on){border-color:var(--brd2);color:var(--txt)}
.expl-reload{margin-left:auto;display:inline-flex;align-items:center;background:transparent;border:1px solid transparent;color:var(--sub);border-radius:.35rem;padding:0 .45rem;cursor:pointer;font-size:.9rem;transition:.15s}
.expl-reload:hover{color:var(--txt);border-color:var(--brd2)}
.expl-bread{display:flex;align-items:center;gap:.2rem;flex-wrap:wrap;padding:.4rem .8rem;background:var(--bg2);border-bottom:1px solid var(--brd);font-size:.74rem;min-height:30px;flex-shrink:0}
.bseg{cursor:pointer;color:var(--acc)}
.bseg:hover{text-decoration:underline}
.bsep{color:var(--brd2)}
.expl-scroll{overflow-y:auto;max-height:360px;flex:1}
@media(min-width:960px){.expl-scroll{max-height:410px}}
.expl-row{display:grid;grid-template-columns:1.5rem 1fr auto auto;align-items:center;gap:.4rem;padding:.36rem .75rem;border-bottom:1px solid rgba(42,54,80,.7);font-size:.81rem;transition:background .1s}
.expl-row:last-child{border-bottom:none}
.expl-row.dir{cursor:pointer}
.expl-row.dir:hover{background:rgba(79,142,247,.06)}
.expl-row.up{cursor:pointer;color:var(--sub)}
.expl-row.up:hover{background:rgba(139,154,181,.05)}
.expl-ico{text-align:center;font-size:.95rem}
.expl-nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.expl-row.dir .expl-nm{font-weight:500}
.expl-sz{font-family:monospace;font-size:.71rem;color:var(--sub);text-align:right;white-space:nowrap}
.expl-dt{font-size:.69rem;color:var(--brd2);white-space:nowrap}
@media(max-width:360px){.expl-dt{display:none}}
.expl-empty{padding:1.5rem;text-align:center;color:var(--sub);font-size:.84rem}
/* -- Log -- */
.log-wrap{font-family:ui-monospace,monospace;font-size:.75rem;max-height:300px;overflow-y:auto;background:var(--bg2);border-radius:.45rem;padding:.5rem}
.si-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.6rem;margin-bottom:.6rem}
.si-item{background:var(--bg2);border-radius:.45rem;padding:.55rem .7rem}
.si-label{font-size:.7rem;color:var(--sub);text-transform:uppercase;letter-spacing:.04em;margin-bottom:.2rem}
.si-val{font-size:1.05rem;font-weight:700;color:var(--txt)}
.si-sub{font-size:.7rem;color:var(--sub);margin-top:.1rem}
.si-bar{height:4px;background:var(--brd);border-radius:9999px;margin-top:.35rem;overflow:hidden}
.si-fill{height:100%;border-radius:9999px;transition:width .5s}
.si-fill.ok{background:var(--grn2)}.si-fill.warn{background:var(--ylw)}.si-fill.hot{background:var(--red)}
.hist-table{width:100%;border-collapse:collapse;font-size:.8rem}
.hist-table th{text-align:left;padding:.35rem .6rem;color:var(--sub);font-weight:600;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--brd);white-space:nowrap}
.hist-table td{padding:.42rem .6rem;border-bottom:1px solid var(--brd);vertical-align:middle}
.hist-table tr:last-child td{border-bottom:none}
.hist-table tr:hover td{background:var(--bg2)}
.hist-ok{color:var(--grn);font-weight:700}.hist-err{color:var(--red);font-weight:700}
.log-row{display:flex;gap:.5rem;padding:.18rem 0;border-bottom:1px solid rgba(42,54,80,.5)}
.log-row:last-child{border-bottom:none}
.log-t{color:var(--brd2);flex-shrink:0}
.log-m{color:var(--sub)}
/* -- Upload targets -- */
.ut-row{display:flex;align-items:center;gap:.55rem;padding:.6rem .75rem;border:1px solid var(--brd);border-radius:.5rem;transition:.15s}
.ut-row.on{border-color:rgba(79,142,247,.35);background:rgba(79,142,247,.04)}
.ut-ico{font-size:1.1rem;flex-shrink:0}
.ut-nm{font-weight:600;font-size:.86rem}
.ut-meta{font-size:.72rem;color:var(--sub)}
.ut-acts{display:flex;gap:.3rem;margin-left:auto;flex-shrink:0}
.add-panel{border:1px solid var(--brd);border-radius:.6rem;padding:.9rem;margin-top:.75rem;background:var(--bg2)}
/* -- Tabs (WiFi) -- */
.tab-strip{display:flex;gap:.25rem;margin-bottom:.9rem;border-bottom:1px solid var(--brd);padding-bottom:.6rem}
.tab{padding:.28rem .7rem;border-radius:.35rem;font-size:.8rem;font-weight:500;cursor:pointer;color:var(--sub);transition:.15s;border:1px solid transparent}
.tab.on{background:var(--acc);color:#fff;border-color:var(--acc)}
.tab:hover:not(.on){border-color:var(--brd2);color:var(--txt)}
.tpane{display:none}.tpane.on{display:block}
.net-list{display:flex;flex-direction:column;gap:.3rem;max-height:200px;overflow-y:auto;margin-top:.5rem}
.net-row{display:flex;align-items:center;gap:.5rem;padding:.32rem .55rem;border:1px solid var(--brd);border-radius:.4rem;cursor:pointer;font-size:.82rem;transition:.15s}
.net-row:hover{border-color:var(--acc);background:rgba(79,142,247,.06)}
.net-sig{font-size:.7rem;color:var(--sub);margin-left:auto}
/* -- Divider -- */
.sec{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--sub);padding:.1rem 0;margin:.7rem 0 .5rem;display:flex;align-items:center;gap:.5rem}
.sec::after{content:'';flex:1;height:1px;background:var(--brd)}
.empty{color:var(--sub);font-size:.85rem;padding:.3rem 0}
/* -- WireGuard VPN -- */
.wdot.vpn{background:var(--pur);box-shadow:0 0 6px var(--pur)}
</style>
</head>
<body>
<!-- Topbar -->
<header class="topbar">
<div class="logo">
<img src="/logo.png" alt="PiCopy" class="logo-img">
PiCopy
</div>
<div id="upd-badge" class="upd-badge" onclick="installUpdate()" title="Klicken zum Installieren">
&#8593; <span id="upd-version"></span> verfügbar
</div>
<div class="topbar-right">
<div class="topbar-wifi">
<div class="wdot d" id="wdot"></div>
<span id="wifi-label">Verbinde...</span>
<span id="wifi-ip"></span>
</div>
<div id="vpn-pill" class="topbar-wifi" style="display:none">
<div class="wdot d" id="vpn-dot"></div>
<span id="vpn-label" style="font-weight:600;color:var(--txt)">VPN</span>
<span id="vpn-ip" style="color:var(--sub);font-family:monospace;font-size:.76rem"></span>
</div>
</div>
</header>
<main class="page">
<!-- -- Kopierstatus -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon blue"></div>
<span class="card-title">Kopierstatus</span>
<span class="card-sub" id="st-time"></span>
<button id="st-dismiss" onclick="dismissStatus()" title="Meldung schließen" style="display:none;margin-left:.5rem;background:transparent;border:1px solid var(--brd2);color:var(--sub);border-radius:.35rem;padding:.18rem .45rem;cursor:pointer;font-size:.8rem;line-height:1;transition:.15s" onmouseover="this.style.color='var(--txt)'" onmouseout="this.style.color='var(--sub)'"></button>
</div>
<div class="card-body">
<div class="st-headline st-idle" id="st-text">Bereit</div>
<div id="prog-wrap" style="display:none">
<div class="prog-track"><div class="prog-fill" id="prog-fill" style="width:0%"></div></div>
<div class="meta-row">
<span class="pill acc" id="prog-pct" style="display:none"></span>
<span class="pill" id="prog-files" style="display:none"></span>
<span class="pill" id="prog-bytes" style="display:none"></span>
<span class="pill acc" id="eta-pill" style="display:none"></span>
<span class="pill" id="speed-pill" style="display:none"></span>
</div>
<div id="cur-file" style="font-size:.74rem;color:var(--sub);margin-top:.3rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace"></div>
</div>
<div id="st-summary" style="font-size:.81rem;color:var(--sub);margin-top:.4rem"></div>
<!-- Upload-Status -->
<div id="upload-block" style="display:none;margin-top:.75rem;padding:.65rem .85rem;background:var(--bg2);border-radius:.5rem;border:1px solid var(--brd)">
<div class="sec" style="margin-top:0">Fernkopie</div>
<div id="upload-current" style="font-size:.83rem;color:var(--acc)"></div>
<div id="upload-prog" style="display:none;margin-top:.45rem">
<div class="prog-track"><div class="prog-fill" id="upload-fill" style="width:0%"></div></div>
<div class="meta-row">
<span class="pill acc" id="upload-pct"></span>
<span class="pill" id="upload-files"></span>
<span class="pill" id="upload-bytes"></span>
<span class="pill acc" id="upload-eta" style="display:none"></span>
<span class="pill" id="upload-speed" style="display:none"></span>
</div>
<div id="upload-file" style="font-size:.74rem;color:var(--sub);margin-top:.3rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace"></div>
</div>
<div id="upload-results" style="margin-top:.3rem;display:flex;flex-direction:column;gap:.2rem"></div>
</div>
<div class="btn-row">
<button id="btn-start" class="btn pri" onclick="startCopy()">&nbsp;Kopieren starten</button>
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">&nbsp;Abbrechen</button>
<button class="btn ghost" onclick="refreshDevices()">&#8635;&nbsp;Geräte neu laden</button>
</div>
<div id="copy-hint" class="flash" style="display:none"></div>
</div>
</div>
<!-- -- USB Ports + Explorer -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon green">&#8644;</div>
<span class="card-title">USB Ports &amp; Datei-Explorer</span>
<button class="btn sm ghost danger" style="margin-left:auto" onclick="resetPorts()">&#8635;&nbsp;Ports zurücksetzen</button>
</div>
<div class="card-body">
<div class="pex-grid">
<!-- Quelle + Ziel in eigenem gleichbreiten Grid -->
<div class="port-pair">
<!-- Quellen (dynamisch) -->
<div id="slot-src">
<div id="sources-list"></div>
<div style="margin-top:.6rem">
<div class="role-tag src" style="margin-bottom:.5rem">+ Quelle hinzufügen</div>
<div class="field">
<label>Port lernen - Gerät wählen</label>
<select id="src-select">
<option value="">- Gerät einstecken, dann hier wählen -</option>
</select>
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="src-label" placeholder="z.B. Kamera 1 / linker Port">
</div>
<button class="btn grn" style="width:100%" onclick="addSource()">+&nbsp;Quelle hinzufügen</button>
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box">Gerät einstecken &rarr; aus Liste wählen &rarr; Hinzufügen. Mehrere Quellen werden nacheinander auf dasselbe Ziel kopiert.</div>
</div>
</div>
<!-- Ziel -->
<div class="port-slot" id="slot-dst">
<div class="role-tag dst">▼ Ziel</div>
<div class="port-display">
<div class="dot off" id="dst-dot"></div>
<div style="min-width:0">
<div class="port-path" id="dst-port-path">-</div>
<div class="port-info" id="dst-dev-info">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="dst-label" placeholder="z.B. Zielplatte oder Interner Speicher">
</div>
<div class="field">
<label>Zieltyp</label>
<select id="dst-type" onchange="onDestTypeChange()">
<option value="usb">USB-Laufwerk</option>
<option value="internal">Interner Speicher vom Raspberry Pi</option>
</select>
</div>
<div class="field" id="dst-usb-field">
<label>Port lernen - Gerät wählen</label>
<select id="dst-select">
<option value="">- Gerät einstecken, dann hier wählen -</option>
</select>
</div>
<div style="display:flex;gap:.5rem">
<button class="btn pri" style="flex:1" onclick="assignPort('dest')">&nbsp;Als festes Ziel speichern</button>
<button class="btn" id="fmt-toggle-btn" style="flex:0 0 auto;display:none" onclick="toggleFmtBox()" title="Laufwerk formatieren">&#128290;&nbsp;Formatieren</button>
</div>
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box" id="dst-hint">Gerät in den gewünschten Port &rarr; aus Liste wählen &rarr; Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
<!-- Formatieren -->
<div id="fmt-box" style="display:none;margin-top:.75rem;padding:.7rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem">
<div style="font-weight:700;font-size:.83rem;margin-bottom:.55rem">&#128290;&nbsp;Laufwerk formatieren</div>
<div class="field">
<label>Dateisystem</label>
<select id="fmt-fs">
<option value="exfat">exFAT empfohlen (Mac &amp; Windows, keine 4-GB-Grenze)</option>
<option value="fat32">FAT32 Mac &amp; Windows, max. 4 GB pro Datei</option>
<option value="ntfs">NTFS Windows nativ, Mac nur lesen</option>
</select>
</div>
<div class="field">
<label>Bezeichnung (Volume-Name)</label>
<input type="text" id="fmt-name" value="PICOPY" maxlength="32" style="text-transform:uppercase">
</div>
<div style="background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.35);border-radius:.4rem;padding:.45rem .6rem;font-size:.75rem;color:var(--red);margin-bottom:.55rem">
&#9888;&nbsp;<strong>Achtung:</strong> Alle Daten auf dem Laufwerk werden unwiderruflich gelöscht!
</div>
<button class="btn" style="width:100%;background:rgba(248,113,113,.15);border-color:var(--red);color:var(--red)" onclick="startFormat()">Jetzt formatieren</button>
<div id="fmt-flash" class="flash" style="margin-top:.4rem"></div>
</div>
<div id="internal-share-box" style="display:none;margin-top:.75rem;padding:.65rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem">
<div style="display:flex;align-items:center;gap:.55rem;justify-content:space-between">
<div style="min-width:0">
<div style="font-weight:700;font-size:.83rem">SMB-Freigabe</div>
<div id="internal-share-detail" style="font-size:.72rem;color:var(--sub);margin-top:.15rem"></div>
</div>
<button class="btn sm" id="internal-share-btn" onclick="toggleInternalShare()">Freigeben</button>
</div>
<div id="internal-share-flash" class="flash" style="margin-top:.35rem"></div>
</div>
</div>
</div><!-- /port-pair -->
<!-- File Explorer -->
<div class="expl-wrap">
<div class="expl-bar">
<div id="src-tabs" style="display:contents"></div>
<button class="etab" id="etab-dst" onclick="expl.switchRole('dst')">⬇ Ziel</button>
<button class="expl-reload" onclick="expl.reload()" title="Neu laden">&#8635;</button>
</div>
<div class="expl-bread" id="expl-bread"></div>
<div class="expl-scroll" id="expl-body">
<div class="expl-empty">Port konfigurieren und Gerät verbinden</div>
</div>
</div>
</div>
<!-- Nicht zugewiesene Geräte -->
<div id="unassigned-wrap" style="display:none;margin-top:.85rem">
<div class="sec">Weitere verbundene Geräte</div>
<div id="unassigned-list" style="display:flex;flex-direction:column;gap:.35rem"></div>
</div>
</div>
</div>
<!-- -- Kopier-Einstellungen -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon ylw"></div>
<span class="card-title">Kopier-Einstellungen</span>
</div>
<div class="card-body" style="display:grid;grid-template-columns:1fr 1fr;gap:0 2rem">
<!-- Linke Spalte: Ordner & Auto -->
<div>
<div class="sec" style="margin-top:0">Ordnerstruktur</div>
<div class="field">
<label>Datumsformat</label>
<select id="c-fmt">
<option value="%Y-%m-%d">JJJJ-MM-TT &nbsp;(2024-01-15)</option>
<option value="%Y%m%d">JJJJMMTT &nbsp;(20240115)</option>
<option value="%d-%m-%Y">TT-MM-JJJJ &nbsp;(15-01-2024)</option>
<option value="%Y/%m/%d">JJJJ/MM/TT &nbsp;(Unterordner)</option>
</select>
</div>
<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-auto"><span>Automatisch kopieren</span></label>
<div class="sec">Dateifilter</div>
<div class="field">
<label>Nur diese Typen kopieren (leer = alle)</label>
<input type="text" id="c-filter" placeholder="jpg, raw, mp4, mov ...">
</div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap;margin-top:-.35rem;margin-bottom:.85rem">
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,png')">📷 Fotos</button>
<button class="btn sm ghost" onclick="setFilter('mp4,mov,avi,mkv,mts,m2ts,wmv')">🎬 Videos</button>
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,mp4,mov,mts,m2ts')">📷+🎬</button>
<button class="btn sm ghost" onclick="setFilter('')">✕ Alle</button>
</div>
<label class="tog"><input type="checkbox" id="c-excl"><span>Systemdateien ausschließen<br><span style="font-size:.72rem;color:var(--sub)">.DS_Store, Thumbs.db, RECYCLER, System Volume Information ...</span></span></label>
</div>
<!-- Rechte Spalte: Duplikate & Sicherheit -->
<div>
<div class="sec" style="margin-top:0">Duplikate</div>
<div class="field">
<label>Wenn Zieldatei bereits existiert</label>
<select id="c-dup">
<option value="skip">Überspringen (empfohlen)</option>
<option value="overwrite">Überschreiben</option>
<option value="rename">Umbenennen &nbsp;(_1, _2 ...)</option>
</select>
</div>
<div class="sec">Integrität &amp; Aufräumen</div>
<label class="tog" style="margin-bottom:.85rem">
<input type="checkbox" id="c-verify">
<span>Dateien nach Kopieren per MD5 verifizieren<br>
<span style="font-size:.72rem;color:var(--sub)">Stellt sicher dass jede Datei identisch ankam - dauert länger</span></span>
</label>
<label class="tog">
<input type="checkbox" id="c-delsrc">
<span style="color:var(--ylw)">⚠ Quelldateien nach Kopieren löschen<br>
<span style="font-size:.72rem;color:var(--sub)">Löscht Dateien von der Quelle nach erfolgreichem Kopieren (bei Verify: nur verifizierte)</span></span>
</label>
</div>
<!-- Speichern-Zeile über beide Spalten -->
<div style="grid-column:1/-1;margin-top:.25rem">
<div class="btn-row" style="margin-top:0">
<button class="btn pri" onclick="saveCopyCfg()">&nbsp;Speichern</button>
<div id="copy-cfg-msg" class="flash ok" style="display:none;align-self:center">Gespeichert!</div>
</div>
</div>
</div>
</div>
<!-- -- Upload-Ziele -- -->
<div class="card">
<div class="card-head">
<div class="card-icon pur">^</div>
<span class="card-title">Fernkopie - NAS / SMB</span>
</div>
<div class="card-body">
<div id="ut-list" style="display:flex;flex-direction:column;gap:.45rem;margin-bottom:.65rem"></div>
<button class="btn" onclick="utToggleForm()" id="ut-add-btn">+&nbsp;NAS-Ziel hinzufügen</button>
<div id="ut-form" class="add-panel" style="display:none">
<!-- Schritt 1: Verbindungsdaten -->
<div id="ut-step1">
<div class="sec" style="margin-top:0">Schritt 1 Server-Verbindung</div>
<div class="field"><label>Server (IP / Hostname)</label>
<input type="text" id="ut-host" placeholder="192.168.1.100 oder nas.local" autocomplete="off"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div class="field"><label>Benutzer</label>
<input type="text" id="ut-user" placeholder="(leer = anonym)" autocomplete="off"></div>
<div class="field"><label>Passwort</label>
<input type="password" id="ut-pass" placeholder="(leer = kein Passwort)" autocomplete="off"></div>
</div>
<div class="btn-row">
<button class="btn pri" id="ut-connect-btn" onclick="utConnect()">&#128279;&nbsp;Verbinden &amp; Freigaben laden</button>
<button class="btn ghost" onclick="utToggleForm()">Abbrechen</button>
</div>
</div>
<!-- Schritt 2: Freigabe wählen (nach erfolgreicher Verbindung) -->
<div id="ut-step2" style="display:none">
<div class="sec" style="margin-top:0">Schritt 2 Freigabe &amp; Details</div>
<div class="field"><label>Freigabe wählen</label>
<select id="ut-share-sel" style="width:100%"></select></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div class="field"><label>Name (Anzeigename)</label>
<input type="text" id="ut-name" placeholder="z.B. Heimserver NAS"></div>
<div class="field"><label>Ziel-Ordner auf dem NAS</label>
<input type="text" id="ut-dest" placeholder="PiCopy" value="PiCopy"></div>
</div>
<div class="btn-row">
<button class="btn pri" onclick="utSave()">&nbsp;Speichern &amp; Verbindung testen</button>
<button class="btn ghost" onclick="utBack()">&#8592;&nbsp;Zurück</button>
</div>
</div>
<div id="ut-form-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- -- WiFi + Log nebeneinander -- -->
<div class="card">
<div class="card-head">
<div class="card-icon acc" style="background:rgba(79,142,247,.15);color:var(--acc)"></div>
<span class="card-title">WiFi-Einstellungen</span>
</div>
<div class="card-body">
<div class="tab-strip">
<div class="tab on" data-tab="tc" onclick="swTab('tc','ta')">Heimnetz</div>
<div class="tab" data-tab="ta" onclick="swTab('ta','tc')">Hotspot (AP)</div>
</div>
<div id="tc" class="tpane on">
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.75rem;line-height:1.5">Heimnetz für die Router-Verbindung. Ohne Verbindung startet PiCopy automatisch einen eigenen Hotspot.</div>
<div class="field">
<label>Netzwerk (SSID)</label>
<div style="display:flex;gap:.4rem">
<input type="text" id="w-ssid" placeholder="WLAN-Name" style="flex:1">
<button class="btn ghost" onclick="scanNets()" title="Netzwerke suchen">🔍</button>
</div>
</div>
<div id="net-list" class="net-list" style="display:none"></div>
<div class="field"><label>Passwort</label><input type="password" id="w-pw" placeholder="WLAN-Passwort"></div>
<button class="btn pri" onclick="connectWifi()">🔌&nbsp;Verbinden &amp; Speichern</button>
<div id="wifi-flash" class="flash" style="margin-top:.4rem"></div>
</div>
<div id="ta" class="tpane">
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.75rem;line-height:1.5">Startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP im Hotspot-Modus: <b style="color:var(--txt)">10.42.0.1:8080</b></div>
<div id="hotspot-qr-box" class="qr-box">
<div class="qr-row">
<canvas id="hotspot-qr" width="116" height="116"></canvas>
<div>
<div class="qr-title">Direkt öffnen</div>
<div class="qr-url" id="hotspot-qr-url">http://10.42.0.1:8080</div>
<div class="qr-sub">Im PiCopy-Hotspot mit dem Handy scannen und die Oberfläche öffnen.</div>
</div>
</div>
</div>
<div class="field"><label>Hotspot-Name (SSID)</label><input type="text" id="ap-ssid" placeholder="PiCopy"></div>
<div class="field"><label>Passwort (min. 8 Zeichen)</label>
<div style="display:flex;gap:.4rem">
<input type="password" id="ap-pw" placeholder="PiCopy," style="flex:1">
<button type="button" class="btn sm ghost" id="ap-pw-toggle" onclick="togglePwVis('ap-pw','ap-pw-toggle')" style="flex-shrink:0;line-height:0"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="12" rx="6" ry="4"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg></button>
</div>
</div>
<button class="btn pri" onclick="saveAP()">&nbsp;Speichern &amp; Neustart</button>
<div id="ap-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- -- WireGuard VPN -- -->
<div class="card">
<div class="card-head">
<div class="card-icon pur"></div>
<span class="card-title">WireGuard VPN</span>
<span class="card-sub" id="wg-status-sub"></span>
</div>
<div class="card-body">
<!-- Paket nicht installiert -->
<div id="wg-not-installed" style="display:none">
<div style="display:flex;align-items:center;gap:.75rem;padding:.75rem .9rem;background:rgba(251,191,36,.07);border:1px solid rgba(251,191,36,.28);border-radius:.5rem;margin-bottom:.6rem">
<span style="font-size:1.3rem;flex-shrink:0">📦</span>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:.87rem;color:var(--ylw)">WireGuard nicht installiert</div>
<div style="font-size:.76rem;color:var(--sub);margin-top:.15rem">wireguard + wireguard-tools + openresolv werden per apt-get installiert</div>
</div>
<button class="btn pri" onclick="wgInstall()" style="flex-shrink:0">Installieren</button>
</div>
</div>
<!-- Paketoperation läuft -->
<div id="wg-pkg-progress" style="display:none">
<div style="display:flex;align-items:center;gap:.75rem;padding:.75rem .9rem;background:rgba(79,142,247,.07);border:1px solid rgba(79,142,247,.25);border-radius:.5rem;margin-bottom:.6rem">
<span style="font-size:1.3rem;flex-shrink:0" id="wg-pkg-icon"></span>
<div>
<div style="font-weight:600;font-size:.87rem" id="wg-pkg-title">Installiere WireGuard...</div>
<div style="font-size:.76rem;color:var(--sub);margin-top:.1rem">apt-get läuft bitte warten (bis 60 s)</div>
</div>
</div>
</div>
<!-- Hauptbereich (wenn installiert) -->
<div id="wg-installed-ui" style="display:none">
<!-- Verbindungsstatus -->
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.85rem;padding:.65rem .85rem;background:var(--bg2);border-radius:.5rem;border:1px solid var(--brd)">
<div id="wg-dot" class="wdot d"></div>
<div style="flex:1;min-width:0">
<div id="wg-label" style="font-weight:600;font-size:.87rem">Getrennt</div>
<div id="wg-detail" style="font-size:.74rem;color:var(--sub);font-family:monospace;margin-top:.1rem"></div>
</div>
<button id="wg-btn-connect" class="btn grn sm" onclick="wgConnect()" style="display:none">&nbsp;Verbinden</button>
<button id="wg-btn-disconnect" class="btn danger sm" onclick="wgDisconnect()" style="display:none">&nbsp;Trennen</button>
</div>
<!-- Konfiguration -->
<div class="sec" style="margin-top:0">Konfiguration</div>
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.65rem;line-height:1.5">
WireGuard .conf einfügen - wird als <code style="background:var(--bg2);padding:.1rem .3rem;border-radius:.25rem">/etc/wireguard/picopy.conf</code> gespeichert (Permissions 600). Der private Schlüssel wird maskiert angezeigt.
</div>
<div class="field">
<label>WireGuard Konfiguration (.conf)</label>
<textarea id="wg-config" rows="9" placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = 10.x.x.x/32&#10;DNS = ...&#10;&#10;[Peer]&#10;PublicKey = ...&#10;Endpoint = mein-nas.dyndns.org:51820&#10;AllowedIPs = 192.168.1.0/24" style="font-family:ui-monospace,monospace;font-size:.77rem;line-height:1.6"></textarea>
</div>
<label class="tog"><input type="checkbox" id="wg-auto"><span>Beim Start automatisch verbinden</span></label>
<div class="btn-row">
<button class="btn pri" onclick="wgSaveConfig()">&nbsp;Konfiguration speichern</button>
<button class="btn danger" onclick="wgUninstall()" style="margin-left:auto" title="wireguard-Paket entfernen">&nbsp;Deinstallieren</button>
</div>
</div>
<div id="wg-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
<!-- -- System -- -->
<div class="card">
<div class="card-head">
<div class="card-icon" style="background:rgba(255,180,60,.1);color:#f4a332"></div>
<span class="card-title">System</span>
</div>
<div class="card-body" style="display:flex;flex-direction:column;gap:.6rem">
<div class="si-grid" id="sysinfo-grid">
<div class="si-item">
<div class="si-label">CPU-Temp</div>
<div class="si-val" id="si-temp">--</div>
<div class="si-sub" id="si-temp-sub">&nbsp;</div>
</div>
<div class="si-item">
<div class="si-label">RAM</div>
<div class="si-val" id="si-ram">--</div>
<div class="si-bar"><div class="si-fill ok" id="si-ram-bar" style="width:0%"></div></div>
</div>
<div class="si-item">
<div class="si-label">SD-Karte</div>
<div class="si-val" id="si-disk">--</div>
<div class="si-bar"><div class="si-fill ok" id="si-disk-bar" style="width:0%"></div></div>
</div>
</div>
<div id="storage-list" style="display:flex;flex-direction:column;gap:.4rem"></div>
<button class="btn" style="width:100%" onclick="checkUpdate()">🔍&nbsp;Nach Update suchen</button>
<div id="sys-update-flash" class="flash" style="display:none"></div>
<button class="btn" style="width:100%;background:rgba(220,60,60,.12);color:#e05555;border-color:rgba(220,60,60,.25)" onclick="rebootDevice()">&#8634;&nbsp;Gerät neu starten</button>
</div>
</div>
<!-- -- Kopier-Verlauf -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon" style="background:rgba(79,142,247,.1);color:var(--acc)">📋</div>
<span class="card-title">Kopier-Verlauf</span>
<button class="btn sm ghost danger" style="margin-left:auto" onclick="clearHistory()">&nbsp;Löschen</button>
</div>
<div class="card-body" style="padding:.5rem .75rem">
<div id="history-wrap"><div class="expl-empty" style="padding:.75rem 0">Noch keine Kopiervorgänge gespeichert.</div></div>
</div>
</div>
<!-- -- Logs -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon" style="background:rgba(139,154,181,.1);color:var(--sub)">=</div>
<span class="card-title">Logs</span>
</div>
<div class="card-body" style="padding:.65rem .85rem">
<div id="log-box" class="log-wrap"><div class="expl-empty">Noch keine Einträge</div></div>
<div id="space-warn-box" style="display:none;margin-top:.6rem;padding:.55rem .75rem;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.4);border-radius:.45rem">
<div style="font-size:.82rem;font-weight:700;color:var(--ylw)">&#9888;&nbsp;Nicht genug Speicherplatz</div>
<div id="space-warn-detail" style="font-size:.76rem;color:var(--sub);margin-top:.25rem;line-height:1.5"></div>
</div>
</div>
</div>
</main>
<footer class="site-footer">
<a href="https://leuschner.dev" target="_blank" rel="noopener">© 2026 Made with ❤ by Tobias Leuschner</a>
<span class="site-version">Version v{{ version }}</span>
</footer>
<script>
let cfg = {}, devs = [];
const $ = id => document.getElementById(id);
const api = async (p, m='GET', b=null) => {
const o={method:m,headers:{'Content-Type':'application/json'}};
if(b) o.body=JSON.stringify(b);
return (await fetch('/api'+p,o)).json();
};
function drawHotspotQR(){
const url='http://10.42.0.1:8080';
const c=$('hotspot-qr'); if(!c)return;
$('hotspot-qr-url').textContent=url;
drawQR(c,url);
}
function drawQR(canvas,text){
const version=2,size=25,dataCw=34,ecCw=10;
const bytes=[...new TextEncoder().encode(text)];
const bits=[];
const put=(val,len)=>{for(let i=len-1;i>=0;i--)bits.push((val>>>i)&1);};
put(4,4); put(bytes.length,8); bytes.forEach(b=>put(b,8)); put(0,Math.min(4,dataCw*8-bits.length));
while(bits.length%8)bits.push(0);
const data=[];
for(let i=0;i<bits.length;i+=8)data.push(bits.slice(i,i+8).reduce((a,b)=>a*2+b,0));
for(let p=0xec;data.length<dataCw;p=p===0xec?0x11:0xec)data.push(p);
const gfMul=(x,y)=>{let z=0;for(let i=7;i>=0;i--){z=(z<<1)^((z>>>7)*0x11d);if((y>>>i)&1)z^=x;}return z&255;};
const gen=Array(ecCw).fill(0); gen[ecCw-1]=1;
for(let i=0,root=1;i<ecCw;i++,root=gfMul(root,2)){
for(let j=0;j<ecCw;j++){
gen[j]=gfMul(gen[j],root);
if(j+1<ecCw)gen[j]^=gen[j+1];
}
}
const rem=Array(ecCw).fill(0);
data.forEach(d=>{
const factor=d^rem.shift(); rem.push(0);
for(let i=0;i<ecCw;i++)rem[i]^=gfMul(gen[i],factor);
});
const code=data.concat(rem);
const m=Array.from({length:size},()=>Array(size).fill(null));
const set=(x,y,v)=>{if(x>=0&&y>=0&&x<size&&y<size)m[y][x]=v;};
const finder=(x,y)=>{
for(let dy=-1;dy<=7;dy++)for(let dx=-1;dx<=7;dx++){
const xx=x+dx,yy=y+dy;
const on=(dx>=0&&dx<=6&&dy>=0&&dy<=6&&(dx===0||dx===6||dy===0||dy===6||(dx>=2&&dx<=4&&dy>=2&&dy<=4)));
set(xx,yy,on?1:0);
}
};
finder(0,0); finder(size-7,0); finder(0,size-7);
for(let i=8;i<size-8;i++){set(i,6,i%2?0:1);set(6,i,i%2?0:1);}
for(let y=16;y<=20;y++)for(let x=16;x<=20;x++)set(x,y,(x===16||x===20||y===16||y===20||(x===18&&y===18))?1:0);
set(8,size-8,1);
for(let i=0;i<9;i++){if(m[8][i]===null)set(8,i,0);if(m[i][8]===null)set(i,8,0);}
for(let i=0;i<8;i++){if(m[8][size-1-i]===null)set(8,size-1-i,0);if(m[size-1-i][8]===null)set(size-1-i,8,0);}
const dataBits=code.flatMap(b=>Array.from({length:8},(_,i)=>(b>>>(7-i))&1));
let bi=0,up=true;
for(let x=size-1;x>0;x-=2){
if(x===6)x--;
for(let yi=0;yi<size;yi++){
const y=up?size-1-yi:yi;
for(let dx=0;dx<2;dx++){
const xx=x-dx;
if(m[y][xx]!==null)continue;
const mask=(x+y)%2===0;
m[y][xx]=(dataBits[bi++]||0)^(mask?1:0);
}
}
up=!up;
}
const fmt=qrFormatBits(1,0);
for(let i=0;i<=5;i++)set(8,i,(fmt>>i)&1); set(8,7,(fmt>>6)&1); set(8,8,(fmt>>7)&1); set(7,8,(fmt>>8)&1);
for(let i=9;i<15;i++)set(14-i,8,(fmt>>i)&1);
for(let i=0;i<8;i++)set(size-1-i,8,(fmt>>i)&1);
for(let i=8;i<15;i++)set(8,size-15+i,(fmt>>i)&1);
const ctx=canvas.getContext('2d'),scale=Math.floor(canvas.width/(size+8)),off=Math.floor((canvas.width-scale*size)/2);
ctx.fillStyle='#fff';ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.fillStyle='#0a0f1e';
for(let y=0;y<size;y++)for(let x=0;x<size;x++)if(m[y][x])ctx.fillRect(off+x*scale,off+y*scale,scale,scale);
}
function qrFormatBits(ecl,mask){
let data=(ecl<<3)|mask, rem=data;
for(let i=0;i<10;i++)rem=(rem<<1)^(((rem>>>9)&1)?0x537:0);
return ((data<<10)|rem)^0x5412;
}
// -- Tabs ---------------------------------------------------------------------
function swTab(show,hide){
$(show).classList.add('on'); $(hide).classList.remove('on');
document.querySelectorAll('.tab').forEach(tab=>
tab.classList.toggle('on', tab.dataset.tab===show)
);
}
// -- Port Slots ----------------------------------------------------------------
async function refreshDevices(){
devs = await api('/devices');
renderSources();
renderSlot('dst', cfg.dest_port, cfg.dest_label);
renderUnassigned();
populateSel();
}
let selectedPortSet = new Set();
function renderSources(){
const ports = cfg.source_ports || [];
$('sources-list').innerHTML = ports.map((sp, i) => {
const dev = devs.find(d => d.usb_port === sp.port);
const info = dev
? (dev.label||dev.device) + (dev.size ? ' | '+dev.size : '')
: 'Gerät nicht verbunden';
const chk = selectedPortSet.has(sp.port) ? 'checked' : '';
return `<div class="port-slot ${dev?'src-on':''}" style="margin-bottom:.5rem">
<div class="role-tag src">&#9650; Quelle ${i+1}${sp.label?' '+sp.label:''}</div>
<div class="port-display">
<div class="dot ${dev?'on':'off'}"></div>
<div style="min-width:0">
<div class="port-path">Port ${sp.port}</div>
<div class="port-info">${info}</div>
</div>
<label style="margin-left:auto;display:flex;align-items:center;gap:.3rem;font-size:.76rem;cursor:pointer;flex-shrink:0;white-space:nowrap">
<input type="checkbox" ${chk} onchange="toggleSrc('${sp.port}',this.checked)">
${'Kopieren'}
</label>
<button class="btn sm danger" style="margin-left:.4rem;flex-shrink:0"
onclick="removeSource('${sp.port}')">&#10005;</button>
</div>
</div>`;
}).join('') + (ports.length === 0
? '<div style="color:var(--sub);font-size:.83rem;margin-bottom:.5rem">'+'Noch keine Quelle konfiguriert.'+'</div>'
: '');
renderExplorerTabs();
}
function toggleSrc(port, on){
if(on) selectedPortSet.add(port); else selectedPortSet.delete(port);
}
function renderExplorerTabs(){
const ports = cfg.source_ports || [];
let tabs = ports.map((sp, i) => {
const r = 'src_'+i;
const label = sp.label || ('Quelle '+(i+1));
return `<button class="etab ${expl.role===r?'on':''}" id="etab-${r}"
onclick="expl.switchRole('${r}')">&#9650; ${label}</button>`;
}).join('');
if((cfg.dest_type||'usb')==='internal'){
tabs += `<button class="etab ${expl.role==='dst'?'on':''}" id="etab-dst"
onclick="expl.switchRole('dst')">&#9660; Intern</button>`;
}
$('src-tabs').innerHTML = tabs;
// Fallback: falls aktive Rolle nicht mehr existiert
if(expl.role!=='dst' && !ports.some((_,i)=>expl.role==='src_'+i)){
expl.role = ports.length>0 ? 'src_0' : 'dst';
}
}
function renderSlot(r, port, label){
if(r==='dst' && (cfg.dest_type||'usb')==='internal'){
const dot=$('dst-dot'), pp=$('dst-port-path'), pi=$('dst-dev-info');
const sl=$('slot-dst'), lb=$('dst-label');
sl.classList.add('dst-on');
dot.className='dot on';
pp.textContent='Interner Speicher';
pi.textContent=(label||cfg.internal_dest_label||'Interner Speicher')+' | /opt/picopy/internal';
if(lb && !lb.dataset.dirty) lb.value=label||cfg.internal_dest_label||'Interner Speicher';
return;
}
const dev=devs.find(d=>d.usb_port===port);
const dot=$(r+'-dot'), pp=$(r+'-port-path'), pi=$(r+'-dev-info');
const sl=$('slot-'+r), lb=$(r+'-label');
sl.classList.toggle('dst-on', !!port);
if(port){
pp.textContent='Port '+port+(label?' | '+label:'');
if(dev){ dot.className='dot on'; pi.textContent=(dev.label||dev.device)+(dev.size?' | '+dev.size:'')+(dev.mount?' | '+dev.mount:''); }
else { dot.className='dot off'; pi.textContent='Gerät nicht verbunden'; }
} else {
dot.className='dot off'; pp.textContent='-'; pi.textContent='Kein Port konfiguriert';
}
if(lb && !lb.dataset.dirty) lb.value=label||'';
}
function populateSel(){
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
const mkOpts = filter => devs.filter(filter)
.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`)
.join('');
const blank = v => `<option value="">- ${v} -</option>`;
const srcEl=$('src-select'), srcPrev=srcEl.value;
srcEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
+ mkOpts(d => !srcSet.has(d.usb_port) && ((cfg.dest_type||'usb')==='internal' || !cfg.dest_port || d.usb_port !== cfg.dest_port));
if(srcPrev && devs.find(d=>d.usb_port===srcPrev)) srcEl.value=srcPrev;
const dstEl=$('dst-select'), dstPrev=dstEl.value;
dstEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
+ mkOpts(d => !srcSet.has(d.usb_port));
if(dstPrev && devs.find(d=>d.usb_port===dstPrev)) dstEl.value=dstPrev;
dstEl.onchange=updateFmtToggleBtn;
updateFmtToggleBtn();
}
function onDestTypeChange(markDirty=true){
const type=$('dst-type').value;
$('dst-usb-field').style.display=type==='internal'?'none':'';
$('internal-share-box').style.display=type==='internal'?'block':'none';
$('dst-hint').textContent=type==='internal'
? 'Kopiert auf /opt/picopy/internal. Die Daten können optional als SMB-Freigabe im Netzwerk bereitgestellt werden.'
: 'Gerät in den gewünschten Port → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.';
if(type==='internal' && !$('dst-label').value) $('dst-label').value='Interner Speicher';
if(markDirty) $('dst-label').dataset.dirty='1';
updateInternalShareBox();
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderExplorerTabs();
updateFmtToggleBtn();
if(type==='internal'){$('fmt-box').style.display='none';}
}
function renderUnassigned(){
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&((cfg.dest_type||'usb')==='internal'||!cfg.dest_port||d.usb_port!==cfg.dest_port));
const w=$('unassigned-wrap');
if(!list.length){w.style.display='none';return;}
w.style.display='block';
$('unassigned-list').innerHTML=list.map(d=>`
<div style="display:flex;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg2);border-radius:.45rem;font-size:.83rem">
<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(--sub);font-size:.73rem">${d.device} | Port ${d.usb_port||'?'} | ${d.size}</span>
</div>`).join('');
}
async function addSource(){
const port=$('src-select').value, label=$('src-label').value.trim();
if(!port){flash('src-flash','err','Bitte zuerst ein Gerät wählen.');return;}
if((cfg.dest_type||'usb')!=='internal' && port===cfg.dest_port){flash('src-flash','err','Port bereits als Ziel konfiguriert!');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err','Port bereits als Quelle hinzugefügt!');return;}
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
selectedPortSet.add(port);
await api('/config','POST',cfg);
$('src-label').value='';
flash('src-flash','ok','✓ '+'Quelle Port ${p} hinzugefügt.'.replace('${p}',port));
renderSources(); populateSel(); renderUnassigned();
}
async function resetPorts(){
if(!confirm('Alle Port-Zuweisungen (Quellen & Ziel) zurücksetzen?'))return;
await api('/config/ports/reset','POST');
cfg.source_ports=[]; cfg.dest_port=null; cfg.dest_label=''; cfg.dest_type='usb';
selectedPortSet.clear();
renderSources(); renderSlot('dst',null,''); populateSel(); renderUnassigned();
renderExplorerTabs(); expl.role='dst'; expl.load('');
}
async function removeSource(port){
cfg.source_ports = (cfg.source_ports||[]).filter(sp=>sp.port!==port);
selectedPortSet.delete(port);
await api('/config','POST',cfg);
renderSources(); populateSel(); renderUnassigned();
}
async function assignPort(role){
const sid='dst-select', lid='dst-label';
const fid='dst-flash', pk='dest_port', lk='dest_label';
const type=$('dst-type').value;
const port=$(sid).value, label=$(lid).value.trim();
if(type==='internal'){
cfg.dest_type='internal';
cfg.internal_dest_label=label||'Interner Speicher';
cfg[lk]=cfg.internal_dest_label;
$(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok','✓ '+'Interner Speicher als Ziel gespeichert.');
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderExplorerTabs(); expl.reload();
return;
}
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err','Port bereits als Quelle konfiguriert!');return;}
cfg.dest_type='usb';
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok','✓ '+'Port ${p} als Ziel gespeichert.'.replace('${p}',port));
renderSlot('dst',cfg.dest_port,cfg.dest_label);
populateSel(); renderUnassigned();
}
['dst-label'].forEach(id=>window.addEventListener('DOMContentLoaded',()=>{
const el=$(id); if(el) el.addEventListener('input',()=>el.dataset.dirty='1');
}));
// -- Copy ----------------------------------------------------------------------
async function startCopy(){
_dismissed=false;
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
const ports=[...(cfg.source_ports||[]).map(sp=>sp.port).filter(p=>selectedPortSet.has(p))];
if(!ports.length){flash('copy-hint','warn','Keine Quelle ausgewählt bitte mindestens eine Quelle anhaken.');return;}
const r=await api('/copy/start','POST',{ports});
if(r.error) flash('copy-hint','warn',r.error);
else $('copy-hint').style.display='none';
}
async function cancelCopy(){ await api('/copy/cancel','POST'); }
// -- Config --------------------------------------------------------------------
async function loadCfg(){
cfg=await api('/config');
// Migration: altes source_port-Feld -> source_ports-Array
if(!cfg.source_ports) cfg.source_ports=[];
if(cfg.source_ports.length===0 && cfg.source_port)
cfg.source_ports=[{port:cfg.source_port, label:cfg.source_label||''}];
// Alle konfigurierten Quellen standardmäßig ausgewählt
selectedPortSet = new Set(cfg.source_ports.map(sp=>sp.port));
$('c-fmt').value=cfg.folder_format||'%Y-%m-%d';
$('c-time').checked=!!cfg.add_time; $('c-sub').checked=!!cfg.subfolder; $('c-auto').checked=!!cfg.auto_copy;
$('c-filter').value=cfg.file_filter||'';
$('c-excl').checked=cfg.exclude_system!==false;
$('c-dup').value=cfg.duplicate_handling||'skip';
$('c-verify').checked=!!cfg.verify_checksum;
$('c-delsrc').checked=!!cfg.delete_source;
$('w-ssid').value=cfg.wifi_ssid||''; $('ap-ssid').value=cfg.ap_ssid||'PiCopy';
$('ap-pw').value=cfg.ap_password||'';
$('dst-type').value=cfg.dest_type||'usb';
onDestTypeChange(false);
}
async function saveCopyCfg(){
cfg.folder_format=$('c-fmt').value; cfg.add_time=$('c-time').checked;
cfg.subfolder=$('c-sub').checked; cfg.auto_copy=$('c-auto').checked;
cfg.file_filter=$('c-filter').value.trim();
cfg.exclude_system=$('c-excl').checked;
cfg.duplicate_handling=$('c-dup').value;
cfg.verify_checksum=$('c-verify').checked;
cfg.delete_source=$('c-delsrc').checked;
await api('/config','POST',cfg);
const m=$('copy-cfg-msg'); m.textContent='Gespeichert!'; m.style.display='block';
setTimeout(()=>m.style.display='none',2500);
}
function setFilter(v){ $('c-filter').value=v; }
// -- WiFi ----------------------------------------------------------------------
async function scanNets(){
$('net-list').style.display='flex'; $('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Suche...</div>';
const nets=await api('/wifi/scan');
if(!nets.length){$('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Keine Netzwerke</div>';return;}
$('net-list').innerHTML=nets.map(n=>{
const b=n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
return`<div class="net-row" onclick="pickNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-sig">${b} ${n.signal}%</span></div>`;
}).join('');
}
function pickNet(s){$('w-ssid').value=s;$('net-list').style.display='none';$('w-pw').focus();}
function togglePwVis(inputId, btnId){
const inp=$(inputId), btn=$(btnId);
const show = inp.type==='password';
inp.type = show ? 'text' : 'password';
const eye='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="12" rx="6" ry="4"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
const eyeOff='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="12" rx="6" ry="4"/><line x1="3" y1="3" x2="21" y2="21"/></svg>';
btn.innerHTML = show ? eyeOff : eye;
}
async function connectWifi(){
const ssid=$('w-ssid').value.trim(),pw=$('w-pw').value;
if(!ssid){flash('wifi-flash','err','Bitte SSID eingeben');return;}
flash('wifi-flash','ok','Verbinde... (bis 30s)');
const r=await api('/wifi/connect','POST',{ssid,password:pw});
if(r.error) flash('wifi-flash','err',r.error);
else flash('wifi-flash','ok','Gestartet. Neue IP erscheint oben.');
}
async function saveAP(){
const s=$('ap-ssid').value.trim(),p=$('ap-pw').value;
if(!s){flash('ap-flash','err','Name fehlt');return;}
if(p.length<8){flash('ap-flash','err','Min. 8 Zeichen');return;}
const r=await api('/wifi/ap','POST',{ssid:s,password:p});
if(r.error) flash('ap-flash','err',r.error);
else flash('ap-flash','ok','Gespeichert! Hotspot startet neu.');
}
// -- Upload-Ziele --------------------------------------------------------------
let utTargets=[], _utConn={};
async function loadUTs(){utTargets=await api('/upload/targets');renderUTs();}
function renderUTs(){
const el=$('ut-list');
if(!utTargets.length){el.innerHTML='<div class="empty">'+'Noch keine Fernziele konfiguriert'+'</div>';return;}
el.innerHTML=utTargets.map(t=>`
<div class="ut-row ${t.enabled?'on':''}">
<span class="ut-ico">🖧</span>
<div style="flex:1;min-width:0">
<div class="ut-nm">${t.name}</div>
<div class="ut-meta">SMB/NAS | ${(t.smb_share||'?')}${t.dest_path?'/'+t.dest_path:''}</div>
</div>
<div class="ut-acts">
<button class="btn sm ghost" id="ut-test-${t.id}" onclick="utTest('${t.id}')">&#128269; Test</button>
<button class="btn sm ${t.enabled?'grn':'ghost'}" onclick="utToggle('${t.id}')">${t.enabled?'Aktiv':'Inaktiv'}</button>
<button class="btn sm danger" onclick="utDel('${t.id}','${t.name}')">✕</button>
</div>
<div id="ut-test-result-${t.id}" style="display:none;font-size:.76rem;margin-top:.35rem;padding:.3rem .5rem;border-radius:.35rem"></div>
</div>`).join('');
}
function utToggleForm(){
const f=$('ut-form'),b=$('ut-add-btn'),show=f.style.display==='none';
f.style.display=show?'block':'none';
b.innerHTML=show?'✕ Abbrechen':'+ NAS-Ziel hinzufügen';
if(show){
$('ut-step1').style.display=''; $('ut-step2').style.display='none';
['ut-host','ut-user','ut-pass','ut-name'].forEach(id=>{$(id).value='';});
$('ut-dest').value='PiCopy';
$('ut-form-flash').style.display='none';
_utConn={};
}
}
async function utConnect(){
const host=$('ut-host').value.trim();
if(!host){flash('ut-form-flash','err','Server-Adresse fehlt');return;}
const btn=$('ut-connect-btn');
btn.disabled=true; btn.textContent='Verbinde...';
$('ut-form-flash').style.display='none';
const r=await api('/upload/browse','POST',{
host, user:$('ut-user').value.trim(), pass:$('ut-pass').value
});
btn.disabled=false; btn.innerHTML='&#128279;&nbsp;Verbinden &amp; Freigaben laden';
if(r.error){flash('ut-form-flash','err','✗ '+r.error);return;}
if(!r.shares||!r.shares.length){flash('ut-form-flash','warn','Verbunden, aber keine Freigaben gefunden');return;}
_utConn={host, user:$('ut-user').value.trim(), pass:$('ut-pass').value};
$('ut-share-sel').innerHTML=r.shares.map(s=>`<option value="${s}">${s}</option>`).join('');
if(!$('ut-name').value) $('ut-name').value=host;
$('ut-step1').style.display='none'; $('ut-step2').style.display='';
}
function utBack(){
$('ut-step1').style.display=''; $('ut-step2').style.display='none';
$('ut-form-flash').style.display='none';
}
async function utSave(){
const name=$('ut-name').value.trim(), dest=$('ut-dest').value.trim()||'PiCopy';
const share=$('ut-share-sel').value;
if(!name){flash('ut-form-flash','err','Name fehlt');return;}
if(!share){flash('ut-form-flash','err','Bitte eine Freigabe wählen');return;}
const body={type:'smb',name,dest_path:dest,share,
host:_utConn.host, user:_utConn.user, pass:_utConn.pass};
flash('ut-form-flash','warn','Speichere...');
const r=await api('/upload/targets','POST',body);
if(r.error){flash('ut-form-flash','err',r.error);return;}
flash('ut-form-flash','warn','Teste Verbindung Schreibzugriff wird geprüft...');
try{
const tr=await api('/upload/targets/'+r.id+'/test','POST');
if(tr.ok){flash('ut-form-flash','ok','✓ Verbindung OK Lesen & Schreiben erfolgreich');utToggleForm();await loadUTs();}
else flash('ut-form-flash','err','✗ '+(tr.error||'✗ Test fehlgeschlagen (Server-Timeout)'.slice(2)));
}catch(e){flash('ut-form-flash','err','✗ Test fehlgeschlagen (Server-Timeout)');}
}
async function utTest(id){
const btn=$('ut-test-'+id), res=$('ut-test-result-'+id);
btn.disabled=true; btn.textContent='Teste...';
res.style.display='none';
const r=await api('/upload/targets/'+id+'/test','POST');
btn.disabled=false; btn.innerHTML='&#128269; Test';
res.style.display='block';
if(r.ok){
res.style.background='rgba(52,211,153,.12)'; res.style.color='var(--grn)';
res.textContent='✓ Verbindung OK Lesen & Schreiben erfolgreich';
} else {
res.style.background='rgba(248,113,113,.1)'; res.style.color='var(--red)';
res.textContent='✗ ' + (r.error||'✗ Test fehlgeschlagen (Server-Timeout)'.slice(2));
}
}
async function utToggle(id){await api('/upload/targets/'+id+'/toggle','POST');await loadUTs();}
async function utDel(id,name){
if(!confirm('"${n}" wirklich löschen?'.replace('${n}',name)))return;
await api('/upload/targets/'+id,'DELETE');await loadUTs();
}
async function updateInternalShareBox(state=null){
if(!$('internal-share-box'))return;
const s=state||await api('/internal-share/status');
const btn=$('internal-share-btn'), detail=$('internal-share-detail');
const free=s.free!=null?fmtBytes(s.free)+' frei':'';
if(s.pkg_running){
btn.disabled=true; btn.textContent='Installiere...';
detail.textContent='Samba wird installiert. '+free;
return;
}
btn.disabled=false;
btn.textContent=s.enabled?'Freigabe stoppen':'Freigeben';
const status=s.enabled
? ((s.active?'Aktiv':'Konfiguriert')+' | \\\\'+(location.hostname||'picopy')+'\\PiCopy')
: 'Nicht freigegeben';
detail.textContent=status+(free?' | '+free:'');
}
async function toggleInternalShare(){
const current=await api('/internal-share/status');
const enable=!current.enabled;
if(enable && !current.installed){
if(!confirm('Samba installieren und /opt/picopy/internal als Netzwerkfreigabe PiCopy bereitstellen?\n\nDie Freigabe ist im Netzwerk lesbar erreichbar.'))return;
}
flash('internal-share-flash','ok',enable?'Aktiviere Freigabe...':'Deaktiviere Freigabe...');
const r=await api('/internal-share','POST',{enabled:enable});
if(r.error){flash('internal-share-flash','err',r.error);return;}
flash('internal-share-flash','ok',enable?'✓ Freigabe aktiv':'✓ Freigabe deaktiviert');
updateInternalShareBox(r.status);
}
// -- Format -------------------------------------------------------------------
let fmtPolling=null;
function toggleFmtBox(){
const box=$('fmt-box');
const visible=box.style.display!=='none';
box.style.display=visible?'none':'block';
if(!visible) $('fmt-flash').textContent='';
}
function updateFmtToggleBtn(){
const btn=$('fmt-toggle-btn');
if(!btn) return;
const sel=$('dst-select').value;
const isUsb=($('dst-type')||{}).value!=='internal';
btn.style.display=(isUsb && sel)?'':'none';
}
async function startFormat(){
const sel=$('dst-select').value;
if(!sel){flash('fmt-flash','err','Kein Gerät ausgewählt');return;}
const dev=devs.find(d=>d.usb_port===sel);
if(!dev){flash('fmt-flash','err','Gerät nicht gefunden');return;}
const fs=$('fmt-fs').value;
const name=($('fmt-name').value||'PICOPY').toUpperCase();
const fsLabel={'exfat':'exFAT','fat32':'FAT32','ntfs':'NTFS'}[fs]||fs;
if(!confirm(`Laufwerk "${dev.label||dev.device}" (${dev.size}) wirklich mit ${fsLabel} formatieren?\n\nAlle Daten werden gelöscht!`))return;
flash('fmt-flash','ok','Formatierung läuft...');
const r=await api('/format','POST',{fs,name,device:dev.device});
if(r.error){flash('fmt-flash','err',r.error);return;}
clearInterval(fmtPolling);
fmtPolling=setInterval(async()=>{
const s=await api('/format/status');
if(s.error){clearInterval(fmtPolling);flash('fmt-flash','err',s.error);return;}
if(s.done){
clearInterval(fmtPolling);
flash('fmt-flash','ok',`✓ Erfolgreich als ${fsLabel} formatiert`);
setTimeout(pollDevices,1500);
}
},800);
}
// -- File Explorer -------------------------------------------------------------
const expl={
role:'src_0', paths:{dst:''},
switchRole(r){
this.role=r;
document.querySelectorAll('.etab').forEach(t=>t.classList.remove('on'));
const tab=$('etab-'+r); if(tab) tab.classList.add('on');
this.load(this.paths[r]||'');
},
reload(){this.load(this.paths[this.role]||'');},
navigate(p){this.load(p);},
async load(path=''){
let port;
if(this.role==='dst'){
port=(cfg.dest_type||'usb')==='internal'?'__internal__':cfg.dest_port;
} else {
const idx=parseInt(this.role.replace('src_',''),10);
port=cfg.source_ports&&cfg.source_ports[idx]?cfg.source_ports[idx].port:null;
}
const body=$('expl-body'), bread=$('expl-bread');
if(!port){body.innerHTML='<div class="expl-empty">'+'Kein Port konfiguriert'+'</div>';bread.innerHTML='';return;}
const dev=port==='__internal__'
? {usb_port:'__internal__',label:'Interner Speicher',device:'internal'}
: devs.find(d=>d.usb_port===port);
if(!dev){body.innerHTML='<div class="expl-empty">'+'Gerät nicht verbunden'+'</div>';bread.innerHTML='<span style="color:var(--sub)">Port '+port+'</span>';return;}
body.innerHTML='<div class="expl-empty">'+'Lade...'+'</div>';
try{
const data=await api('/browse?port='+encodeURIComponent(port)+'&path='+encodeURIComponent(path));
if(data.error){body.innerHTML='<div class="expl-empty">⚠ '+data.error+'</div>';return;}
this.paths[this.role]=data.path||''; // role z.B. 'src_0', 'dst'
this._bread(data.path||'',dev.label||dev.device);
this._list(data.entries||[],data.path||'');
}catch(e){body.innerHTML='<div class="expl-empty">'+'Verbindungsfehler'+'</div>';}
},
_bread(path,label){
const el=$('expl-bread');
let h=`<span class="bseg" onclick="expl.navigate('')" title="${label}">⌂ ${label}</span>`;
if(path){
let acc='';
path.split('/').filter(Boolean).forEach(p=>{
acc+=(acc?'/':'')+p;const a=acc;
h+=`<span class="bsep"> > </span><span class="bseg" onclick="expl.navigate('${a.replace(/'/g,"\\'")}')">${p}</span>`;
});
}
el.innerHTML=h;
},
_list(entries,cur){
const body=$('expl-body');
let h='';
if(cur){
const par=cur.includes('/')?cur.substring(0,cur.lastIndexOf('/')):'';
h+=`<div class="expl-row up" onclick="expl.navigate('${par}')"><span class="expl-ico"><-</span><span class="expl-nm" style="color:var(--sub)">..</span><span></span><span></span></div>`;
}
if(!entries.length&&!cur){body.innerHTML='<div class="expl-empty">'+'Laufwerk leer'+'</div>';return;}
if(!entries.length){body.innerHTML=h+'<div class="expl-empty">'+'Ordner leer'+'</div>';return;}
entries.forEach(e=>{
const ico=e.dir?'📁':fileIcon(e.name);
const np=(cur?cur+'/':'')+e.name;
const click=e.dir?`onclick="expl.navigate('${np.replace(/'/g,"\\'")}')" `:'';
h+=`<div class="expl-row ${e.dir?'dir':''}" ${click}>
<span class="expl-ico">${ico}</span>
<span class="expl-nm">${e.name}</span>
<span class="expl-sz">${e.size!=null?fmtBytes(e.size):''}</span>
<span class="expl-dt">${e.mtime||''}</span>
</div>`;
});
body.innerHTML=h;
}
};
function fileIcon(n){
const e=(n.split('.').pop()||'').toLowerCase();
if(['jpg','jpeg','png','gif','bmp','raw','cr2','nef','arw','heic','webp','dng'].includes(e))return'🖼';
if(['mp4','mov','avi','mkv','mts','m2ts','wmv','3gp'].includes(e))return'🎬';
if(['mp3','wav','flac','aac','m4a','ogg'].includes(e))return'🎵';
if(['pdf','doc','docx','txt','xls','xlsx'].includes(e))return'📄';
if(['zip','rar','7z','tar','gz'].includes(e))return'🗜';
return'📄';
}
function fmtBytes(b){
if(b==null)return'';
if(b===0)return'0 B';
if(b<1024)return b+' B';
if(b<1048576)return(b/1024).toFixed(1)+' KB';
if(b<1073741824)return(b/1048576).toFixed(1)+' MB';
return(b/1073741824).toFixed(2)+' GB';
}
function fmtETA(s){
if(!s||s<=0)return'';
if(s<60)return'<1 Min.';
const m=Math.round(s/60);
if(m<60)return'~'+m+' Min.';
const h=Math.floor(m/60),rm=m%60;
return'~'+h+'h'+(rm?' '+rm+'m':'');
}
function fmtSpd(bps){
if(!bps||bps<=0)return'';
if(bps<1048576)return(bps/1024).toFixed(0)+' KB/s';
return(bps/1048576).toFixed(1)+' MB/s';
}
// -- Poll ----------------------------------------------------------------------
async function poll(){
try{
const {copy:c,wifi:w,vpn:v,internal_share:is}=await api('/status');
if(is) updateInternalShareBox(is);
// VPN Topbar + Card
if(v){
const vp=$('vpn-pill'),vdot=$('vpn-dot'),vl=$('vpn-label'),vi=$('vpn-ip');
const ni=$('wg-not-installed'),pp=$('wg-pkg-progress'),ui=$('wg-installed-ui');
if(v.pkg_running){
ni.style.display='none'; pp.style.display='block'; ui.style.display='none';
const act=t(v.pkg_action==='remove'?'wg.action_remove':'wg.action_install');
$('wg-pkg-title').textContent=act+' WireGuard...';
$('wg-pkg-icon').textContent='⏳';
$('wg-status-sub').textContent=act+'...';
vp.style.display='none';
} else if(!v.installed){
ni.style.display='block'; pp.style.display='none'; ui.style.display='none';
$('wg-status-sub').textContent='Nicht installiert'||'Nicht installiert';
vp.style.display='none';
if(v.pkg_error) flash('wg-flash','err',v.pkg_error);
} else {
ni.style.display='none'; pp.style.display='none'; ui.style.display='block';
const wgd=$('wg-dot'),wgl=$('wg-label'),wgdet=$('wg-detail');
const bc=$('wg-btn-connect'),bd=$('wg-btn-disconnect');
if(v.connected){
vp.style.display='flex'; vdot.className='wdot c';
vl.textContent='VPN aktiv'; vi.textContent=v.ip||'';
wgd.className='wdot c'; wgl.textContent='Verbunden';
wgdet.textContent=v.ip?(v.ip+(v.peer?' | peer ...'+v.peer.slice(-8):'')):'';
bc.style.display='none'; bd.style.display=''; bd.disabled=false;
$('wg-status-sub').textContent=v.ip||'';
} else {
vp.style.display=v.has_config?'flex':'none';
vdot.className='wdot d'; vl.textContent='VPN'; vi.textContent='';
wgd.className='wdot d'; wgl.textContent='Getrennt';
wgdet.textContent=v.error||'';
bc.style.display=v.has_config?'':'none'; bc.disabled=false; bd.style.display='none';
$('wg-status-sub').textContent=v.has_config?'Konfiguriert':'Nicht konfiguriert';
}
}
}
// WiFi
const wd=$('wdot'),wl=$('wifi-label'),wi=$('wifi-ip');
if(w.mode==='client'){wd.className='wdot c';wl.textContent=w.ssid||'Verbunden';wi.textContent=w.ip||'';}
else if(w.mode==='ap'){wd.className='wdot a';wl.textContent='Hotspot: '+(w.ssid||'PiCopy');wi.textContent='10.42.0.1';}
else{wd.className='wdot d';wl.textContent='Kein WLAN';wi.textContent='';}
const qrBox=$('hotspot-qr-box');
if(qrBox){qrBox.style.display=w.mode==='ap'?'block':'none'; if(w.mode==='ap')drawHotspotQR();}
// Copy
const tx=$('st-text'),pf=$('prog-fill'),pw=$('prog-wrap'),pp=$('prog-pct');
const pfiles=$('prog-files'),pbytes=$('prog-bytes'),eta=$('eta-pill'),spd=$('speed-pill');
const cf=$('cur-file'),sum=$('st-summary'),time=$('st-time');
const bS=$('btn-start'),bC=$('btn-cancel');
if(c.running){
const ph=c.phase||'copy';
if(ph==='verify'){
tx.className='st-headline st-run'; tx.textContent='Verifiziere... '+c.progress+'%';
pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%';
pp.style.display=''; pp.textContent=c.progress+'%';
pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' geprüft';
pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none';
cf.textContent=c.current||'';
} else if(ph==='delete'){
tx.className='st-headline st-run'; tx.textContent='Quelle wird geleert...';
pw.style.display='none'; pp.style.display='none'; pfiles.style.display='none';
pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none';
cf.textContent='';
} else {
tx.className='st-headline st-run'; tx.textContent='Kopiert... '+c.progress+'%';
pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%';
pp.style.display=''; pp.textContent=c.progress+'%';
pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' Dateien';
if(c.bytes_total>0){pbytes.style.display='';pbytes.textContent=fmtBytes(c.bytes_done)+' / '+fmtBytes(c.bytes_total);}else pbytes.style.display='none';
const e=fmtETA(c.eta_sec); eta.style.display=e?'':'none'; eta.textContent=e?'⏱ '+e:'';
const s=fmtSpd(c.speed_bps); spd.style.display=s?'':'none'; spd.textContent=s?'⚡ '+s:'';
cf.textContent=c.current||'';
}
sum.textContent=''; bS.style.display='none'; bC.style.display=''; time.textContent='';
}else{
bS.style.display=''; bC.style.display='none'; cf.textContent='';
eta.style.display='none'; spd.style.display='none'; pfiles.style.display='none'; pbytes.style.display='none'; pp.style.display='none';
if(c.error){
tx.className='st-headline st-err'; tx.textContent='Fehler: '+c.error;
pf.className='prog-fill err'; pw.style.display='block'; pf.style.width='100%';
sum.textContent=''; time.textContent='';
}else if(c.last_copy && !_dismissed){
if(c.last_copy !== _lastHistoryTs){ _lastHistoryTs=c.last_copy; loadHistory(); }
tx.className='st-headline st-ok'; tx.textContent='✓ Abgeschlossen';
pf.className='prog-fill done'; pw.style.display='block'; pf.style.width='100%';
sum.textContent=c.total+' Dateien'+' | '+fmtBytes(c.bytes_total);
time.textContent=new Date(c.last_copy).toLocaleString('de-DE');
$('st-dismiss').style.display='';
// Auto-dismiss nach 5 Minuten
if(!_autoDismissTimer && c.last_copy){
const age=(Date.now()-new Date(c.last_copy).getTime())/1000;
const remaining=Math.max(0,300-age);
_autoDismissTimer=setTimeout(dismissStatus, remaining*1000);
}
}else{
tx.className='st-headline st-idle'; tx.textContent='Bereit';
pw.style.display='none'; sum.textContent=''; time.textContent='';
$('st-dismiss').style.display='none';
}
}
// Log
if(c.logs&&c.logs.length)
$('log-box').innerHTML=c.logs.slice().reverse().map(l=>`<div class="log-row"><span class="log-t">${l.t}</span><span class="log-m">${l.m}</span></div>`).join('');
// Speicherplatz-Warnung
const swb=$('space-warn-box'),swd=$('space-warn-detail');
if(c.space_warning){
swb.style.display='block';
const needed=fmtBytes(c.space_needed||0), free=fmtBytes(c.space_free||0);
const missing=fmtBytes(Math.max(0,(c.space_needed||0)-(c.space_free||0)));
let detail=`Benötigt: <strong>${needed}</strong> · Verfügbar: <strong>${free}</strong> · Fehlend: <strong>${missing}</strong><br>Quelldateien werden nicht gelöscht.`;
if(c.last_success_file) detail+=`<br>Zuletzt kopiert: <span style="font-family:monospace">${c.last_success_file}</span>`;
swd.innerHTML=detail;
}else{
swb.style.display='none';
}
}catch(e){}
// Upload status
try{
const u=await api('/upload/status');
const ub=$('upload-block');
if(u.running||u.results.length){
ub.style.display='block';
$('upload-current').innerHTML=u.running?'⚡ '+u.current+'...':'';
const up=$('upload-prog'),uf=$('upload-fill');
const pct=Math.max(0,Math.min(100,u.progress||0));
if(u.running){
up.style.display='block'; uf.style.width=pct+'%';
$('upload-pct').textContent=pct+'%';
$('upload-files').textContent=(u.done||0)+' / '+(u.total||0)+' Dateien';
$('upload-bytes').textContent=fmtBytes(u.bytes_done||0)+' / '+fmtBytes(u.bytes_total||0);
const ue=fmtETA(u.eta_sec); $('upload-eta').style.display=ue?'':'none'; $('upload-eta').textContent=ue?'⏱ '+ue:'';
const us=fmtSpd(u.speed_bps); $('upload-speed').style.display=us?'':'none'; $('upload-speed').textContent=us?'⚡ '+us:'';
$('upload-file').textContent=u.current_file||'';
}else{
up.style.display='none';
}
$('upload-results').innerHTML=u.results.map(r=>`<div style="font-size:.79rem;color:${r.ok?'var(--grn)':'var(--red)'}">${r.ok?'✓':'✗'} ${r.name}${r.msg?' - '+r.msg:''}</div>`).join('');
}else ub.style.display='none';
}catch(e){}
}
let _dismissed = false, _autoDismissTimer = null, _lastHistoryTs = null;
function dismissStatus(){
_dismissed = true;
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
$('st-text').className='st-headline st-idle'; $('st-text').textContent='Bereit';
$('prog-wrap').style.display='none';
$('st-summary').textContent=''; $('st-time').textContent='';
$('st-dismiss').style.display='none';
}
// -- Update --------------------------------------------------------------------
async function pollUpdate() {
try {
const u = await api('/update/status');
const badge = $('upd-badge'), vEl = $('upd-version');
if (u.available && u.latest) {
vEl.textContent = 'v' + u.latest;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
} catch(e) {}
}
async function installUpdate() {
const u = await api('/update/status');
const latest = (u.latest || '?');
if (!confirm('Update auf v${v} installieren?\n\nDas Web-Interface ist für ca. 10 Sekunden nicht erreichbar.'.replace('${v}',latest))) return;
$('upd-badge').innerHTML = '&#8595; Installiere...';
$('upd-badge').style.pointerEvents = 'none';
try {
await api('/update/install', 'POST');
} catch(e) {}
// Warte bis der Dienst wieder läuft, dann reload
setTimeout(async function waitForRestart() {
try {
await fetch('/api/update/status');
location.reload();
} catch(e) {
setTimeout(waitForRestart, 2000);
}
}, 5000);
}
async function checkUpdate() {
const btn = event.currentTarget;
btn.disabled = true; btn.innerHTML = '&#128269;&nbsp;'+'Prüfe...';
try {
await api('/update/check', 'POST');
// Warten bis der Server-Check abgeschlossen ist (max 15 s, alle 500 ms)
let u;
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 500));
u = await api('/update/status');
if (!u.checking) break;
}
await pollUpdate(); // Badge sofort aktualisieren
const fl = $('sys-update-flash');
if (u.available && u.latest) {
fl.className = 'flash warn'; fl.textContent = 'Update v${v} verfügbar über das Badge oben installieren.'.replace('${v}',u.latest);
} else if (u.error) {
fl.className = 'flash err'; fl.textContent = 'Fehler: ' + u.error;
} else {
fl.className = 'flash ok'; fl.textContent = 'PiCopy ist aktuell.';
}
fl.style.display = 'block';
if (fl.className.includes('ok')) setTimeout(() => fl.style.display = 'none', 3500);
} catch(e) {
const fl = $('sys-update-flash');
fl.className = 'flash err'; fl.textContent = 'Verbindungsfehler'; fl.style.display = 'block';
} finally {
btn.disabled = false; btn.innerHTML = '🔍&nbsp;'+'Nach Update suchen';
}
}
async function rebootDevice() {
if (!confirm('Gerät jetzt neu starten?\n\nDas Web-Interface ist für ca. 30 Sekunden nicht erreichbar.')) return;
try { await api('/system/reboot', 'POST'); } catch(e) {}
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#888;font-size:1rem">'+'&#8634; Gerät startet neu bitte warten...'+'</div>';
setTimeout(async function waitForRestart() {
try { await fetch('/api/update/status'); location.reload(); }
catch(e) { setTimeout(waitForRestart, 2000); }
}, 10000);
}
// -- WireGuard VPN -------------------------------------------------------------
async function wgInstall(){
if(!confirm('wireguard + wireguard-tools + openresolv jetzt per apt-get installieren?\n\nDauer: ca. 3090 Sekunden.'))return;
flash('wg-flash','ok','Starte Installation...');
const r=await api('/wireguard/install','POST');
if(r.error) flash('wg-flash','err',r.error);
}
async function wgUninstall(){
if(!confirm('WireGuard wirklich deinstallieren?\n\nDer aktive VPN-Tunnel wird vorher getrennt.\nDie Konfigurationsdatei bleibt erhalten.'))return;
flash('wg-flash','ok','Deinstalliere...');
const r=await api('/wireguard/uninstall','POST');
if(r.error) flash('wg-flash','err',r.error);
}
async function wgConnect(){
$('wg-btn-connect').disabled=true;
flash('wg-flash','ok','Verbinde VPN...');
await api('/wireguard/connect','POST');
}
async function wgDisconnect(){
$('wg-btn-disconnect').disabled=true;
flash('wg-flash','ok','Trenne VPN...');
const r=await api('/wireguard/disconnect','POST');
if(!r.ok) flash('wg-flash','err','Trennen fehlgeschlagen');
}
async function wgSaveConfig(){
const content=$('wg-config').value.trim();
if(!content){flash('wg-flash','err','Konfiguration ist leer');return;}
if(!content.includes('[Interface]')){flash('wg-flash','err','[Interface] fehlt');return;}
const auto=$('wg-auto').checked;
flash('wg-flash','ok','Speichere...');
const r=await api('/wireguard/config','POST',{content,auto});
if(r.error){flash('wg-flash','err',r.error);return;}
flash('wg-flash','ok','✓ Konfiguration gespeichert');
}
async function loadWgConfig(){
try{
const r=await api('/wireguard/config');
if(r.exists && r.config) $('wg-config').value=r.config;
const c=await api('/config');
$('wg-auto').checked=!!c.wireguard_auto;
}catch(e){}
}
function flash(id,cls,msg){
const el=$(id); el.className='flash '+cls; el.textContent=msg; el.style.display='block';
if(cls==='ok') setTimeout(()=>el.style.display='none',3500);
}
// -- Sysinfo ------------------------------------------------------------------
async function pollSysinfo(){
try{
const s=await api('/sysinfo');
// CPU-Temp
const tempEl=$('si-temp'), tempSub=$('si-temp-sub');
if(s.cpu_temp!=null){
tempEl.textContent=s.cpu_temp+'°C';
const cls=s.cpu_temp>=80?'hot':s.cpu_temp>=65?'warn':'ok';
tempEl.style.color=cls==='hot'?'var(--red)':cls==='warn'?'var(--ylw)':'var(--grn)';
tempSub.textContent=s.cpu_temp>=80?'Heiß':s.cpu_temp>=65?'Warm':'Normal';
} else { tempEl.textContent='n/v'; tempSub.textContent=''; }
// RAM
if(s.ram_used!=null){
$('si-ram').textContent=s.ram_used+' / '+s.ram_total+' MB';
const rb=$('si-ram-bar'); rb.style.width=s.ram_pct+'%';
rb.className='si-fill '+(s.ram_pct>=90?'hot':s.ram_pct>=70?'warn':'ok');
}
// Disk
if(s.disk_used!=null){
$('si-disk').textContent=s.disk_used+' / '+s.disk_total+' GB';
const db=$('si-disk-bar'); db.style.width=s.disk_pct+'%';
db.className='si-fill '+(s.disk_pct>=90?'hot':s.disk_pct>=75?'warn':'ok');
}
}catch(e){}
loadStorageInfo();
}
// -- Speicher (System-Karte) --------------------------------------------------
async function loadStorageInfo(){
const list=$('storage-list');
if(!list) return;
try{
const items=await api('/storage-info');
if(!items||!items.length){ list.innerHTML=''; return; }
list.innerHTML=items.map(it=>{
const roleColor=it.role==='source'?'var(--grn)':it.role==='dest'?'var(--acc)':'var(--sub)';
const roleIcon=it.role==='source'?'&#9650;':it.role==='dest'?'&#9660;':'&#9632;';
const roleLabel=it.role==='source'?'Quelle':it.role==='dest'?'Ziel':'Gerät';
const portStr=it.port==='__internal__'?'intern':'Port '+it.port;
if(it.total==null){
const fallback=it.size_str?it.size_str:'';
return `<div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .45rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.4rem">
<span style="font-size:.78rem;color:${roleColor};flex-shrink:0">${roleIcon}</span>
<div style="min-width:0;flex:1">
<div style="font-size:.78rem;font-weight:600;color:var(--txt);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.label}</div>
<div style="font-size:.71rem;color:var(--sub)">${roleLabel} · ${portStr} · ${fallback}</div>
</div>
</div>`;
}
const pct=it.pct||0;
const barCls=pct>=90?'hot':pct>=75?'warn':'ok';
const barColor=barCls==='hot'?'var(--red)':barCls==='warn'?'var(--ylw)':'var(--grn)';
return `<div style="padding:.35rem .45rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.4rem">
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.3rem">
<span style="font-size:.78rem;color:${roleColor};flex-shrink:0">${roleIcon}</span>
<div style="min-width:0;flex:1">
<div style="font-size:.78rem;font-weight:600;color:var(--txt);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${it.label}</div>
<div style="font-size:.71rem;color:var(--sub)">${roleLabel} · ${portStr} · ${fmtBytes(it.used)} / ${fmtBytes(it.total)} · ${fmtBytes(it.free)} frei</div>
</div>
<span style="font-size:.74rem;font-weight:700;color:${barColor};flex-shrink:0">${pct}%</span>
</div>
<div class="si-bar" style="margin:0"><div class="si-fill ${barCls}" style="width:${pct}%"></div></div>
</div>`;
}).join('');
}catch(e){}
}
// -- Kopier-Verlauf -----------------------------------------------------------
function fmtDur(s){
if(s<60) return s+'s';
const m=Math.floor(s/60), sec=s%60;
return m+'m'+(sec?sec+'s':'');
}
async function loadHistory(){
try{
const h=await api('/history');
renderHistory(h);
}catch(e){}
}
function renderHistory(h){
const w=$('history-wrap');
if(!h||!h.length){
w.innerHTML='<div class="expl-empty" style="padding:.75rem 0">Noch keine Kopiervorgänge gespeichert.</div>';
return;
}
w.innerHTML=`<table class="hist-table">
<thead><tr>
<th>Datum</th><th>Quellen</th><th>Ziel</th>
<th style="text-align:right">Dateien</th><th style="text-align:right">Größe</th>
<th style="text-align:right">Dauer</th><th>Status</th>
</tr></thead>
<tbody>${h.map(e=>{
const d=new Date(e.ts);
const date=d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
const time=d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
const srcs=(e.sources||[]).join(', ')||'?';
const files=e.copied+(e.skipped?` <span style="color:var(--sub);font-size:.75em">(+${e.skipped} übersp.)</span>`:'');
const size=e.bytes>0?fmtBytes(e.bytes):'--';
const status=e.ok
? '<span class="hist-ok">✓ OK</span>'
: `<span class="hist-err" title="${(e.error||'').replace(/"/g,'&quot;')}">✗ Fehler</span>`;
const io=e.errors?` <span style="color:var(--red);font-size:.75em">${e.errors} I/O-Err.</span>`:'';
return`<tr>
<td><span style="font-weight:600">${date}</span><br><span style="color:var(--sub);font-size:.75em">${time}</span></td>
<td>${srcs}</td>
<td style="color:var(--sub)">${e.dest||'?'}</td>
<td style="text-align:right">${files}${io}</td>
<td style="text-align:right">${size}</td>
<td style="text-align:right">${fmtDur(e.duration||0)}</td>
<td>${status}</td>
</tr>`;
}).join('')}</tbody>
</table>`;
}
async function clearHistory(){
if(!confirm('Kopier-Verlauf wirklich löschen?'))return;
await api('/history','DELETE');
renderHistory([]);
}
(async()=>{
await loadCfg();
await refreshDevices();
await loadUTs();
await loadWgConfig();
expl.load('');
loadHistory();
pollSysinfo();
setInterval(poll,1500);
setInterval(refreshDevices,8000);
setInterval(pollUpdate,60000);
setInterval(pollSysinfo,8000);
poll();
pollUpdate();
setTimeout(pollUpdate, 8000);
})();
</script>
</body>
</html>