Füge Datei-Explorer-Funktionalität hinzu und verbessere das Layout der USB-Port-Konfiguration
This commit is contained in:
473
app.py
473
app.py
@@ -521,7 +521,71 @@ def r_wifi_status():
|
||||
with wifi_lock:
|
||||
return jsonify(dict(wifi_state))
|
||||
|
||||
# ── HTML Template ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Browse (persistente Mounts für File-Explorer) ─────────────────────────────
|
||||
|
||||
_browse_mounts = {} # usb_port -> mount_point
|
||||
|
||||
def get_browse_mp(dev):
|
||||
port = dev.get('usb_port', '')
|
||||
if dev.get('mount'):
|
||||
return dev['mount']
|
||||
mp = _browse_mounts.get(port)
|
||||
if mp and Path(mp).is_dir():
|
||||
return mp
|
||||
mp = f'/mnt/picopy_br_{port}'
|
||||
os.makedirs(mp, exist_ok=True)
|
||||
r = subprocess.run(['mount', dev['device'], mp], capture_output=True)
|
||||
if r.returncode == 0:
|
||||
_browse_mounts[port] = mp
|
||||
return mp
|
||||
return None
|
||||
|
||||
|
||||
@app.route('/api/browse')
|
||||
def r_browse():
|
||||
port = request.args.get('port', '')
|
||||
rpath = request.args.get('path', '').lstrip('/')
|
||||
|
||||
devs = usb_devices()
|
||||
dev = next((d for d in devs if d['usb_port'] == port), None)
|
||||
if not dev:
|
||||
return jsonify(error='Gerät nicht verbunden'), 404
|
||||
|
||||
mp = get_browse_mp(dev)
|
||||
if not mp:
|
||||
return jsonify(error='Gerät nicht mountbar'), 500
|
||||
|
||||
try:
|
||||
base = Path(mp).resolve()
|
||||
target = (base / rpath).resolve()
|
||||
|
||||
if not str(target).startswith(str(base)):
|
||||
return jsonify(error='Ungültiger Pfad'), 400
|
||||
if not target.is_dir():
|
||||
return jsonify(error='Kein Verzeichnis'), 400
|
||||
|
||||
entries = []
|
||||
for item in sorted(target.iterdir(),
|
||||
key=lambda x: (x.is_file(), x.name.lower())):
|
||||
try:
|
||||
s = item.stat()
|
||||
entries.append({
|
||||
'name': item.name,
|
||||
'dir': item.is_dir(),
|
||||
'size': s.st_size if item.is_file() else None,
|
||||
'mtime': datetime.fromtimestamp(s.st_mtime).strftime('%d.%m.%y %H:%M'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rel = str(target.relative_to(base))
|
||||
return jsonify(path='' if rel == '.' else rel, entries=entries)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify(error=str(e)), 500
|
||||
|
||||
|
||||
|
||||
# ── HTML Template ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -537,7 +601,7 @@ HTML = r"""<!DOCTYPE html>
|
||||
body{background:var(--bg);color:var(--txt);font-family:system-ui,sans-serif;padding:1rem 1rem 4rem;min-height:100vh}
|
||||
h1{font-size:1.35rem;font-weight:700;display:flex;align-items:center;gap:.5rem;margin-bottom:1.25rem}
|
||||
h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.8rem}
|
||||
.wrap{max-width:960px;margin:0 auto;display:grid;gap:.85rem;grid-template-columns:1fr}
|
||||
.wrap{max-width:1100px;margin:0 auto;display:grid;gap:.85rem;grid-template-columns:1fr}
|
||||
@media(min-width:600px){.wrap{grid-template-columns:1fr 1fr}}
|
||||
.card{background:var(--s1);border:1px solid var(--brd);border-radius:.8rem;padding:1.1rem}
|
||||
.span2{grid-column:1/-1}
|
||||
@@ -560,7 +624,6 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
|
||||
.field label{display:block;font-size:.81rem;color:var(--mut);margin-bottom:.3rem}
|
||||
.field input,.field select{width:100%;padding:.44rem .65rem;background:var(--bg);border:1px solid var(--brd);border-radius:.4rem;color:var(--txt);font-size:.88rem}
|
||||
.field input:focus,.field select:focus{outline:none;border-color:var(--acc)}
|
||||
.field input[type=password]{letter-spacing:.05em}
|
||||
.tog{display:flex;align-items:center;gap:.5rem;margin-bottom:.65rem;cursor:pointer;user-select:none;font-size:.88rem}
|
||||
.tog input{accent-color:var(--acc);width:16px;height:16px;cursor:pointer}
|
||||
.log-box{font-family:ui-monospace,monospace;font-size:.76rem;max-height:220px;overflow-y:auto}
|
||||
@@ -581,9 +644,8 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
|
||||
.net-signal{font-size:.72rem;color:var(--mut);margin-left:auto}
|
||||
.flash{font-size:.78rem;padding:.25rem 0;min-height:1.2rem}
|
||||
.flash.ok{color:var(--grn)}.flash.err{color:var(--red)}
|
||||
/* Port Slots */
|
||||
.port-grid{display:grid;grid-template-columns:1fr 1fr;gap:.85rem}
|
||||
@media(max-width:599px){.port-grid{grid-template-columns:1fr}}
|
||||
|
||||
/* ── Port Slots ── */
|
||||
.port-slot{border:2px solid var(--brd);border-radius:.7rem;padding:1rem;transition:border-color .2s}
|
||||
.port-slot.has-src{border-color:var(--grn)}
|
||||
.port-slot.has-dst{border-color:var(--acc)}
|
||||
@@ -597,6 +659,42 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
|
||||
.port-dev-name{font-weight:600;font-size:.9rem;line-height:1.3}
|
||||
.port-dev-sub{font-size:.73rem;color:var(--mut);font-family:monospace;margin-top:.1rem}
|
||||
.port-hint{font-size:.73rem;color:var(--mut);margin-top:.65rem;padding:.5rem .65rem;background:var(--bg);border-radius:.4rem;border-left:3px solid var(--brd)}
|
||||
|
||||
/* ── Port + Explorer grid ── */
|
||||
.port-and-expl{display:grid;gap:.85rem;grid-template-columns:1fr 1fr}
|
||||
@media(max-width:599px){.port-and-expl{grid-template-columns:1fr}}
|
||||
@media(min-width:900px){.port-and-expl{grid-template-columns:1fr 1fr 1.3fr}}
|
||||
.expl-col{border:1px solid var(--brd);border-radius:.7rem;overflow:hidden;display:flex;flex-direction:column}
|
||||
@media(max-width:899px){.expl-col{grid-column:1/-1}}
|
||||
@media(min-width:900px){.expl-col{grid-column:3;grid-row:1}}
|
||||
|
||||
/* ── File Explorer ── */
|
||||
.expl-header{padding:.6rem .8rem;background:var(--s2);border-bottom:1px solid var(--brd);display:flex;align-items:center;gap:.4rem;flex-shrink:0}
|
||||
.expl-tab-btn{padding:.25rem .65rem;border-radius:.35rem;font-size:.78rem;cursor:pointer;border:1px solid var(--brd);background:transparent;color:var(--mut);transition:.15s}
|
||||
.expl-tab-btn.active{background:var(--acc);border-color:var(--acc);color:#fff}
|
||||
.expl-tab-btn:hover:not(.active){border-color:var(--acc);color:var(--acc)}
|
||||
.expl-refresh{margin-left:auto;background:transparent;border:1px solid var(--brd);color:var(--mut);border-radius:.35rem;padding:.22rem .5rem;cursor:pointer;font-size:.85rem;transition:.15s}
|
||||
.expl-refresh:hover{border-color:var(--acc);color:var(--acc)}
|
||||
.expl-bread{padding:.45rem .8rem;font-size:.76rem;background:var(--bg);border-bottom:1px solid var(--brd);color:var(--mut);display:flex;align-items:center;gap:.25rem;flex-wrap:wrap;flex-shrink:0;min-height:32px}
|
||||
.bread-seg{cursor:pointer;color:var(--acc);transition:.1s}
|
||||
.bread-seg:hover{text-decoration:underline}
|
||||
.bread-sep{color:var(--brd)}
|
||||
.expl-body{flex:1;overflow-y:auto;max-height:380px}
|
||||
@media(min-width:900px){.expl-body{max-height:420px}}
|
||||
.expl-empty{padding:1.2rem .8rem;color:var(--mut);font-size:.84rem;text-align:center}
|
||||
.expl-item{display:grid;grid-template-columns:1.6rem 1fr auto auto;align-items:center;gap:.35rem;padding:.38rem .75rem;border-bottom:1px solid rgba(51,65,85,.4);font-size:.82rem;transition:background .1s;cursor:default}
|
||||
.expl-item:last-child{border-bottom:none}
|
||||
.expl-item.is-dir{cursor:pointer}
|
||||
.expl-item.is-dir:hover{background:rgba(59,130,246,.07)}
|
||||
.expl-item.is-up{cursor:pointer;color:var(--mut)}
|
||||
.expl-item.is-up:hover{background:rgba(148,163,184,.07)}
|
||||
.expl-icon{font-size:1rem;text-align:center;line-height:1}
|
||||
.expl-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
||||
.expl-item.is-dir .expl-name{font-weight:500}
|
||||
.expl-size{font-size:.72rem;color:var(--mut);text-align:right;white-space:nowrap;font-family:monospace}
|
||||
.expl-date{font-size:.7rem;color:var(--brd);white-space:nowrap;margin-left:.2rem}
|
||||
@media(max-width:400px){.expl-date{display:none}}
|
||||
.expl-arrow{color:var(--brd);font-size:.7rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -633,11 +731,11 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB Port Konfiguration -->
|
||||
<!-- USB Port Konfiguration + File Explorer -->
|
||||
<div class="card span2">
|
||||
<h2>USB Port Konfiguration</h2>
|
||||
<h2>USB Port Konfiguration & Datei-Explorer</h2>
|
||||
|
||||
<div class="port-grid">
|
||||
<div class="port-and-expl">
|
||||
|
||||
<!-- QUELLE -->
|
||||
<div class="port-slot" id="slot-src">
|
||||
@@ -661,7 +759,7 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
|
||||
</div>
|
||||
<button class="btn sec" style="width:100%" onclick="assignPort('source')">✓ Als feste Quelle speichern</button>
|
||||
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
|
||||
<div class="port-hint">Stecke den USB-Stick in den gewünschten Port, wähle ihn hier aus und klicke Speichern. PiCopy merkt sich diesen physischen Port dauerhaft.</div>
|
||||
<div class="port-hint">Gerät einstecken → aus Liste wählen → Speichern. PiCopy merkt sich diesen physischen Port dauerhaft.</div>
|
||||
</div>
|
||||
|
||||
<!-- ZIEL -->
|
||||
@@ -686,12 +784,27 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
|
||||
</div>
|
||||
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">✓ Als festes Ziel speichern</button>
|
||||
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
|
||||
<div class="port-hint">Stecke das Ziel-Laufwerk in den gewünschten Port, wähle es aus und klicke Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
|
||||
<div class="port-hint">Gerät einstecken → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
|
||||
</div>
|
||||
|
||||
<!-- FILE EXPLORER -->
|
||||
<div class="expl-col" id="expl-col">
|
||||
<div class="expl-header">
|
||||
<button class="expl-tab-btn active" id="expl-tab-src" onclick="expl.switchRole('src')">💾 Quelle</button>
|
||||
<button class="expl-tab-btn" id="expl-tab-dst" onclick="expl.switchRole('dst')">💾 Ziel</button>
|
||||
<button class="expl-refresh" onclick="expl.reload()" title="Neu laden">↻</button>
|
||||
</div>
|
||||
<div class="expl-bread" id="expl-bread">
|
||||
<span class="bread-seg" onclick="expl.navigate('')">⌂</span>
|
||||
</div>
|
||||
<div class="expl-body" id="expl-body">
|
||||
<div class="expl-empty">Gerät verbinden und Port konfigurieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Weitere verbundene Geräte (nicht zugewiesen) -->
|
||||
<!-- Nicht zugewiesene Geräte -->
|
||||
<div id="unassigned-wrap" style="margin-top:.85rem;display:none">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.5rem">Weitere verbundene Geräte (noch nicht zugewiesen)</div>
|
||||
<div id="unassigned-list" style="display:flex;flex-direction:column;gap:.35rem"></div>
|
||||
@@ -734,23 +847,14 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
|
||||
</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>
|
||||
<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="tab-ap" class="tab-pane">
|
||||
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">Dieser Hotspot startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP des Pi im Hotspot-Modus: <b>10.42.0.1:8080</b></div>
|
||||
<div class="field">
|
||||
<label>Hotspot-Name (SSID)</label>
|
||||
<input type="text" id="ap-ssid" placeholder="PiCopy">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Hotspot-Passwort (min. 8 Zeichen)</label>
|
||||
<input type="password" id="ap-pw" placeholder="PiCopy,">
|
||||
</div>
|
||||
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">Startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP im Hotspot-Modus: <b>10.42.0.1:8080</b></div>
|
||||
<div class="field"><label>Hotspot-Name (SSID)</label><input type="text" id="ap-ssid" placeholder="PiCopy"></div>
|
||||
<div class="field"><label>Hotspot-Passwort (min. 8 Zeichen)</label><input type="password" id="ap-pw" placeholder="PiCopy,"></div>
|
||||
<button class="btn pri" onclick="saveAP()">✓ Speichern & Neustart</button>
|
||||
<div id="ap-flash" class="flash" style="margin-top:.4rem"></div>
|
||||
</div>
|
||||
@@ -774,9 +878,9 @@ const api = async (path, method='GET', body=null) => {
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
function switchTab(show, hide) {
|
||||
$(show).classList.add('active'); $(hide).classList.remove('active');
|
||||
$(show).classList.add('active'); $(hide).classList.remove('active');
|
||||
document.querySelectorAll('.tab').forEach(t =>
|
||||
t.classList.toggle('active', t.textContent.trim() === (show==='tab-client' ? 'Heimnetz' : 'Hotspot (AP)'))
|
||||
t.classList.toggle('active', t.textContent.trim().startsWith(show==='tab-client'?'Heim':'Hot'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -796,29 +900,23 @@ function renderPortSlots() {
|
||||
function renderSlot(role, port, label) {
|
||||
const isSrc = role === 'src';
|
||||
const dev = devs.find(d => d.usb_port === port);
|
||||
const dot = $(role+'-dot');
|
||||
const nameEl = $(role+'-dev-name');
|
||||
const subEl = $(role+'-dev-sub');
|
||||
const slotEl = $('slot-'+role);
|
||||
const lblEl = $(role+'-label');
|
||||
|
||||
const dot = $(role+'-dot'), nameEl=$(role+'-dev-name'), subEl=$(role+'-dev-sub');
|
||||
const slotEl = $('slot-'+role), lblEl=$(role+'-label');
|
||||
slotEl.classList.toggle('has-src', isSrc && !!port);
|
||||
slotEl.classList.toggle('has-dst', !isSrc && !!port);
|
||||
|
||||
if (dev) {
|
||||
dot.className = 'pdot on';
|
||||
nameEl.textContent = dev.label || dev.device;
|
||||
subEl.textContent = 'Port ' + port + (dev.size ? ' · ' + dev.size : '') + (dev.mount ? ' · ' + dev.mount : '');
|
||||
subEl.textContent = 'Port '+port+(dev.size?' · '+dev.size:'')+(dev.mount?' · '+dev.mount:'');
|
||||
} else if (port) {
|
||||
dot.className = 'pdot off';
|
||||
nameEl.textContent = label || 'Nicht verbunden';
|
||||
subEl.textContent = 'Konfigurierter Port: ' + port + (label ? ' · ' + label : '');
|
||||
subEl.textContent = 'Konfiguriert: Port '+port+(label?' · '+label:'');
|
||||
} else {
|
||||
dot.className = 'pdot off';
|
||||
nameEl.textContent = 'Nicht verbunden';
|
||||
subEl.textContent = 'Kein Port konfiguriert';
|
||||
}
|
||||
|
||||
if (lblEl && !lblEl.dataset.dirty) lblEl.value = label || '';
|
||||
}
|
||||
|
||||
@@ -827,18 +925,18 @@ function populateSelects() {
|
||||
`<option value="${d.usb_port}">${d.label||d.device} — Port ${d.usb_port||'?'} (${d.size})</option>`
|
||||
).join('');
|
||||
['src-select','dst-select'].forEach(id => {
|
||||
const el = $(id), prev = el.value;
|
||||
el.innerHTML = '<option value="">— Gerät einstecken & hier wählen —</option>' + opts;
|
||||
if (prev && devs.find(d => d.usb_port === prev)) el.value = prev;
|
||||
const el=$(id), prev=el.value;
|
||||
el.innerHTML='<option value="">— Gerät einstecken & hier wählen —</option>'+opts;
|
||||
if (prev && devs.find(d => d.usb_port===prev)) el.value=prev;
|
||||
});
|
||||
}
|
||||
|
||||
function renderUnassigned() {
|
||||
const list = devs.filter(d => d.usb_port !== cfg.source_port && d.usb_port !== cfg.dest_port);
|
||||
const list = devs.filter(d => d.usb_port!==cfg.source_port && d.usb_port!==cfg.dest_port);
|
||||
const wrap = $('unassigned-wrap');
|
||||
if (!list.length) { wrap.style.display = 'none'; return; }
|
||||
wrap.style.display = 'block';
|
||||
$('unassigned-list').innerHTML = list.map(d => `
|
||||
if (!list.length) { wrap.style.display='none'; return; }
|
||||
wrap.style.display='block';
|
||||
$('unassigned-list').innerHTML = list.map(d=>`
|
||||
<div style="display:flex;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg);border-radius:.45rem;font-size:.84rem">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:var(--ylw);flex-shrink:0"></div>
|
||||
<span style="font-weight:600">${d.label||d.device}</span>
|
||||
@@ -846,159 +944,214 @@ function renderUnassigned() {
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── Assign port ───────────────────────────────────────────────────────────
|
||||
async function assignPort(role) {
|
||||
const isSrc = role === 'source';
|
||||
const selId = isSrc ? 'src-select' : 'dst-select';
|
||||
const lblId = isSrc ? 'src-label' : 'dst-label';
|
||||
const flashId = isSrc ? 'src-flash' : 'dst-flash';
|
||||
const portKey = isSrc ? 'source_port' : 'dest_port';
|
||||
const labelKey = isSrc ? 'source_label': 'dest_label';
|
||||
const port = $(selId).value;
|
||||
const label = $(lblId).value.trim();
|
||||
|
||||
if (!port) { flash(flashId,'err','Bitte zuerst ein Gerät aus der Liste wählen.'); return; }
|
||||
|
||||
const otherPort = isSrc ? cfg.dest_port : cfg.source_port;
|
||||
if (port === otherPort) {
|
||||
flash(flashId,'err','Dieser Port ist bereits als '+(isSrc?'Ziel':'Quelle')+' konfiguriert!'); return;
|
||||
}
|
||||
|
||||
cfg[portKey] = port;
|
||||
cfg[labelKey] = label;
|
||||
$(lblId).dataset.dirty = '';
|
||||
const isSrc=role==='source', selId=isSrc?'src-select':'dst-select';
|
||||
const lblId=isSrc?'src-label':'dst-label', fId=isSrc?'src-flash':'dst-flash';
|
||||
const port=$(selId).value, label=$(lblId).value.trim();
|
||||
if (!port) { flash(fId,'err','Bitte zuerst ein Gerät wählen.'); return; }
|
||||
const other = isSrc ? cfg.dest_port : cfg.source_port;
|
||||
if (port===other) { flash(fId,'err','Dieser Port ist bereits als '+(isSrc?'Ziel':'Quelle')+' konfiguriert!'); return; }
|
||||
cfg[isSrc?'source_port':'dest_port'] = port;
|
||||
cfg[isSrc?'source_label':'dest_label'] = label;
|
||||
$(lblId).dataset.dirty='';
|
||||
await api('/config','POST',cfg);
|
||||
flash(flashId,'ok','Gespeichert — Port '+port+' ist jetzt feste '+(isSrc?'Quelle':'Ziel')+'.');
|
||||
renderPortSlots();
|
||||
renderUnassigned();
|
||||
flash(fId,'ok','Gespeichert — Port '+port+' ist jetzt feste '+(isSrc?'Quelle':'Ziel')+'.');
|
||||
renderPortSlots(); renderUnassigned();
|
||||
expl.reload();
|
||||
}
|
||||
|
||||
['src-label','dst-label'].forEach(id => {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const el = $(id);
|
||||
if (el) el.addEventListener('input', () => { el.dataset.dirty = '1'; });
|
||||
});
|
||||
});
|
||||
['src-label','dst-label'].forEach(id =>
|
||||
window.addEventListener('DOMContentLoaded',()=>{
|
||||
const el=$(id); if(el) el.addEventListener('input',()=>el.dataset.dirty='1');
|
||||
})
|
||||
);
|
||||
|
||||
// ── Copy ──────────────────────────────────────────────────────────────────
|
||||
async function startCopy() {
|
||||
const r = await api('/copy/start','POST');
|
||||
if (r.error) alert('Fehler: '+r.error);
|
||||
}
|
||||
async function startCopy() { const r=await api('/copy/start','POST'); if(r.error) alert('Fehler: '+r.error); }
|
||||
async function cancelCopy() { await api('/copy/cancel','POST'); }
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────
|
||||
async function loadCfg() {
|
||||
cfg = await api('/config');
|
||||
$('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;
|
||||
$('w-ssid').value = cfg.wifi_ssid || '';
|
||||
$('ap-ssid').value = cfg.ap_ssid || 'PiCopy';
|
||||
cfg=await api('/config');
|
||||
$('c-fmt').value=$('c-fmt').querySelector(`[value="${cfg.folder_format||'%Y-%m-%d'}"]`)?.value||'%Y-%m-%d';
|
||||
$('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;
|
||||
$('w-ssid').value=cfg.wifi_ssid||''; $('ap-ssid').value=cfg.ap_ssid||'PiCopy';
|
||||
}
|
||||
|
||||
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;
|
||||
await api('/config','POST',cfg);
|
||||
flash('copy-cfg-msg','ok','Gespeichert!');
|
||||
cfg.folder_format=$('c-fmt').value; cfg.add_time=$('c-time').checked;
|
||||
cfg.subfolder=$('c-sub').checked; cfg.auto_copy=$('c-auto').checked;
|
||||
await api('/config','POST',cfg); flash('copy-cfg-msg','ok','Gespeichert!');
|
||||
}
|
||||
|
||||
// ── WiFi ──────────────────────────────────────────────────────────────────
|
||||
async function scanNetworks() {
|
||||
$('net-list').style.display='flex';
|
||||
$('net-list').innerHTML='<div class="empty">Suche Netzwerke…</div>';
|
||||
const nets = await api('/wifi/scan');
|
||||
if (!nets.length) { $('net-list').innerHTML='<div class="empty">Keine Netzwerke gefunden</div>'; return; }
|
||||
$('net-list').innerHTML = nets.map(n => {
|
||||
const b = n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
|
||||
return `<div class="net-item" onclick="selectNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-signal">${b} ${n.signal}%</span></div>`;
|
||||
$('net-list').style.display='flex'; $('net-list').innerHTML='<div class="empty">Suche…</div>';
|
||||
const nets=await api('/wifi/scan');
|
||||
if(!nets.length){$('net-list').innerHTML='<div class="empty">Keine Netzwerke gefunden</div>';return;}
|
||||
$('net-list').innerHTML=nets.map(n=>{
|
||||
const b=n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
|
||||
return`<div class="net-item" onclick="selectNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-signal">${b} ${n.signal}%</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
function selectNet(ssid) { $('w-ssid').value=ssid; $('net-list').style.display='none'; $('w-pw').focus(); }
|
||||
|
||||
async function connectWifi() {
|
||||
const ssid=$('w-ssid').value.trim(), pw=$('w-pw').value;
|
||||
if (!ssid) { flash('wifi-flash','err','Bitte SSID eingeben'); return; }
|
||||
function selectNet(ssid){$('w-ssid').value=ssid;$('net-list').style.display='none';$('w-pw').focus();}
|
||||
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… (kann 30s dauern)');
|
||||
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. Bei Erfolg erscheint oben die neue IP.');
|
||||
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. Bei Erfolg erscheint oben die neue IP.');
|
||||
}
|
||||
async function saveAP(){
|
||||
const ssid=$('ap-ssid').value.trim(),pw=$('ap-pw').value;
|
||||
if(!ssid){flash('ap-flash','err','SSID fehlt');return;}
|
||||
if(pw.length<8){flash('ap-flash','err','Passwort min. 8 Zeichen');return;}
|
||||
const r=await api('/wifi/ap','POST',{ssid,password:pw});
|
||||
if(r.error)flash('ap-flash','err',r.error);else flash('ap-flash','ok','Gespeichert! Hotspot wird neu gestartet.');
|
||||
}
|
||||
|
||||
async function saveAP() {
|
||||
const ssid=$('ap-ssid').value.trim(), pw=$('ap-pw').value;
|
||||
if (!ssid) { flash('ap-flash','err','SSID fehlt'); return; }
|
||||
if (pw.length<8) { flash('ap-flash','err','Passwort min. 8 Zeichen'); return; }
|
||||
const r = await api('/wifi/ap','POST',{ssid,password:pw});
|
||||
if (r.error) flash('ap-flash','err',r.error);
|
||||
else flash('ap-flash','ok','Gespeichert! Hotspot wird neu gestartet.');
|
||||
// ── File Explorer ─────────────────────────────────────────────────────────
|
||||
const expl = {
|
||||
role: 'src',
|
||||
paths: {src:'', dst:''},
|
||||
|
||||
switchRole(role) {
|
||||
this.role = role;
|
||||
$('expl-tab-src').classList.toggle('active', role==='src');
|
||||
$('expl-tab-dst').classList.toggle('active', role==='dst');
|
||||
this.load(this.paths[role]);
|
||||
},
|
||||
|
||||
reload() { this.load(this.paths[this.role]); },
|
||||
|
||||
navigate(path) { this.load(path); },
|
||||
|
||||
async load(path='') {
|
||||
const port = this.role==='src' ? cfg.source_port : cfg.dest_port;
|
||||
const body = $('expl-body'), bread = $('expl-bread');
|
||||
|
||||
if (!port) {
|
||||
body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';
|
||||
bread.innerHTML='<span style="color:var(--mut)">—</span>';
|
||||
return;
|
||||
}
|
||||
const dev = 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(--mut)">—</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 || '';
|
||||
this._renderBread(data.path||'', dev.label||dev.device);
|
||||
this._renderList(data.entries||[], data.path||'');
|
||||
} catch(e) {
|
||||
body.innerHTML='<div class="expl-empty">Verbindungsfehler</div>';
|
||||
}
|
||||
},
|
||||
|
||||
_renderBread(path, devLabel) {
|
||||
const bread=$('expl-bread');
|
||||
let html=`<span class="bread-seg" onclick="expl.navigate('')" title="${devLabel}">⌂ ${devLabel}</span>`;
|
||||
if (path) {
|
||||
const parts=path.split('/').filter(Boolean);
|
||||
let acc='';
|
||||
parts.forEach(p=>{
|
||||
acc+=(acc?'/':'')+p;
|
||||
const a=acc;
|
||||
html+=`<span class="bread-sep"> › </span><span class="bread-seg" onclick="expl.navigate('${a.replace(/'/g,"\\'")}') ">${p}</span>`;
|
||||
});
|
||||
}
|
||||
bread.innerHTML=html;
|
||||
},
|
||||
|
||||
_renderList(entries, curPath) {
|
||||
const body=$('expl-body');
|
||||
if (!entries.length && !curPath) { body.innerHTML='<div class="expl-empty">Laufwerk ist leer</div>'; return; }
|
||||
|
||||
let html='';
|
||||
if (curPath) {
|
||||
const parent=curPath.includes('/') ? curPath.substring(0,curPath.lastIndexOf('/')) : '';
|
||||
html+=`<div class="expl-item is-up" onclick="expl.navigate('${parent}')">
|
||||
<span class="expl-icon">↩</span>
|
||||
<span class="expl-name" style="color:var(--mut)">..</span>
|
||||
<span class="expl-size"></span><span class="expl-date"></span>
|
||||
</div>`;
|
||||
}
|
||||
if (!entries.length) { body.innerHTML=html+'<div class="expl-empty">Ordner ist leer</div>'; return; }
|
||||
|
||||
entries.forEach(e=>{
|
||||
const icon=e.dir ? '📁' : fileIcon(e.name);
|
||||
const size=e.size!=null ? fmtSize(e.size) : '';
|
||||
const newPath=(curPath?curPath+'/':'')+e.name;
|
||||
const click=e.dir ? `onclick="expl.navigate('${newPath.replace(/'/g,"\\'")}') "` : '';
|
||||
html+=`<div class="expl-item ${e.dir?'is-dir':''}" ${click}>
|
||||
<span class="expl-icon">${icon}</span>
|
||||
<span class="expl-name">${e.name}</span>
|
||||
<span class="expl-size">${size}</span>
|
||||
<span class="expl-date">${e.mtime||''}</span>
|
||||
</div>`;
|
||||
});
|
||||
body.innerHTML=html;
|
||||
}
|
||||
};
|
||||
|
||||
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','wma'].includes(e)) return '🎵';
|
||||
if(['pdf','doc','docx','txt','xls','xlsx','ppt','pptx'].includes(e)) return '📄';
|
||||
if(['zip','rar','7z','tar','gz','bz2'].includes(e)) return '🗜';
|
||||
return '📄';
|
||||
}
|
||||
function fmtSize(b) {
|
||||
if(b==null) return '';
|
||||
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';
|
||||
}
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────
|
||||
async function poll() {
|
||||
try {
|
||||
const {copy:c, wifi:w} = await api('/status');
|
||||
const {copy:c,wifi:w}=await api('/status');
|
||||
const dot=$('wifi-dot'),mTxt=$('wifi-mode-txt'),ip=$('wifi-ip');
|
||||
if(w.mode==='client'){dot.className='wifi-dot green';mTxt.innerHTML='🔌 '+(w.ssid||'Verbunden');ip.textContent=w.ip||'';}
|
||||
else if(w.mode==='ap'){dot.className='wifi-dot blue';mTxt.innerHTML='📶 Hotspot: '+(w.ssid||'PiCopy');ip.textContent='10.42.0.1 · Port 8080';}
|
||||
else{dot.className='wifi-dot grey';mTxt.textContent='Kein WLAN';ip.textContent='';}
|
||||
|
||||
// WiFi bar
|
||||
const dot=$('wifi-dot'), mTxt=$('wifi-mode-txt'), ip=$('wifi-ip');
|
||||
if (w.mode==='client'){
|
||||
dot.className='wifi-dot green';
|
||||
mTxt.innerHTML='🔌 '+(w.ssid||'Verbunden');
|
||||
ip.textContent=w.ip||'';
|
||||
} else if (w.mode==='ap'){
|
||||
dot.className='wifi-dot blue';
|
||||
mTxt.innerHTML='📶 Hotspot: '+(w.ssid||'PiCopy');
|
||||
ip.textContent='10.42.0.1 · Port 8080';
|
||||
} else {
|
||||
dot.className='wifi-dot grey';
|
||||
mTxt.textContent='Kein WLAN'; ip.textContent='';
|
||||
}
|
||||
|
||||
// Copy status
|
||||
const txt=$('st-text'), bar=$('prog-bar'), wrap=$('prog-wrap');
|
||||
const info=$('prog-info'), sum=$('st-summary');
|
||||
const bS=$('btn-start'), bC=$('btn-cancel');
|
||||
if (c.running){
|
||||
txt.className='st-run'; txt.textContent='Kopiert… '+c.progress+'%';
|
||||
wrap.style.display='block'; bar.style.width=c.progress+'%';
|
||||
const txt=$('st-text'),bar=$('prog-bar'),wrap=$('prog-wrap'),info=$('prog-info'),sum=$('st-summary');
|
||||
const bS=$('btn-start'),bC=$('btn-cancel');
|
||||
if(c.running){
|
||||
txt.className='st-run';txt.textContent='Kopiert… '+c.progress+'%';
|
||||
wrap.style.display='block';bar.style.width=c.progress+'%';
|
||||
info.textContent=c.current?c.done+' / '+c.total+' — '+c.current:'';
|
||||
sum.textContent=''; bS.style.display='none'; bC.style.display='';
|
||||
sum.textContent='';bS.style.display='none';bC.style.display='';
|
||||
} else {
|
||||
bS.style.display=''; bC.style.display='none'; info.textContent='';
|
||||
if (c.error){
|
||||
txt.className='st-err'; txt.textContent='Fehler: '+c.error;
|
||||
wrap.style.display='none'; sum.textContent='';
|
||||
} else if (c.last_copy){
|
||||
txt.className='st-ok'; txt.textContent='✓ Abgeschlossen';
|
||||
wrap.style.display='block'; bar.style.width='100%';
|
||||
sum.textContent=c.total+' Dateien · '+new Date(c.last_copy).toLocaleString('de-DE');
|
||||
} else {
|
||||
txt.className='st-idle'; txt.textContent='Bereit';
|
||||
wrap.style.display='none'; sum.textContent='';
|
||||
}
|
||||
bS.style.display='';bC.style.display='none';info.textContent='';
|
||||
if(c.error){txt.className='st-err';txt.textContent='Fehler: '+c.error;wrap.style.display='none';sum.textContent='';}
|
||||
else if(c.last_copy){txt.className='st-ok';txt.textContent='✓ Abgeschlossen';wrap.style.display='block';bar.style.width='100%';sum.textContent=c.total+' Dateien · '+new Date(c.last_copy).toLocaleString('de-DE');}
|
||||
else{txt.className='st-idle';txt.textContent='Bereit';wrap.style.display='none';sum.textContent='';}
|
||||
}
|
||||
|
||||
// Log
|
||||
if (c.logs&&c.logs.length)
|
||||
$('log-box').innerHTML=c.logs.slice().reverse().map(l=>
|
||||
`<div class="log-entry"><span class="log-t">${l.t}</span><span>${l.m}</span></div>`).join('');
|
||||
if(c.logs&&c.logs.length)
|
||||
$('log-box').innerHTML=c.logs.slice().reverse().map(l=>`<div class="log-entry"><span class="log-t">${l.t}</span><span>${l.m}</span></div>`).join('');
|
||||
} 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);
|
||||
}
|
||||
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);}
|
||||
|
||||
(async()=>{
|
||||
await loadCfg();
|
||||
await refreshDevices();
|
||||
setInterval(poll, 1500);
|
||||
setInterval(refreshDevices, 8000);
|
||||
expl.load('');
|
||||
setInterval(poll,1500);
|
||||
setInterval(refreshDevices,8000);
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user