Add DE/EN/PL i18n with browser-language detection and per-user override
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
8729b0d1ed
commit
9d4c710d2f
6 changed files with 385 additions and 137 deletions
21
migrations/Version20260430100000.php
Normal file
21
migrations/Version20260430100000.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260430100000 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
300
public/app.js
300
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<list.length;i++){
|
||||
var inv=list[i];
|
||||
var label=inv.note||new Date(inv.created_at).toLocaleDateString('de-DE',{day:'numeric',month:'short',year:'numeric'});
|
||||
var detail=inv.used_by_email?('→ '+inv.used_by_email):(inv.status==='pending'?'läuft ab: '+new Date(inv.expires_at).toLocaleDateString('de-DE',{day:'numeric',month:'short'}):'');
|
||||
var label=inv.note||new Date(inv.created_at).toLocaleDateString(ldoc(),{day:'numeric',month:'short',year:'numeric'});
|
||||
var detail=inv.used_by_email?(tr('acceptedBy')+' '+inv.used_by_email):(inv.status==='pending'?tr('expiresAt')+' '+new Date(inv.expires_at).toLocaleDateString(ldoc(),{day:'numeric',month:'short'}):'');
|
||||
var row=tpl('tpl-invite-row');
|
||||
row.querySelector('.ir-label').textContent=label;
|
||||
if(detail) row.querySelector('.ir-detail').textContent=' '+detail;
|
||||
|
|
@ -398,14 +584,14 @@ function openData(){
|
|||
st.textContent=statusLabel[inv.status]; st.style.color=statusColor[inv.status];
|
||||
if(inv.url){
|
||||
var cp=row.querySelector('.ir-copy'); cp.style.display='';
|
||||
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast('Link kopiert!'); }); }; }(inv.url);
|
||||
cp.onclick=function(url){ return function(){ navigator.clipboard.writeText(url).then(function(){ showToast(tr('linkCopied')); }); }; }(inv.url);
|
||||
}
|
||||
body.appendChild(row);
|
||||
}
|
||||
}
|
||||
showSheet(lc,true);
|
||||
lc.querySelector('.il-close').onclick=closeOv;
|
||||
}).catch(function(){ showToast('Fehler beim Laden'); });
|
||||
}).catch(function(){ showToast(tr('errLoad')); });
|
||||
};
|
||||
|
||||
c.querySelector('.dm-exp').onclick=function(){
|
||||
|
|
@ -421,23 +607,28 @@ function openData(){
|
|||
var r=new FileReader(); r.onload=function(ev){
|
||||
try{
|
||||
var p=JSON.parse(ev.target.result);
|
||||
if(!p.goals||!Array.isArray(p.goals)) throw new Error('Ungültiges Format');
|
||||
if(!confirm(p.goals.length+' Ziel(e) importieren?')) return;
|
||||
if(!p.goals||!Array.isArray(p.goals)) throw new Error(tr('invalidFormat'));
|
||||
if(!confirm(tr('confirmImport').replace('{n}',p.goals.length))) return;
|
||||
var promises=p.goals.map(function(g){
|
||||
return api('POST','goals',{name:g.name,unit:g.unit,daily:g.daily,days:g.days,start:g.start,sets:g.sets||{}})
|
||||
.then(function(r){ goals.push({id:r.id,name:r.name,unit:r.unit,daily:r.daily,days:r.days,start:r.start,sets:r.sets||{}}); });
|
||||
});
|
||||
Promise.all(promises).then(function(){ closeOv(); render(); alert(p.goals.length+' Ziel(e) importiert.'); });
|
||||
}catch(err){ alert('Fehler: '+err.message); }
|
||||
Promise.all(promises).then(function(){ closeOv(); render(); alert(tr('importDone').replace('{n}',p.goals.length)); });
|
||||
}catch(err){ alert(err.message); }
|
||||
}; r.readAsText(f);
|
||||
}; inp.click();
|
||||
};
|
||||
|
||||
c.querySelectorAll('.btn-lang').forEach(function(b){
|
||||
if(b.dataset.lang===LOCALE) b.classList.add('active');
|
||||
b.onclick=function(){ setLocale(this.dataset.lang,true); closeOv(); };
|
||||
});
|
||||
|
||||
c.querySelector('.dm-clr').onclick=function(){
|
||||
if(!confirm('Alle Daten löschen?')) return;
|
||||
if(!confirm(tr('confirmClear'))) return;
|
||||
var ids=goals.map(function(g){return g.id;}); goals=[]; render();
|
||||
Promise.all(ids.map(function(id){return api('DELETE','goals/'+id);}))
|
||||
.catch(function(){ showToast('Fehler beim Löschen'); });
|
||||
.catch(function(){ showToast(tr('errDelete')); });
|
||||
closeOv();
|
||||
};
|
||||
}
|
||||
|
|
@ -459,7 +650,7 @@ function buildNameWrap(g){
|
|||
|
||||
function buildPanel(g,off){
|
||||
var t=tOff(g), sets=g.sets[String(off)]||[], tot=dTot(g,off);
|
||||
var lbl=off===t?'Heute':'Gestern', k=g.id+'_'+off;
|
||||
var lbl=off===t?tr('heute'):tr('gestern'), k=g.id+'_'+off;
|
||||
var el=tpl('tpl-panel');
|
||||
el.querySelector('.dpanel-title').textContent=lbl+' — '+fd(o2d(g,off));
|
||||
el.querySelector('.dpanel-sub').textContent=tot+' / '+g.daily+' '+g.unit;
|
||||
|
|
@ -526,8 +717,8 @@ function buildCard(g){
|
|||
el.querySelector('.m-end').textContent=fs(c.end);
|
||||
var badge=el.querySelector('.badge'); badge.className='badge '+bc; badge.textContent=bt;
|
||||
var fill=el.querySelector('.prog-fill'); fill.style.width=c.pct+'%'; fill.style.background=fc;
|
||||
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' gemacht';
|
||||
el.querySelector('.pr-pct').textContent=c.pct+'% von '+c.tot;
|
||||
el.querySelector('.pr-done').textContent=c.done+' '+g.unit+' '+tr('doneLabel');
|
||||
el.querySelector('.pr-pct').textContent=c.pct+'% '+tr('ofLabel')+' '+c.tot;
|
||||
el.querySelector('.sv-tdone').textContent=c.tdone;
|
||||
el.querySelector('.sv-daily').textContent=g.daily;
|
||||
el.querySelector('.sv-st').textContent=c.st;
|
||||
|
|
@ -556,7 +747,7 @@ function buildQuickBook(){
|
|||
var active=goals.filter(function(g){ var c=calc(g); return tOff(g)<g.days&&!c.ok; });
|
||||
if(!active.length) return null;
|
||||
var frag=document.createDocumentFragment();
|
||||
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent='Quick-Buchen';
|
||||
var lbl=document.createElement('div'); lbl.className='sec-lbl'; lbl.textContent=tr('qbLabel');
|
||||
frag.appendChild(lbl);
|
||||
var card=document.createElement('div'); card.className='card qb-card';
|
||||
for(var i=0;i<active.length;i++){
|
||||
|
|
@ -615,7 +806,7 @@ function render(){
|
|||
}
|
||||
|
||||
if(userName){
|
||||
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent='Hallo '+userName+'!';
|
||||
var gr=document.createElement('div'); gr.className='greeting'; gr.textContent=tr('hello').replace('{n}',userName);
|
||||
frag.appendChild(gr);
|
||||
}
|
||||
|
||||
|
|
@ -627,12 +818,12 @@ function render(){
|
|||
if(c.ok) done.push(g); else open.push(g);
|
||||
}
|
||||
if(open.length){
|
||||
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent='Offen';
|
||||
var sl=document.createElement('div'); sl.className='sec-lbl'; sl.textContent=tr('openLabel');
|
||||
frag.appendChild(sl);
|
||||
for(var i=0;i<open.length;i++) frag.appendChild(buildCard(open[i]));
|
||||
}
|
||||
if(done.length){
|
||||
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent='Heute erledigt';
|
||||
var sl2=document.createElement('div'); sl2.className='sec-lbl'; sl2.textContent=tr('doneToday');
|
||||
frag.appendChild(sl2);
|
||||
for(var j=0;j<done.length;j++) frag.appendChild(buildCard(done[j]));
|
||||
}
|
||||
|
|
@ -678,7 +869,7 @@ function wire(){
|
|||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function updateHeader(){
|
||||
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long'});
|
||||
document.getElementById('tlbl').textContent=TODAY.toLocaleDateString(ldoc(),{weekday:'long',day:'numeric',month:'long'});
|
||||
}
|
||||
|
||||
document.getElementById('btnNew').onclick=openNew;
|
||||
|
|
@ -691,14 +882,23 @@ var resetSelector=_qs.get('reset_selector');
|
|||
var resetToken=_qs.get('reset_token');
|
||||
if(inviteToken||resetSelector) history.replaceState(null,'',location.pathname);
|
||||
|
||||
function applyLocale(userLocale){
|
||||
var lang = userLocale || localStorage.getItem('zt_locale');
|
||||
if(!lang){
|
||||
var nav = (navigator.language||'').slice(0,2).toLowerCase();
|
||||
if(STRINGS[nav]) lang = nav;
|
||||
}
|
||||
if(lang && STRINGS[lang]) LOCALE = lang;
|
||||
}
|
||||
|
||||
if(resetSelector&&resetToken){
|
||||
render(); showResetPassword(resetSelector,resetToken);
|
||||
applyLocale(null); render(); showResetPassword(resetSelector,resetToken);
|
||||
} else {
|
||||
api('GET','me')
|
||||
.then(function(r){ userName=r.name||''; updateHeader(); return loadGoals(); })
|
||||
.then(function(r){ userName=r.name||''; applyLocale(r.locale); updateHeader(); return loadGoals(); })
|
||||
.then(function(g){ goals=g; render(); })
|
||||
.catch(function(){
|
||||
render();
|
||||
applyLocale(null); render();
|
||||
if(inviteToken){ showRegister(inviteToken); }
|
||||
else { showLogin(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -115,3 +115,6 @@ body{font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);min
|
|||
.hint{margin:0 16px 12px;background:var(--blue-bg);border:1px solid rgba(37,99,235,.15);border-radius:var(--rs);padding:10px 12px;font-size:12px;color:var(--blue);line-height:1.5;display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.hclose{background:none;border:none;color:var(--blue);cursor:pointer;font-size:16px;padding:0 2px}
|
||||
.btn-lnk{background:none;border:none;color:var(--text2);font-size:13px;cursor:pointer;padding:4px 8px;text-decoration:underline;text-underline-offset:2px}
|
||||
.lang-row{display:flex;gap:8px;margin-bottom:8px}
|
||||
.btn-lang{flex:1;padding:8px;border-radius:var(--rs);background:var(--bg3);color:var(--text2);border:1px solid var(--border);cursor:pointer;font-family:'DM Mono',monospace;font-size:13px;font-weight:600;letter-spacing:.5px}
|
||||
.btn-lang.active{background:var(--text);color:var(--bg);border-color:var(--text)}
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ class AuthController extends AbstractController
|
|||
return new JsonResponse(['ok' => 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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@
|
|||
<!-- ── Templates ──────────────────────────────────────────────────────────── -->
|
||||
|
||||
<template id="tpl-hint">
|
||||
<div class="hint">Menü → "Zum Startbildschirm" für App-Icon<button class="hclose">×</button></div>
|
||||
<div class="hint"><span data-t="hint"></span><button class="hclose">×</button></div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-empty">
|
||||
<div class="empty"><div style="font-size:40px;opacity:.4;margin-bottom:12px">🎯</div>Noch keine Ziele.<br>Tippe auf <strong>+</strong> um zu starten.</div>
|
||||
<div class="empty"><div style="font-size:40px;opacity:.4;margin-bottom:12px">🎯</div><span data-t="emptyLine1"></span><br><span data-t="emptyLine2"></span></div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-dot">
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
</template>
|
||||
|
||||
<template id="tpl-nosets">
|
||||
<div class="nosets">Noch kein Eintrag</div>
|
||||
<div class="nosets" data-t="noEntry"></div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-set-row">
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
<input class="num-in" type="number" min="1"/>
|
||||
<span class="ulbl"></span>
|
||||
<button class="btn-sw-fill" style="display:none">⏱</button>
|
||||
<button class="btn-as">Eintragen</button>
|
||||
<button class="btn-as" data-t="log"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
<div class="qb-stat"></div>
|
||||
<input class="num-in" type="number" min="1"/>
|
||||
<button class="btn-sw-fill" style="display:none">⏱</button>
|
||||
<button class="btn-as">Eintragen</button>
|
||||
<button class="btn-as" data-t="log"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-bd" style="flex:1;min-width:0">
|
||||
<div class="goal-meta">Noch <span class="m-dr"></span>T · endet <span class="m-end"></span> · heute: <span class="m-heute"></span><br>total: <span class="m-total"></span></div>
|
||||
<div class="goal-meta"><span data-t="noch"></span> <span class="m-dr"></span><span data-t="dAbbr"></span> · <span data-t="endet"></span> <span class="m-end"></span> · <span data-t="todayShort"></span>: <span class="m-heute"></span><br><span data-t="total"></span>: <span class="m-total"></span></div>
|
||||
</div>
|
||||
<span class="badge"></span>
|
||||
<span class="chevron">▸</span>
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-bd" style="flex:1;min-width:0">
|
||||
<div class="goal-meta">Noch <span class="m-dr"></span>T · endet <span class="m-end"></span></div>
|
||||
<div class="goal-meta"><span data-t="noch"></span> <span class="m-dr"></span><span data-t="dAbbr"></span> · <span data-t="endet"></span> <span class="m-end"></span></div>
|
||||
</div>
|
||||
<span class="badge"></span>
|
||||
<span class="chevron">▴</span>
|
||||
|
|
@ -126,34 +126,34 @@
|
|||
</div>
|
||||
<div class="heute-stats">
|
||||
<div class="heute-group">
|
||||
<div class="heute-lbl">Heute</div>
|
||||
<div class="heute-lbl" data-t="todayHeading"></div>
|
||||
<div class="heute-inner">
|
||||
<div class="stat">
|
||||
<div class="slbl">Gemacht</div>
|
||||
<div class="slbl" data-t="done2"></div>
|
||||
<div class="sval"><span class="sv-tdone"></span><div class="sunit"></div></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="slbl">Tagesziel</div>
|
||||
<div class="slbl" data-t="dailyGoal"></div>
|
||||
<div class="sval"><span class="sv-daily"></span><div class="sunit"></div></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="slbl">Noch</div>
|
||||
<div class="slbl" data-t="remaining"></div>
|
||||
<div class="sval sv-noch"><span class="sv-st"></span><div class="sunit"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dots-sec">
|
||||
<div class="dots-lbl">Verlauf — heute & gestern bearbeitbar</div>
|
||||
<div class="dots-lbl" data-t="history"></div>
|
||||
<div class="dots-wrap"></div>
|
||||
<div class="legend">
|
||||
<span class="leg"><span class="ldot" style="background:rgba(37,99,235,.3)"></span>Puffer</span>
|
||||
<span class="leg"><span class="ldot" style="background:rgba(22,163,74,.3)"></span>Erreicht</span>
|
||||
<span class="leg"><span class="ldot" style="background:rgba(217,119,6,.3)"></span>Teilweise</span>
|
||||
<span class="leg"><span class="ldot" style="background:rgba(220,38,38,.3)"></span>Verpasst</span>
|
||||
<span class="leg"><span class="ldot" style="background:rgba(37,99,235,.3)"></span><span data-t="legBuf"></span></span>
|
||||
<span class="leg"><span class="ldot" style="background:rgba(22,163,74,.3)"></span><span data-t="legDone"></span></span>
|
||||
<span class="leg"><span class="ldot" style="background:rgba(217,119,6,.3)"></span><span data-t="legPartial"></span></span>
|
||||
<span class="leg"><span class="ldot" style="background:rgba(220,38,38,.3)"></span><span data-t="legMissed"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot"><button class="btn-del">Ziel löschen</button></div>
|
||||
<div class="card-foot"><button class="btn-del" data-t="delGoal"></button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -165,139 +165,145 @@
|
|||
|
||||
<template id="tpl-login">
|
||||
<div>
|
||||
<div class="stitle">Anmelden</div>
|
||||
<div class="stitle" data-t="loginTitle"></div>
|
||||
<div class="ssub">Dudi</div>
|
||||
<div class="login-err" style="display:none"></div>
|
||||
<div class="ff"><label>E-Mail</label><input class="fi lf-email" type="email" autocomplete="email"/></div>
|
||||
<div class="ff"><label>Passwort</label><input class="fi lf-pass" type="password" autocomplete="current-password"/></div>
|
||||
<div class="factions"><button class="btn-p lf-sub">Anmelden</button></div>
|
||||
<div style="text-align:center;margin-top:8px"><button class="btn-lnk lf-fgt">Passwort vergessen?</button></div>
|
||||
<div class="ff"><label data-t="emailLabel"></label><input class="fi lf-email" type="email" autocomplete="email"/></div>
|
||||
<div class="ff"><label data-t="passwordLabel"></label><input class="fi lf-pass" type="password" autocomplete="current-password"/></div>
|
||||
<div class="factions"><button class="btn-p lf-sub" data-t="loginBtn"></button></div>
|
||||
<div style="text-align:center;margin-top:8px"><button class="btn-lnk lf-fgt" data-t="forgotPw"></button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-forgot-pw">
|
||||
<div>
|
||||
<div class="stitle">Passwort vergessen</div>
|
||||
<div class="ssub">Wir schicken dir einen Reset-Link</div>
|
||||
<div class="ff"><label>E-Mail</label><input class="fi fp-email" type="email" autocomplete="email"/></div>
|
||||
<div class="stitle" data-t="forgotTitle"></div>
|
||||
<div class="ssub" data-t="forgotSub"></div>
|
||||
<div class="ff"><label data-t="emailLabel"></label><input class="fi fp-email" type="email" autocomplete="email"/></div>
|
||||
<div class="login-err" style="display:none"></div>
|
||||
<div class="factions">
|
||||
<button class="btn-p fp-sub">Link senden</button>
|
||||
<button class="btn-c fp-back">Zurück</button>
|
||||
<button class="btn-p fp-sub" data-t="sendLink"></button>
|
||||
<button class="btn-c fp-back" data-t="back"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-email-sent">
|
||||
<div>
|
||||
<div class="stitle">E-Mail gesendet</div>
|
||||
<div class="ssub">Falls die Adresse bekannt ist, erhältst du in Kürze einen Reset-Link.</div>
|
||||
<div class="factions"><button class="btn-p es-ok">OK</button></div>
|
||||
<div class="stitle" data-t="emailSentTitle"></div>
|
||||
<div class="ssub" data-t="emailSentSub"></div>
|
||||
<div class="factions"><button class="btn-p es-ok" data-t="ok"></button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-reset-pw">
|
||||
<div>
|
||||
<div class="stitle">Neues Passwort</div>
|
||||
<div class="ff"><label>Neues Passwort</label><input class="fi rp-pass" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||
<div class="stitle" data-t="resetTitle"></div>
|
||||
<div class="ff"><label data-t="newPwLabel"></label><input class="fi rp-pass" type="password" autocomplete="new-password" data-ph="min8"/></div>
|
||||
<div class="login-err" style="display:none"></div>
|
||||
<div class="factions"><button class="btn-p rp-sub">Passwort setzen</button></div>
|
||||
<div class="factions"><button class="btn-p rp-sub" data-t="setPw"></button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-pw-changed">
|
||||
<div>
|
||||
<div class="stitle">Passwort geändert</div>
|
||||
<div class="ssub">Du kannst dich jetzt anmelden.</div>
|
||||
<div class="factions"><button class="btn-p pc-ok">Anmelden</button></div>
|
||||
<div class="stitle" data-t="pwChangedTitle"></div>
|
||||
<div class="ssub" data-t="pwChangedSub"></div>
|
||||
<div class="factions"><button class="btn-p pc-ok" data-t="loginBtn"></button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-change-name">
|
||||
<div>
|
||||
<div class="stitle">Name ändern</div>
|
||||
<div class="ff"><label>Dein Name</label><input class="fi cn-name" type="text" autocomplete="name"/></div>
|
||||
<div class="stitle" data-t="changeNameTitle"></div>
|
||||
<div class="ff"><label data-t="yourName"></label><input class="fi cn-name" type="text" autocomplete="name"/></div>
|
||||
<div class="login-err" style="display:none"></div>
|
||||
<div class="factions">
|
||||
<button class="btn-p cn-sub">Speichern</button>
|
||||
<button class="btn-c cn-can">Abbrechen</button>
|
||||
<button class="btn-p cn-sub" data-t="save"></button>
|
||||
<button class="btn-c cn-can" data-t="cancel"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-change-pw">
|
||||
<div>
|
||||
<div class="stitle">Passwort ändern</div>
|
||||
<div class="ff"><label>Aktuelles Passwort</label><input class="fi cp-old" type="password" autocomplete="current-password"/></div>
|
||||
<div class="ff"><label>Neues Passwort</label><input class="fi cp-new" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||
<div class="ff"><label>Neues Passwort bestätigen</label><input class="fi cp-new2" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||
<div class="stitle" data-t="changePwTitle"></div>
|
||||
<div class="ff"><label data-t="currentPwLabel"></label><input class="fi cp-old" type="password" autocomplete="current-password"/></div>
|
||||
<div class="ff"><label data-t="newPwLabel"></label><input class="fi cp-new" type="password" autocomplete="new-password" data-ph="min8"/></div>
|
||||
<div class="ff"><label data-t="newPwConfLabel"></label><input class="fi cp-new2" type="password" autocomplete="new-password" data-ph="min8"/></div>
|
||||
<div class="login-err" style="display:none"></div>
|
||||
<div class="factions">
|
||||
<button class="btn-p cp-sub">Ändern</button>
|
||||
<button class="btn-c cp-can">Abbrechen</button>
|
||||
<button class="btn-p cp-sub" data-t="changePwBtn"></button>
|
||||
<button class="btn-c cp-can" data-t="cancel"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-register">
|
||||
<div>
|
||||
<div class="stitle">Konto erstellen</div>
|
||||
<div class="ssub">Du wurdest eingeladen</div>
|
||||
<div class="ff"><label>Dein Name</label><input class="fi rg-name" type="text" autocomplete="name" placeholder="Wie sollen wir dich nennen?"/></div>
|
||||
<div class="ff"><label>E-Mail</label><input class="fi rg-email" type="email" autocomplete="email"/></div>
|
||||
<div class="ff"><label>Passwort</label><input class="fi rg-pass" type="password" autocomplete="new-password" placeholder="mind. 8 Zeichen"/></div>
|
||||
<div class="ff"><label>Passwort bestätigen</label><input class="fi rg-pass2" type="password" autocomplete="new-password" placeholder="Passwort wiederholen"/></div>
|
||||
<div class="stitle" data-t="registerTitle"></div>
|
||||
<div class="ssub" data-t="registerSub"></div>
|
||||
<div class="ff"><label data-t="yourName"></label><input class="fi rg-name" type="text" autocomplete="name" data-ph="namePlaceholder"/></div>
|
||||
<div class="ff"><label data-t="emailLabel"></label><input class="fi rg-email" type="email" autocomplete="email"/></div>
|
||||
<div class="ff"><label data-t="passwordLabel"></label><input class="fi rg-pass" type="password" autocomplete="new-password" data-ph="min8"/></div>
|
||||
<div class="ff"><label data-t="pwConfLabel"></label><input class="fi rg-pass2" type="password" autocomplete="new-password" data-ph="pwPlaceholder"/></div>
|
||||
<div class="login-err" style="display:none"></div>
|
||||
<div class="factions"><button class="btn-p rg-sub">Registrieren</button></div>
|
||||
<div class="factions"><button class="btn-p rg-sub" data-t="registerBtn"></button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-new-goal">
|
||||
<div>
|
||||
<div class="stitle">Neues Ziel</div>
|
||||
<div class="ff"><label>Übung / Gewohnheit</label><input class="fi ng-name" type="text" placeholder="Liegestütz, Plank …"/></div>
|
||||
<div class="stitle" data-t="newGoalTitle"></div>
|
||||
<div class="ff"><label data-t="exerciseLabel"></label><input class="fi ng-name" type="text" data-ph="exercisePlaceholder"/></div>
|
||||
<div class="fgrid">
|
||||
<div class="ff"><label>Einheit</label><input class="fi ng-unit" type="text" value="Stück"/></div>
|
||||
<div class="ff"><label>Dauer in Tagen</label><input class="fi ng-days" type="number" min="7" max="365" value="30"/></div>
|
||||
<div class="ff"><label data-t="unitLabel"></label><input class="fi ng-unit" type="text" data-val="unitDefault"/></div>
|
||||
<div class="ff"><label data-t="daysLabel"></label><input class="fi ng-days" type="number" min="7" max="365" value="30"/></div>
|
||||
</div>
|
||||
<div class="fgrid">
|
||||
<div class="ff"><label>Tagesziel</label><input class="fi ng-daily" type="number" min="0.01" step="any" value="50"/></div>
|
||||
<div class="ff"><label>Wochenziel</label><input class="fi ng-weekly" type="number" min="0.01" step="any" value="350"/></div>
|
||||
<div class="ff"><label data-t="dailyLabel"></label><input class="fi ng-daily" type="number" min="0.01" step="any" value="50"/></div>
|
||||
<div class="ff"><label data-t="weeklyLabel"></label><input class="fi ng-weekly" type="number" min="0.01" step="any" value="350"/></div>
|
||||
</div>
|
||||
<div class="factions">
|
||||
<button class="btn-p ng-sub">Ziel starten</button>
|
||||
<button class="btn-c ng-can">Abbrechen</button>
|
||||
<button class="btn-p ng-sub" data-t="startGoal"></button>
|
||||
<button class="btn-c ng-can" data-t="cancel"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-data-menu">
|
||||
<div>
|
||||
<div class="stitle">Daten verwalten</div>
|
||||
<div class="ssub">Export, Import und Backup</div>
|
||||
<button class="dbtn dm-exp"><span class="dico">⬇</span><span class="dlbl">Exportieren<span class="dsub">Alle Ziele als JSON-Datei speichern</span></span></button>
|
||||
<button class="dbtn dm-imp"><span class="dico">⬆</span><span class="dlbl">Importieren<span class="dsub">Backup laden oder zusammenführen</span></span></button>
|
||||
<div class="stitle" data-t="dataMenuTitle"></div>
|
||||
<div class="ssub" data-t="dataMenuSub"></div>
|
||||
<button class="dbtn dm-exp"><span class="dico">⬇</span><span class="dlbl"><span data-t="exportLabel"></span><span class="dsub" data-t="exportSub"></span></span></button>
|
||||
<button class="dbtn dm-imp"><span class="dico">⬆</span><span class="dlbl"><span data-t="importLabel"></span><span class="dsub" data-t="importSub"></span></span></button>
|
||||
<div class="ddiv"></div>
|
||||
<button class="dbtn dm-inv"><span class="dico">🔗</span><span class="dlbl">Freund einladen<span class="dsub">Einladungslink generieren</span></span></button>
|
||||
<button class="dbtn dm-invlist"><span class="dico">📋</span><span class="dlbl">Meine Einladungen<span class="dsub">Status aller gesendeten Einladungen</span></span></button>
|
||||
<button class="dbtn dm-inv"><span class="dico">🔗</span><span class="dlbl"><span data-t="inviteLabel"></span><span class="dsub" data-t="inviteSub"></span></span></button>
|
||||
<button class="dbtn dm-invlist"><span class="dico">📋</span><span class="dlbl"><span data-t="inviteListLabel"></span><span class="dsub" data-t="inviteListSub"></span></span></button>
|
||||
<div class="ddiv"></div>
|
||||
<button class="dbtn dm-name"><span class="dico">✏️</span><span class="dlbl">Name ändern</span></button>
|
||||
<button class="dbtn dm-cpw"><span class="dico">🔑</span><span class="dlbl">Passwort ändern</span></button>
|
||||
<button class="dbtn dm-lgout"><span class="dico">→</span><span class="dlbl">Abmelden</span></button>
|
||||
<button class="btn-c dm-cls" style="width:100%;margin-top:4px;text-align:center">Schließen</button>
|
||||
<button class="dbtn dm-name"><span class="dico">✏️</span><span class="dlbl" data-t="changeName"></span></button>
|
||||
<button class="dbtn dm-cpw"><span class="dico">🔑</span><span class="dlbl" data-t="changePw"></span></button>
|
||||
<button class="dbtn dm-lgout"><span class="dico">→</span><span class="dlbl" data-t="logout"></span></button>
|
||||
<div class="ddiv"></div>
|
||||
<div class="lang-row">
|
||||
<button class="btn-lang" data-lang="de">DE</button>
|
||||
<button class="btn-lang" data-lang="en">EN</button>
|
||||
<button class="btn-lang" data-lang="pl">PL</button>
|
||||
</div>
|
||||
<button class="btn-c dm-cls" style="width:100%;margin-top:4px;text-align:center" data-t="close"></button>
|
||||
<div class="ddiv" style="margin-top:16px"></div>
|
||||
<button class="dbtn ddanger dm-clr"><span class="dico">✕</span><span class="dlbl">Alle Daten löschen<span class="dsub">Kann nicht rückgängig gemacht werden</span></span></button>
|
||||
<button class="dbtn ddanger dm-clr"><span class="dico">✕</span><span class="dlbl"><span data-t="clearAll"></span><span class="dsub" data-t="clearAllSub"></span></span></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-invite-form">
|
||||
<div>
|
||||
<div class="stitle">Freund einladen</div>
|
||||
<div class="ssub">Link gilt 7 Tage und kann nur einmal verwendet werden</div>
|
||||
<div class="ff"><label>Name (für deine Übersicht)</label><input class="fi inv-name" type="text" placeholder="z.B. Max"/></div>
|
||||
<div class="stitle" data-t="inviteFormTitle"></div>
|
||||
<div class="ssub" data-t="inviteFormSub"></div>
|
||||
<div class="ff"><label data-t="inviteNameLabel"></label><input class="fi inv-name" type="text" data-ph="inviteNamePlaceholder"/></div>
|
||||
<div class="factions">
|
||||
<button class="btn-p inv-gen">Link generieren</button>
|
||||
<button class="btn-c inv-cancel">Abbrechen</button>
|
||||
<button class="btn-p inv-gen" data-t="generateLink"></button>
|
||||
<button class="btn-c inv-cancel" data-t="cancel"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -305,27 +311,27 @@
|
|||
<template id="tpl-invite-link">
|
||||
<div>
|
||||
<div class="stitle"></div>
|
||||
<div class="ssub">Link gilt 7 Tage und kann nur einmal verwendet werden</div>
|
||||
<div class="ssub" data-t="inviteFormSub"></div>
|
||||
<div class="ff"><input class="fi il-url" type="text" readonly/></div>
|
||||
<div class="factions">
|
||||
<button class="btn-p il-copy">Link kopieren</button>
|
||||
<button class="btn-c il-close">Schließen</button>
|
||||
<button class="btn-p il-copy" data-t="copyLink"></button>
|
||||
<button class="btn-c il-close" data-t="close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-invite-list">
|
||||
<div>
|
||||
<div class="stitle">Meine Einladungen</div>
|
||||
<div class="stitle" data-t="inviteListLabel"></div>
|
||||
<div class="dpanel-body"></div>
|
||||
<div class="factions"><button class="btn-c il-close">Schließen</button></div>
|
||||
<div class="factions"><button class="btn-c il-close" data-t="close"></button></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-invite-row">
|
||||
<div class="set-row">
|
||||
<span style="flex:1"><strong class="ir-label"></strong><span class="ir-detail" style="opacity:.6;font-size:.85em"></span></span>
|
||||
<button class="ir-copy btn-lnk" style="font-size:.8em;display:none">🔗 Link</button>
|
||||
<button class="ir-copy btn-lnk" style="font-size:.8em;display:none">🔗 <span data-t="linkLabel"></span></button>
|
||||
<span class="ir-status" style="font-size:.85em;font-weight:600"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in a new issue