From 9d4c710d2f2b1ee68a42eb30f62d790eb45ecdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20K=C3=BChn?= Date: Thu, 30 Apr 2026 13:34:41 +0200 Subject: [PATCH] Add DE/EN/PL i18n with browser-language detection and per-user override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - STRINGS object in app.js with all UI strings in de/en/pl - tr() function, ldoc() locale-aware date formatting - tpl() auto-translates data-t/data-ph/data-val attributes on clone - app.html.twig: data-t attributes on all template static text, language picker in data menu - locale CHAR(2) column on users table; GET /api/me returns locale; PATCH /api/me accepts locale - setLocale() persists to API + localStorage; applyLocale() reads user.locale → localStorage → navigator.language Co-Authored-By: Claude Sonnet 4.6 --- migrations/Version20260430100000.php | 21 ++ public/app.js | 300 ++++++++++++++++++++++----- public/style.css | 3 + src/Controller/AuthController.php | 21 +- src/Entity/User.php | 5 + templates/app.html.twig | 172 +++++++-------- 6 files changed, 385 insertions(+), 137 deletions(-) create mode 100644 migrations/Version20260430100000.php diff --git a/migrations/Version20260430100000.php b/migrations/Version20260430100000.php new file mode 100644 index 0000000..9c83bfe --- /dev/null +++ b/migrations/Version20260430100000.php @@ -0,0 +1,21 @@ +addSql('ALTER TABLE users ADD COLUMN locale CHAR(2) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE users DROP COLUMN locale'); + } +} diff --git a/public/app.js b/public/app.js index 4aadb9b..31fbde1 100644 --- a/public/app.js +++ b/public/app.js @@ -2,6 +2,188 @@ var TODAY = new Date(); TODAY.setHours(0,0,0,0); var goals = [], prefs, selDay = {}, addAmt = {}, renamingId = null, renameVal = '', collapsed = {}; var userName = ''; +var STRINGS = { + de: { + hint:'Menü → "Zum Startbildschirm" für App-Icon', + emptyLine1:'Noch keine Ziele.',emptyLine2:'Tippe auf + um zu starten.', + noEntry:'Noch kein Eintrag',log:'Eintragen', + noch:'Noch',dAbbr:'T',endet:'endet',todayShort:'heute',total:'total', + todayHeading:'Heute',done2:'Gemacht',dailyGoal:'Tagesziel',remaining:'Noch', + history:'Verlauf — heute & gestern bearbeitbar', + legBuf:'Puffer',legDone:'Erreicht',legPartial:'Teilweise',legMissed:'Verpasst', + delGoal:'Ziel löschen', + qbLabel:'Quick-Buchen',openLabel:'Offen',doneToday:'Heute erledigt', + hello:'Hallo {n}!', + confirmDelete:'Ziel wirklich löschen?',errDelete:'Fehler beim Löschen',errSave:'Speicherfehler', + loginTitle:'Anmelden',emailLabel:'E-Mail',passwordLabel:'Passwort',loginBtn:'Anmelden', + forgotPw:'Passwort vergessen?', + loginErrEmpty:'Bitte E-Mail und Passwort eingeben', + loginErrWrong:'Falsche E-Mail oder Passwort',loginErrRate:'Zu viele Versuche',loginErrConn:'Verbindungsfehler', + forgotTitle:'Passwort vergessen',forgotSub:'Wir schicken dir einen Reset-Link', + sendLink:'Link senden',back:'Zurück', + emailSentTitle:'E-Mail gesendet', + emailSentSub:'Falls die Adresse bekannt ist, erhältst du in Kürze einen Reset-Link.', + ok:'OK', + resetTitle:'Neues Passwort',newPwLabel:'Neues Passwort',setPw:'Passwort setzen',min8:'mind. 8 Zeichen', + pwChangedTitle:'Passwort geändert',pwChangedSub:'Du kannst dich jetzt anmelden.', + changeNameTitle:'Name ändern',yourName:'Dein Name',save:'Speichern',cancel:'Abbrechen', + errNameEmpty:'Name darf nicht leer sein',errNameSave:'Fehler beim Speichern',nameSaved:'Name gespeichert', + changePwTitle:'Passwort ändern',currentPwLabel:'Aktuelles Passwort', + newPwConfLabel:'Neues Passwort bestätigen',changePwBtn:'Ändern', + errPwMismatch:'Die neuen Passwörter stimmen nicht überein',pwChanged:'Passwort geändert', + registerTitle:'Konto erstellen',registerSub:'Du wurdest eingeladen', + namePlaceholder:'Wie sollen wir dich nennen?',pwConfLabel:'Passwort bestätigen', + pwPlaceholder:'Passwort wiederholen',registerBtn:'Registrieren', + errPwMismatch2:'Passwörter stimmen nicht überein',errFillAll:'Bitte alle Felder ausfüllen', + newGoalTitle:'Neues Ziel',exerciseLabel:'Übung / Gewohnheit',exercisePlaceholder:'Liegestütz, Plank …', + unitLabel:'Einheit',unitDefault:'Stück',daysLabel:'Dauer in Tagen', + dailyLabel:'Tagesziel',weeklyLabel:'Wochenziel',startGoal:'Ziel starten',errCreate:'Fehler beim Erstellen', + dataMenuTitle:'Daten verwalten',dataMenuSub:'Export, Import und Backup', + exportLabel:'Exportieren',exportSub:'Alle Ziele als JSON-Datei speichern', + importLabel:'Importieren',importSub:'Backup laden oder zusammenführen', + inviteLabel:'Freund einladen',inviteSub:'Einladungslink generieren', + inviteListLabel:'Meine Einladungen',inviteListSub:'Status aller gesendeten Einladungen', + changeName:'Name ändern',changePw:'Passwort ändern',logout:'Abmelden',close:'Schließen', + clearAll:'Alle Daten löschen',clearAllSub:'Kann nicht rückgängig gemacht werden', + confirmClear:'Alle Daten löschen?', + inviteFormTitle:'Freund einladen',inviteFormSub:'Link gilt 7 Tage und kann nur einmal verwendet werden', + inviteNameLabel:'Name (für deine Übersicht)',inviteNamePlaceholder:'z.B. Max', + generateLink:'Link generieren',inviteLinkTitle:'Einladungslink', + copyLink:'Link kopieren',linkCopied:'Link kopiert!',errGenerate:'Fehler beim Generieren', + noInvites:'Noch keine Einladungen verschickt', + statusPending:'Ausstehend',statusUsed:'Angenommen',statusExpired:'Abgelaufen', + errLoad:'Fehler beim Laden', + confirmImport:'{n} Ziel(e) importieren?',importDone:'{n} Ziel(e) importiert.',invalidFormat:'Ungültiges Format', + linkLabel:'Link',doneLabel:'gemacht',ofLabel:'von', + gestern:'Gestern',heute:'Heute', + expiresAt:'läuft ab:',acceptedBy:'→', + }, + en: { + hint:'Menu → "Add to Home Screen" for app icon', + emptyLine1:'No goals yet.',emptyLine2:'Tap + to get started.', + noEntry:'No entries yet',log:'Log', + noch:'Left',dAbbr:'d',endet:'ends',todayShort:'today',total:'total', + todayHeading:'Today',done2:'Done',dailyGoal:'Daily goal',remaining:'Left', + history:'History — today & yesterday editable', + legBuf:'Buffer',legDone:'Reached',legPartial:'Partial',legMissed:'Missed', + delGoal:'Delete goal', + qbLabel:'Quick-log',openLabel:'Open',doneToday:'Done today', + hello:'Hello {n}!', + confirmDelete:'Really delete this goal?',errDelete:'Delete failed',errSave:'Save failed', + loginTitle:'Sign in',emailLabel:'E-Mail',passwordLabel:'Password',loginBtn:'Sign in', + forgotPw:'Forgot password?', + loginErrEmpty:'Please enter email and password', + loginErrWrong:'Wrong email or password',loginErrRate:'Too many attempts',loginErrConn:'Connection error', + forgotTitle:'Forgot password',forgotSub:"We'll send you a reset link", + sendLink:'Send link',back:'Back', + emailSentTitle:'Email sent', + emailSentSub:"If the address is registered, you'll receive a reset link shortly.", + ok:'OK', + resetTitle:'New password',newPwLabel:'New password',setPw:'Set password',min8:'at least 8 characters', + pwChangedTitle:'Password changed',pwChangedSub:'You can now sign in.', + changeNameTitle:'Change name',yourName:'Your name',save:'Save',cancel:'Cancel', + errNameEmpty:'Name cannot be empty',errNameSave:'Save failed',nameSaved:'Name saved', + changePwTitle:'Change password',currentPwLabel:'Current password', + newPwConfLabel:'Confirm new password',changePwBtn:'Change', + errPwMismatch:"The new passwords don't match",pwChanged:'Password changed', + registerTitle:'Create account',registerSub:"You've been invited", + namePlaceholder:'What should we call you?',pwConfLabel:'Confirm password', + pwPlaceholder:'Repeat password',registerBtn:'Register', + errPwMismatch2:"Passwords don't match",errFillAll:'Please fill in all fields', + newGoalTitle:'New goal',exerciseLabel:'Exercise / Habit',exercisePlaceholder:'Push-ups, Plank …', + unitLabel:'Unit',unitDefault:'reps',daysLabel:'Duration (days)', + dailyLabel:'Daily goal',weeklyLabel:'Weekly goal',startGoal:'Start goal',errCreate:'Create failed', + dataMenuTitle:'Manage data',dataMenuSub:'Export, Import and Backup', + exportLabel:'Export',exportSub:'Save all goals as JSON file', + importLabel:'Import',importSub:'Load or merge backup', + inviteLabel:'Invite friend',inviteSub:'Generate invite link', + inviteListLabel:'My invitations',inviteListSub:'Status of all sent invitations', + changeName:'Change name',changePw:'Change password',logout:'Sign out',close:'Close', + clearAll:'Delete all data',clearAllSub:'Cannot be undone', + confirmClear:'Delete all data?', + inviteFormTitle:'Invite friend',inviteFormSub:'Link valid 7 days, single use', + inviteNameLabel:'Name (for your reference)',inviteNamePlaceholder:'e.g. Max', + generateLink:'Generate link',inviteLinkTitle:'Invite link', + copyLink:'Copy link',linkCopied:'Link copied!',errGenerate:'Generate failed', + noInvites:'No invitations sent yet', + statusPending:'Pending',statusUsed:'Accepted',statusExpired:'Expired', + errLoad:'Load failed', + confirmImport:'Import {n} goal(s)?',importDone:'{n} goal(s) imported.',invalidFormat:'Invalid format', + linkLabel:'Link',doneLabel:'done',ofLabel:'of', + gestern:'Yesterday',heute:'Today', + expiresAt:'expires:',acceptedBy:'→', + }, + pl: { + hint:'Menu → "Dodaj do ekranu głównego" aby zainstalować', + emptyLine1:'Brak celów.',emptyLine2:'Dotknij +, aby zacząć.', + noEntry:'Brak wpisów',log:'Zapisz', + noch:'Jeszcze',dAbbr:'d',endet:'kończy',todayShort:'dziś',total:'łącznie', + todayHeading:'Dziś',done2:'Wykonano',dailyGoal:'Cel dzienny',remaining:'Pozostało', + history:'Historia — dziś i wczoraj edytowalne', + legBuf:'Zapas',legDone:'Osiągnięto',legPartial:'Częściowo',legMissed:'Pominięto', + delGoal:'Usuń cel', + qbLabel:'Szybki wpis',openLabel:'Otwarte',doneToday:'Dziś ukończone', + hello:'Cześć {n}!', + confirmDelete:'Na pewno usunąć cel?',errDelete:'Błąd podczas usuwania',errSave:'Błąd zapisu', + loginTitle:'Zaloguj się',emailLabel:'E-Mail',passwordLabel:'Hasło',loginBtn:'Zaloguj', + forgotPw:'Zapomniałeś hasła?', + loginErrEmpty:'Podaj e-mail i hasło', + loginErrWrong:'Błędny e-mail lub hasło',loginErrRate:'Zbyt wiele prób',loginErrConn:'Błąd połączenia', + forgotTitle:'Zapomniane hasło',forgotSub:'Wyślemy Ci link do resetu', + sendLink:'Wyślij link',back:'Wróć', + emailSentTitle:'E-mail wysłany', + emailSentSub:'Jeśli adres jest zarejestrowany, wkrótce otrzymasz link resetujący.', + ok:'OK', + resetTitle:'Nowe hasło',newPwLabel:'Nowe hasło',setPw:'Ustaw hasło',min8:'min. 8 znaków', + pwChangedTitle:'Hasło zmienione',pwChangedSub:'Możesz się teraz zalogować.', + changeNameTitle:'Zmień nazwę',yourName:'Twoje imię',save:'Zapisz',cancel:'Anuluj', + errNameEmpty:'Imię nie może być puste',errNameSave:'Błąd zapisu',nameSaved:'Imię zapisano', + changePwTitle:'Zmień hasło',currentPwLabel:'Aktualne hasło', + newPwConfLabel:'Potwierdź nowe hasło',changePwBtn:'Zmień', + errPwMismatch:'Nowe hasła się nie zgadzają',pwChanged:'Hasło zmienione', + registerTitle:'Utwórz konto',registerSub:'Zostałeś zaproszony', + namePlaceholder:'Jak mamy Cię nazywać?',pwConfLabel:'Potwierdź hasło', + pwPlaceholder:'Powtórz hasło',registerBtn:'Zarejestruj', + errPwMismatch2:'Hasła się nie zgadzają',errFillAll:'Wypełnij wszystkie pola', + newGoalTitle:'Nowy cel',exerciseLabel:'Ćwiczenie / Nawyk',exercisePlaceholder:'Pompki, Plank …', + unitLabel:'Jednostka',unitDefault:'szt.',daysLabel:'Czas trwania (dni)', + dailyLabel:'Cel dzienny',weeklyLabel:'Cel tygodniowy',startGoal:'Rozpocznij cel',errCreate:'Błąd tworzenia', + dataMenuTitle:'Zarządzaj danymi',dataMenuSub:'Eksport, import i kopia zapasowa', + exportLabel:'Eksportuj',exportSub:'Zapisz wszystkie cele jako plik JSON', + importLabel:'Importuj',importSub:'Załaduj lub połącz kopię zapasową', + inviteLabel:'Zaproś znajomego',inviteSub:'Wygeneruj link zaproszenia', + inviteListLabel:'Moje zaproszenia',inviteListSub:'Status wszystkich wysłanych zaproszeń', + changeName:'Zmień nazwę',changePw:'Zmień hasło',logout:'Wyloguj',close:'Zamknij', + clearAll:'Usuń wszystkie dane',clearAllSub:'Nie można cofnąć', + confirmClear:'Usunąć wszystkie dane?', + inviteFormTitle:'Zaproś znajomego',inviteFormSub:'Link ważny 7 dni, jednorazowy', + inviteNameLabel:'Nazwa (dla Twojej ewidencji)',inviteNamePlaceholder:'np. Max', + generateLink:'Wygeneruj link',inviteLinkTitle:'Link zaproszenia', + copyLink:'Kopiuj link',linkCopied:'Link skopiowany!',errGenerate:'Błąd generowania', + noInvites:'Brak wysłanych zaproszeń', + statusPending:'Oczekujący',statusUsed:'Zaakceptowany',statusExpired:'Wygasły', + errLoad:'Błąd ładowania', + confirmImport:'Importować {n} cel(e)?',importDone:'Zaimportowano {n} cel(e).',invalidFormat:'Nieprawidłowy format', + linkLabel:'Link',doneLabel:'wykonano',ofLabel:'z', + gestern:'Wczoraj',heute:'Dziś', + expiresAt:'wygasa:',acceptedBy:'→', + } +}; + +var LOCALE = 'de'; + +function tr(key){ return (STRINGS[LOCALE]||STRINGS.de)[key]||STRINGS.de[key]||key; } +function ldoc(){ return LOCALE==='de'?'de-DE':LOCALE==='pl'?'pl-PL':'en-GB'; } + +function setLocale(lang, save){ + LOCALE = lang; + if(save){ + api('PATCH','me',{locale:lang}).catch(function(){}); + localStorage.setItem('zt_locale',lang); + } + render(); updateHeader(); +} + function loadPref(k,def){ try{ return JSON.parse(localStorage.getItem(k)||def); }catch(e){ return JSON.parse(def); } } function saveP(){ localStorage.setItem('zt_p',JSON.stringify(prefs)); } prefs = loadPref('zt_p','{}'); @@ -9,8 +191,8 @@ prefs = loadPref('zt_p','{}'); function tOff(g){ return Math.round((TODAY - new Date(g.start))/86400000); } function o2d(g,i){ var d=new Date(new Date(g.start).getTime()+i*86400000); d.setHours(0,0,0,0); return d; } function dTot(g,o){ return (g.sets[String(o)]||[]).reduce(function(a,b){return a+b.amount;},0); } -function fd(d){ return d.toLocaleDateString('de-DE',{weekday:'short',day:'numeric',month:'short'}); } -function fs(d){ return d.toLocaleDateString('de-DE',{day:'numeric',month:'short'}); } +function fd(d){ return d.toLocaleDateString(ldoc(),{weekday:'short',day:'numeric',month:'short'}); } +function fs(d){ return d.toLocaleDateString(ldoc(),{day:'numeric',month:'short'}); } function editable(g,o){ var t=tOff(g); return o===t||o===t-1; } function now(){ var n=new Date(); return String(n.getHours()).padStart(2,'0')+':'+String(n.getMinutes()).padStart(2,'0'); } @@ -87,7 +269,7 @@ function saveGoal(g){ api('PATCH','goals/'+g.id,{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets}) .catch(function(e){ if(e.status===401){ showLogin(); } - else showToast('Speicherfehler'); + else showToast(tr('errSave')); }); } @@ -115,10 +297,10 @@ function remSet(gid,off,idx){ g.sets[String(off)].splice(idx,1); saveGoal(g); render(); } function delGoal(id){ - if(!confirm('Ziel wirklich löschen?')) return; + if(!confirm(tr('confirmDelete'))) return; goals=goals.filter(function(g){return g.id!==id;}); render(); - api('DELETE','goals/'+id).catch(function(){ showToast('Fehler beim Löschen'); }); + api('DELETE','goals/'+id).catch(function(){ showToast(tr('errDelete')); }); } function selD(gid,off){ var g=goals.filter(function(x){return x.id===gid;})[0]; @@ -140,7 +322,11 @@ function cancelRen(){ renamingId=null; render(); } // ── Template-Helper ─────────────────────────────────────────────────────────── function tpl(id){ - return document.getElementById(id).content.cloneNode(true).firstElementChild; + var el=document.getElementById(id).content.cloneNode(true).firstElementChild; + el.querySelectorAll('[data-t]').forEach(function(n){ n.textContent=tr(n.dataset.t); }); + el.querySelectorAll('[data-ph]').forEach(function(n){ n.placeholder=tr(n.dataset.ph); }); + el.querySelectorAll('[data-val]').forEach(function(n){ n.value=tr(n.dataset.val); }); + return el; } // ── Overlays ────────────────────────────────────────────────────────────────── @@ -176,14 +362,14 @@ function showLogin(err){ c.querySelector('.lf-fgt').onclick=function(){showForgotPassword();}; sub.onclick=function(){ var ev=email.value.trim(), pv=pass.value; - if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent='Bitte E-Mail und Passwort eingeben'; errEl.style.display=''; return; } + if(!ev||!pv){ var errEl=c.querySelector('.login-err'); errEl.textContent=tr('loginErrEmpty'); errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('POST','login',{email:ev,password:pv}) .then(function(){ return loadGoals(); }) .then(function(g){ goals=g; closeOv(); render(); }) .catch(function(err){ - sub.disabled=false; sub.textContent='Anmelden'; - showLogin(err.status===401?'Falsche E-Mail oder Passwort':err.status===429?'Zu viele Versuche':'Verbindungsfehler'); + sub.disabled=false; sub.textContent=tr('loginBtn'); + showLogin(err.status===401?tr('loginErrWrong'):err.status===429?tr('loginErrRate'):tr('loginErrConn')); }); }; } @@ -206,7 +392,7 @@ function showForgotPassword(){ showSheet(conf,false); }) .catch(function(err){ - sub.disabled=false; sub.textContent='Link senden'; + sub.disabled=false; sub.textContent=tr('sendLink'); errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; @@ -229,7 +415,7 @@ function showResetPassword(selector,token){ showSheet(conf,false); }) .catch(function(err){ - sub.disabled=false; sub.textContent='Passwort setzen'; + sub.disabled=false; sub.textContent=tr('setPw'); errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; @@ -247,12 +433,12 @@ function showChangePassword(){ sub.onclick=function(){ var o=oldP.value, n=newP.value, n2=newP2.value; if(!o||!n||!n2) return; - if(n!==n2){ errEl.textContent='Die neuen Passwörter stimmen nicht überein'; errEl.style.display=''; return; } + if(n!==n2){ errEl.textContent=tr('errPwMismatch'); errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('POST','change-password',{old_password:o,new_password:n}) - .then(function(){ showToast('Passwort geändert'); closeOv(); }) + .then(function(){ showToast(tr('pwChanged')); closeOv(); }) .catch(function(err){ - sub.disabled=false; sub.textContent='Ändern'; + sub.disabled=false; sub.textContent=tr('changePwBtn'); errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; @@ -271,18 +457,18 @@ function showRegister(token){ email.onkeydown=function(e){if(e.key==='Enter')pass.focus();}; pass.onkeydown=function(e){if(e.key==='Enter')pass2.focus();}; pass2.onkeydown=function(e){if(e.key==='Enter')sub.click();}; - function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; } else { errEl.style.display='none'; } } + function checkMatch(){ if(pass2.value&&pass.value!==pass2.value){ errEl.textContent=tr('errPwMismatch2'); errEl.style.display=''; } else { errEl.style.display='none'; } } pass.oninput=checkMatch; pass2.oninput=checkMatch; sub.onclick=function(){ var nv=nameInp.value.trim(), ev=email.value.trim(), pv=pass.value; - if(!nv||!ev||!pv){ errEl.textContent='Bitte alle Felder ausfüllen'; errEl.style.display=''; return; } - if(pv!==pass2.value){ errEl.textContent='Passwörter stimmen nicht überein'; errEl.style.display=''; return; } + if(!nv||!ev||!pv){ errEl.textContent=tr('errFillAll'); errEl.style.display=''; return; } + if(pv!==pass2.value){ errEl.textContent=tr('errPwMismatch2'); errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('POST','register',{name:nv,email:ev,password:pv,token:token}) .then(function(r){ userName=r.name||''; return loadGoals(); }) .then(function(g){ goals=g; closeOv(); updateHeader(); render(); }) .catch(function(err){ - sub.disabled=false; sub.textContent='Registrieren'; + sub.disabled=false; sub.textContent=tr('registerBtn'); errEl.textContent=err.message||'Fehler'; errEl.style.display=''; }); }; @@ -305,7 +491,7 @@ function openNew(){ setTimeout(function(){name.focus();},50); c.querySelector('.ng-can').onclick=closeOv; sub.onclick=function(){ - var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||'Stück'; + var nv=(name.value||'').trim(), uv=(unit.value||'').trim()||tr('unitDefault'); var dv=parseFloat(daily.value)||1, dyv=parseInt(days.value,10)||30; if(!nv){ name.focus(); return; } sub.disabled=true; @@ -316,7 +502,7 @@ function openNew(){ }).catch(function(e){ sub.disabled=false; if(e.status===401){ closeOv(); showLogin(); } - else showToast('Fehler beim Erstellen'); + else showToast(tr('errCreate')); }); }; } @@ -336,11 +522,11 @@ function openData(){ nc.querySelector('.cn-can').onclick=closeOv; sub.onclick=function(){ var nv=inp.value.trim(); - if(!nv){ errEl.textContent='Name darf nicht leer sein'; errEl.style.display=''; return; } + if(!nv){ errEl.textContent=tr('errNameEmpty'); errEl.style.display=''; return; } sub.disabled=true; sub.textContent='…'; api('PATCH','me',{name:nv}) - .then(function(r){ userName=r.name; closeOv(); render(); showToast('Name gespeichert'); }) - .catch(function(){ sub.disabled=false; sub.textContent='Speichern'; showToast('Fehler beim Speichern'); }); + .then(function(r){ userName=r.name; closeOv(); render(); showToast(tr('nameSaved')); }) + .catch(function(){ sub.disabled=false; sub.textContent=tr('save'); showToast(tr('errNameSave')); }); }; }; c.querySelector('.dm-cpw').onclick=function(){ closeOv(); showChangePassword(); }; @@ -359,38 +545,38 @@ function openData(){ btn.disabled=true; btn.textContent='…'; api('POST','invite',{note:note}).then(function(res){ var lc=tpl('tpl-invite-link'); - lc.querySelector('.stitle').textContent='Einladungslink'+(note?' für '+note:''); + lc.querySelector('.stitle').textContent=tr('inviteLinkTitle')+(note?' — '+note:''); var urlInp=lc.querySelector('.il-url'); urlInp.value=res.url; showSheet(lc,true); lc.querySelector('.il-close').onclick=closeOv; lc.querySelector('.il-copy').onclick=function(){ - navigator.clipboard.writeText(res.url).then(function(){ showToast('Link kopiert!'); closeOv(); }); + navigator.clipboard.writeText(res.url).then(function(){ showToast(tr('linkCopied')); closeOv(); }); }; setTimeout(function(){urlInp.select();},50); }).catch(function(){ - btn.disabled=false; btn.textContent='Link generieren'; - showToast('Fehler beim Generieren'); + btn.disabled=false; btn.textContent=tr('generateLink'); + showToast(tr('errGenerate')); }); }; }; c.querySelector('.dm-invlist').onclick=function(){ api('GET','invites').then(function(list){ - var statusLabel={'pending':'Ausstehend','used':'Angenommen','expired':'Abgelaufen'}; + var statusLabel={'pending':tr('statusPending'),'used':tr('statusUsed'),'expired':tr('statusExpired')}; var statusColor={'pending':'var(--amber)','used':'var(--green)','expired':'var(--red)'}; var lc=tpl('tpl-invite-list'); var body=lc.querySelector('.dpanel-body'); if(!list.length){ var empty=document.createElement('div'); empty.className='nosets'; empty.style.padding='16px'; - empty.textContent='Noch keine Einladungen verschickt'; + empty.textContent=tr('noInvites'); body.appendChild(empty); } else { for(var i=0;i false], Response::HTTP_UNAUTHORIZED); } return new JsonResponse([ - 'ok' => true, - 'email' => $user->getEmail(), - 'id' => $user->getId(), - 'name' => $user->getUsername() ?? '', + 'ok' => true, + 'email' => $user->getEmail(), + 'id' => $user->getId(), + 'name' => $user->getUsername() ?? '', + 'locale' => $user->getLocale(), ]); } @@ -47,6 +48,18 @@ class AuthController extends AbstractController return new JsonResponse(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED); } $data = json_decode($request->getContent(), true) ?? []; + $allowed = ['de', 'en', 'pl']; + + if (isset($data['locale'])) { + $locale = $data['locale']; + if (!in_array($locale, $allowed, true)) { + return new JsonResponse(['error' => 'Ungültige Sprache'], Response::HTTP_BAD_REQUEST); + } + $user->setLocale($locale); + $this->em->flush(); + return new JsonResponse(['ok' => true, 'locale' => $locale]); + } + $name = trim($data['name'] ?? ''); if (!$name) { return new JsonResponse(['error' => 'Name fehlt'], Response::HTTP_BAD_REQUEST); diff --git a/src/Entity/User.php b/src/Entity/User.php index 0de5a52..681d25b 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -34,6 +34,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: 'integer', nullable: true, options: ['unsigned' => true])] private ?int $lastLogin = null; + #[ORM\Column(length: 2, nullable: true)] + private ?string $locale = null; + public function __construct() { $this->registered = time(); @@ -51,6 +54,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function getRegistered(): int { return $this->registered; } public function getLastLogin(): ?int { return $this->lastLogin; } public function setLastLogin(?int $lastLogin): static { $this->lastLogin = $lastLogin; return $this; } + public function getLocale(): ?string { return $this->locale; } + public function setLocale(?string $locale): static { $this->locale = $locale; return $this; } // UserInterface public function getUserIdentifier(): string { return $this->email; } diff --git a/templates/app.html.twig b/templates/app.html.twig index 527f87c..9d09a69 100644 --- a/templates/app.html.twig +++ b/templates/app.html.twig @@ -29,11 +29,11 @@ @@ -76,7 +76,7 @@
- + @@ -97,7 +97,7 @@
-
Noch T · endet · heute:
total:
+
· · :
:
@@ -112,7 +112,7 @@
-
Noch T · endet
+
·
@@ -126,34 +126,34 @@
-
Heute
+
-
Gemacht
+
-
Tagesziel
+
-
Noch
+
-
Verlauf — heute & gestern bearbeitbar
+
- Puffer - Erreicht - Teilweise - Verpasst + + + +
-
+
@@ -165,139 +165,145 @@ @@ -305,27 +311,27 @@