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
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;i
a*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