From f23163501c6c2d1c5db68af523e9ffdc81a1350a Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 9 May 2026 14:38:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Unterst=C3=BCtzung=20f=C3=BCr=20mehrspr?= =?UTF-8?q?achige=20Benutzeroberfl=C3=A4che=20hinzugef=C3=BCgt;=20Versions?= =?UTF-8?q?nummer=20auf=201.0.52=20erh=C3=B6ht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 225 +++++++++++++++++++++++++++++++++++++++++++++++----- version.txt | 2 +- 2 files changed, 207 insertions(+), 20 deletions(-) diff --git a/app.py b/app.py index bd7af4e..a799a14 100644 --- a/app.py +++ b/app.py @@ -59,6 +59,7 @@ DEFAULT_CONFIG = { 'dest_port': None, 'dest_label': '', 'dest_type': 'usb', 'internal_dest_label': 'Interner Speicher', 'internal_share_enabled': False, + 'ui_lang': 'de', 'folder_format': '%Y-%m-%d', 'add_time': True, 'subfolder': True, 'auto_copy': True, 'file_filter': '', 'exclude_system': True, @@ -1974,6 +1975,10 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys .logo{display:flex;align-items:center;gap:.55rem;font-size:1rem;font-weight:700;letter-spacing:-.02em;color:var(--txt)} .logo-dot{width:8px;height:8px;border-radius:50%;background:var(--acc);box-shadow:0 0 8px var(--acc)} .topbar-wifi{margin-left:auto;display:flex;align-items:center;gap:.6rem;font-size:.82rem;background:var(--surf);border:1px solid var(--brd);border-radius:9999px;padding:.3rem .75rem} +.topbar-wifi~.topbar-wifi,.topbar-wifi~.lang-toggle{margin-left:0} +.lang-toggle{display:inline-flex;border:1px solid var(--brd);border-radius:9999px;overflow:hidden;background:var(--surf);flex-shrink:0} +.lang-toggle button{border:0;background:transparent;color:var(--sub);font-size:.74rem;font-weight:700;padding:.32rem .55rem;cursor:pointer} +.lang-toggle button.on{background:var(--acc);color:#fff} .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)} @@ -2065,6 +2070,13 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys .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 */ @@ -2148,9 +2160,13 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
- Verbinde... + Verbinde...
+
+ + +
Startet automatisch wenn kein Heimnetz erreichbar ist.
IP im Hotspot-Modus: 10.42.0.1:8080
+
+
+ +
+
Direkt öffnen
+
http://10.42.0.1:8080
+
Im PiCopy-Hotspot mit dem Handy scannen und die Oberfläche öffnen.
+
+
+
@@ -2576,6 +2602,163 @@ const api = async (p, m='GET', b=null) => { return (await fetch('/api'+p,o)).json(); }; +const I18N = { + de: { + 'wifi.connecting':'Verbinde...', 'wifi.none':'Kein WLAN', 'wifi.connected':'Verbunden', + 'wifi.hotspot':'Hotspot: ', 'vpn.active':'VPN aktiv', 'vpn.disconnected':'Getrennt', + 'copy.ready':'Bereit', 'copy.copying':'Kopiert... ', 'copy.verify':'Verifiziere... ', + 'copy.delete':'Quelle wird geleert...', 'copy.done':'✓ Abgeschlossen', 'copy.files':' Dateien', + 'copy.checked':' geprüft', 'qr.title':'Direkt öffnen', + 'qr.sub':'Im PiCopy-Hotspot mit dem Handy scannen und die Oberfläche öffnen.', + 'share.on':'Freigabe stoppen', 'share.off':'Freigeben', 'share.inactive':'Nicht freigegeben', + 'share.install':'Installiere...', 'share.installing':'Samba wird installiert. ', + }, + en: { + 'wifi.connecting':'Connecting...', 'wifi.none':'No Wi-Fi', 'wifi.connected':'Connected', + 'wifi.hotspot':'Hotspot: ', 'vpn.active':'VPN active', 'vpn.disconnected':'Disconnected', + 'copy.ready':'Ready', 'copy.copying':'Copying... ', 'copy.verify':'Verifying... ', + 'copy.delete':'Clearing source...', 'copy.done':'✓ Complete', 'copy.files':' files', + 'copy.checked':' checked', 'qr.title':'Open directly', + 'qr.sub':'Scan while connected to the PiCopy hotspot to open the interface.', + 'share.on':'Stop share', 'share.off':'Share', 'share.inactive':'Not shared', + 'share.install':'Installing...', 'share.installing':'Installing Samba. ', + } +}; +const STATIC_EN = { + 'Kopierstatus':'Copy Status', 'Quelle hinzufügen':'Add Source', 'Ziel':'Target', + 'Bezeichnung':'Label', 'Zieltyp':'Target Type', 'USB-Laufwerk':'USB Drive', + 'Interner Speicher vom Raspberry Pi':'Raspberry Pi Internal Storage', + 'Port lernen - Gerät wählen':'Learn Port - Choose Device', + 'Als festes Ziel speichern':'Save As Fixed Target', 'SMB-Freigabe':'SMB Share', + 'Kopier-Einstellungen':'Copy Settings', 'Ordnerstruktur':'Folder Structure', + 'Datumsformat':'Date Format', 'Uhrzeit im Ordnernamen':'Add Time To Folder Name', + 'Unterordner pro Quelle':'Subfolder Per Source', 'Automatisch kopieren':'Copy Automatically', + 'Dateifilter':'File Filter', 'Nur diese Typen kopieren (leer = alle)':'Only Copy These Types (empty = all)', + 'Fotos':'Photos', 'Videos':'Videos', 'Alle':'All', 'Systemdateien ausschließen':'Exclude System Files', + 'Duplikate':'Duplicates', 'Wenn Zieldatei bereits existiert':'When Target File Already Exists', + 'Überspringen (empfohlen)':'Skip (recommended)', 'Überschreiben':'Overwrite', + 'Umbenennen (_1, _2 ...)':'Rename (_1, _2 ...)', 'Integrität & Aufräumen':'Integrity & Cleanup', + 'Dateien nach Kopieren per MD5 verifizieren':'Verify Files With MD5 After Copy', + 'Quelldateien nach Kopieren löschen':'Delete Source Files After Copy', + 'Speichern':'Save', 'Fernkopie - NAS / SMB':'Remote Copy - NAS / SMB', + 'NAS-Ziel hinzufügen':'Add NAS Target', 'WiFi-Einstellungen':'Wi-Fi Settings', + 'Heimnetz':'Home Network', 'Hotspot (AP)':'Hotspot (AP)', 'Netzwerk (SSID)':'Network (SSID)', + 'Passwort':'Password', 'Verbinden & Speichern':'Connect & Save', 'Hotspot-Name (SSID)':'Hotspot Name (SSID)', + 'Passwort (min. 8 Zeichen)':'Password (min. 8 chars)', 'Speichern & Neustart':'Save & Restart', + 'WireGuard VPN':'WireGuard VPN', 'Konfiguration':'Configuration', 'Beim Start automatisch verbinden':'Connect Automatically On Startup', + 'Konfiguration speichern':'Save Configuration', 'Deinstallieren':'Uninstall', 'System':'System', + 'Nach Update suchen':'Check For Update', 'Gerät neu starten':'Restart Device', 'Logs':'Logs', + 'Noch keine Einträge':'No entries yet', 'Fernkopie':'Remote Copy', 'Kopieren starten':'Start Copy', + 'Abbrechen':'Cancel', 'Interner Speicher':'Internal Storage', 'Nicht freigegeben':'Not shared', + 'Freigeben':'Share', 'Freigabe stoppen':'Stop share' +}; +function t(k){return (I18N[cfg.ui_lang||'de']&&I18N[cfg.ui_lang||'de'][k])||I18N.de[k]||k;} +function applyLang(){ + const lang=cfg.ui_lang||'de'; + document.documentElement.lang=lang; + $('lang-de').classList.toggle('on',lang==='de'); + $('lang-en').classList.toggle('on',lang==='en'); + document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n);}); + document.querySelectorAll('button,label,span,.card-title,.sec,.hint-box,.expl-empty,option').forEach(el=>{ + if(el.children.length>0) return; + if(!el.dataset.deText) el.dataset.deText=el.textContent.trim(); + const de=el.dataset.deText; + el.textContent=lang==='en'?(STATIC_EN[de]||de):de; + }); + const ph = { + 'src-label':['z.B. Kamera 1 / linker Port','e.g. Camera 1 / left port'], + 'dst-label':['z.B. Zielplatte oder Interner Speicher','e.g. target drive or internal storage'], + 'w-ssid':['WLAN-Name','Wi-Fi name'], + 'w-pw':['WLAN-Passwort','Wi-Fi password'], + 'ut-host':['192.168.1.100 oder nas.local','192.168.1.100 or nas.local'], + 'ut-name':['z.B. Heimserver NAS','e.g. Home server NAS'], + }; + Object.entries(ph).forEach(([id,v])=>{const el=$(id); if(el) el.placeholder=lang==='en'?v[1]:v[0];}); +} +async function setLang(lang){ + cfg.ui_lang=lang; + await api('/config','POST',cfg); + applyLang(); + poll(); +} + +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;ia*2+b,0)); + for(let p=0xec;data.length{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{ + const factor=d^rem.shift(); rem.push(0); + for(let i=0;iArray(size).fill(null)); + const set=(x,y,v)=>{if(x>=0&&y>=0&&x{ + 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;iArray.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>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>>9)&1)?0x537:0); + return ((data<<10)|rem)^0x5412; +} + // -- Tabs --------------------------------------------------------------------- function swTab(show,hide){ $(show).classList.add('on'); $(hide).classList.remove('on'); @@ -2591,6 +2774,7 @@ async function refreshDevices(){ renderSlot('dst', cfg.dest_port, cfg.dest_label); renderUnassigned(); populateSel(); + applyLang(); } let selectedPortSet = new Set(); @@ -2799,6 +2983,7 @@ async function loadCfg(){ $('w-ssid').value=cfg.wifi_ssid||''; $('ap-ssid').value=cfg.ap_ssid||'PiCopy'; $('dst-type').value=cfg.dest_type||'usb'; onDestTypeChange(false); + applyLang(); } async function saveCopyCfg(){ cfg.folder_format=$('c-fmt').value; cfg.add_time=$('c-time').checked; @@ -2845,7 +3030,7 @@ async function saveAP(){ // -- Upload-Ziele -------------------------------------------------------------- let utTargets=[], _utConn={}; -async function loadUTs(){utTargets=await api('/upload/targets');renderUTs();} +async function loadUTs(){utTargets=await api('/upload/targets');renderUTs();applyLang();} function renderUTs(){ const el=$('ut-list'); if(!utTargets.length){el.innerHTML='
Noch keine Fernziele konfiguriert
';return;} @@ -2941,15 +3126,15 @@ async function updateInternalShareBox(state=null){ 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; + btn.disabled=true; btn.textContent=t('share.install'); + detail.textContent=t('share.installing')+free; return; } btn.disabled=false; - btn.textContent=s.enabled?'Freigabe stoppen':'Freigeben'; + btn.textContent=s.enabled?t('share.on'):t('share.off'); const status=s.enabled ? ((s.active?'Aktiv':'Konfiguriert')+' | \\\\'+(location.hostname||'picopy')+'\\PiCopy') - : 'Nicht freigegeben'; + : t('share.inactive'); detail.textContent=status+(free?' | '+free:''); } @@ -3093,15 +3278,15 @@ async function poll(){ 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'; + vl.textContent=t('vpn.active'); vi.textContent=v.ip||''; + wgd.className='wdot c'; wgl.textContent=t('wifi.connected'); 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'; + wgd.className='wdot d'; wgl.textContent=t('vpn.disconnected'); 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'; @@ -3110,9 +3295,11 @@ async function poll(){ } // 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='';} + if(w.mode==='client'){wd.className='wdot c';wl.textContent=w.ssid||t('wifi.connected');wi.textContent=w.ip||'';} + else if(w.mode==='ap'){wd.className='wdot a';wl.textContent=t('wifi.hotspot')+(w.ssid||'PiCopy');wi.textContent='10.42.0.1';} + else{wd.className='wdot d';wl.textContent=t('wifi.none');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'); @@ -3121,22 +3308,22 @@ async function poll(){ if(c.running){ const ph=c.phase||'copy'; if(ph==='verify'){ - tx.className='st-headline st-run'; tx.textContent='Verifiziere... '+c.progress+'%'; + tx.className='st-headline st-run'; tx.textContent=t('copy.verify')+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'; + pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+t('copy.checked'); 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...'; + tx.className='st-headline st-run'; tx.textContent=t('copy.delete'); 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+'%'; + tx.className='st-headline st-run'; tx.textContent=t('copy.copying')+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'; + pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+t('copy.files'); 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:''; @@ -3151,7 +3338,7 @@ async function poll(){ pf.className='prog-fill err'; pw.style.display='block'; pf.style.width='100%'; sum.textContent=''; time.textContent=''; }else if(c.last_copy && !_dismissed){ - tx.className='st-headline st-ok'; tx.textContent='✓ Abgeschlossen'; + tx.className='st-headline st-ok'; tx.textContent=t('copy.done'); 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'); @@ -3163,7 +3350,7 @@ async function poll(){ _autoDismissTimer=setTimeout(dismissStatus, remaining*1000); } }else{ - tx.className='st-headline st-idle'; tx.textContent='Bereit'; + tx.className='st-headline st-idle'; tx.textContent=t('copy.ready'); pw.style.display='none'; sum.textContent=''; time.textContent=''; $('st-dismiss').style.display='none'; } diff --git a/version.txt b/version.txt index 73b4678..46354d7 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.51 +1.0.52