1793 lines
88 KiB
HTML
1793 lines
88 KiB
HTML
<!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">
|
||
↑ <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()">▶ Kopieren starten</button>
|
||
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">■ Abbrechen</button>
|
||
<button class="btn ghost" onclick="refreshDevices()">↻ 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">⇄</div>
|
||
<span class="card-title">USB Ports & Datei-Explorer</span>
|
||
<button class="btn sm ghost danger" style="margin-left:auto" onclick="resetPorts()">↻ 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()">+ Quelle hinzufügen</button>
|
||
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
|
||
<div class="hint-box">Gerät einstecken → aus Liste wählen → 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')">✓ Als festes Ziel speichern</button>
|
||
<button class="btn" id="fmt-toggle-btn" style="flex:0 0 auto;display:none" onclick="toggleFmtBox()" title="Laufwerk formatieren">🔢 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 → aus Liste wählen → 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">🔢 Laufwerk formatieren</div>
|
||
<div class="field">
|
||
<label>Dateisystem</label>
|
||
<select id="fmt-fs">
|
||
<option value="exfat">exFAT – empfohlen (Mac & Windows, keine 4-GB-Grenze)</option>
|
||
<option value="fat32">FAT32 – Mac & 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">
|
||
⚠ <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">↻</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 (2024-01-15)</option>
|
||
<option value="%Y%m%d">JJJJMMTT (20240115)</option>
|
||
<option value="%d-%m-%Y">TT-MM-JJJJ (15-01-2024)</option>
|
||
<option value="%Y/%m/%d">JJJJ/MM/TT (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 (_1, _2 ...)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="sec">Integrität & 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()">✓ 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">+ 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()">🔗 Verbinden & 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 & 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()">✓ Speichern & Verbindung testen</button>
|
||
<button class="btn ghost" onclick="utBack()">← 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()">🔌 Verbinden & 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()">✓ Speichern & 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">⚿ Verbinden</button>
|
||
<button id="wg-btn-disconnect" class="btn danger sm" onclick="wgDisconnect()" style="display:none">✕ 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] PrivateKey = ... Address = 10.x.x.x/32 DNS = ... [Peer] PublicKey = ... Endpoint = mein-nas.dyndns.org:51820 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()">✓ Konfiguration speichern</button>
|
||
<button class="btn danger" onclick="wgUninstall()" style="margin-left:auto" title="wireguard-Paket entfernen">✕ 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"> </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()">🔍 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()">↺ 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()">✕ 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)">⚠ 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">▲ 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}')">✕</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}')">▲ ${label}</button>`;
|
||
}).join('');
|
||
if((cfg.dest_type||'usb')==='internal'){
|
||
tabs += `<button class="etab ${expl.role==='dst'?'on':''}" id="etab-dst"
|
||
onclick="expl.switchRole('dst')">▼ 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}')">🔍 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='🔗 Verbinden & 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='🔍 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 = '↓ 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 = '🔍 '+'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 = '🔍 '+'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">'+'↺ 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. 30–90 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'?'▲':it.role==='dest'?'▼':'■';
|
||
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,'"')}">✗ 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> |