feat: Unterstützung für mehrsprachige Benutzeroberfläche hinzugefügt; Versionsnummer auf 1.0.52 erhöht

This commit is contained in:
2026-05-09 14:38:01 +02:00
parent d05d0938ff
commit f23163501c
2 changed files with 207 additions and 20 deletions

225
app.py
View File

@@ -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
</div>
<div class="topbar-wifi">
<div class="wdot d" id="wdot"></div>
<span id="wifi-label">Verbinde...</span>
<span id="wifi-label" data-i18n="wifi.connecting">Verbinde...</span>
<span id="wifi-ip"></span>
</div>
<div class="lang-toggle" title="Language">
<button id="lang-de" onclick="setLang('de')">DE</button>
<button id="lang-en" onclick="setLang('en')">EN</button>
</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>
@@ -2464,6 +2480,16 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
</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" data-i18n="qr.title">Direkt öffnen</div>
<div class="qr-url" id="hotspot-qr-url">http://10.42.0.1:8080</div>
<div class="qr-sub" data-i18n="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><input type="password" id="ap-pw" placeholder="PiCopy,"></div>
<button class="btn pri" onclick="saveAP()">✓&nbsp;Speichern &amp; Neustart</button>
@@ -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;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');
@@ -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='<div class="empty">Noch keine Fernziele konfiguriert</div>';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';
}