banyaro/backend/static/js/pages/settings.js

608 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
BAN YARO — Einstellungen / Account
Login, Registrierung, Logout, Account-Info.
============================================================ */
window.Page_settings = (() => {
let _container = null;
let _appState = null;
let _mode = 'login'; // 'login' | 'register'
// ----------------------------------------------------------
// INIT / REFRESH
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
}
function refresh() {
_render();
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
function _render() {
if (_appState.user) {
_renderAccount();
} else {
_renderAuth(_mode);
}
}
// ----------------------------------------------------------
// EINGELOGGT — Account-Übersicht
// ----------------------------------------------------------
function _renderAccount() {
const u = _appState.user;
// Avatar: Bild oder Buchstabe
const avatarInner = u.avatar_url
? `<img src="${_esc(u.avatar_url)}" alt="Avatar"
style="width:56px;height:56px;border-radius:50%;object-fit:cover;display:block">`
: _esc(u.name.charAt(0).toUpperCase());
// Mitglied seit
const memberSince = (() => {
if (!u.created_at) return '';
const d = new Date(u.created_at);
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
})();
// Erfahrungs-Labels
const erfahrungLabel = {
einsteiger: 'Einsteiger (erster Hund)',
erfahren: 'Erfahrener Hundehalter',
trainer: 'Trainer / Ausbilder',
zuechter: 'Züchter',
};
_container.innerHTML = `
<div style="max-width:400px;margin:0 auto;padding:var(--space-4) 0">
<div class="card" style="padding:var(--space-5);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-4)">
<div id="settings-avatar-btn"
style="width:56px;height:56px;border-radius:50%;
background:var(--c-primary);color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:1.5rem;font-weight:700;flex-shrink:0;
cursor:pointer;overflow:hidden;position:relative">
${avatarInner}
<div style="position:absolute;inset:0;background:rgba(0,0,0,0.25);
display:flex;align-items:center;justify-content:center;
opacity:0;transition:opacity .15s"
class="avatar-overlay">
<svg style="width:20px;height:20px;color:#fff" fill="currentColor" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#camera"></use>
</svg>
</div>
</div>
<input type="file" id="settings-avatar-input" accept="image/*"
style="display:none">
<div>
<div style="font-weight:700;font-size:var(--text-lg)">${_esc(u.name)}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
${u.is_premium
? `<span class="badge badge-primary" style="margin-top:var(--space-1)">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="margin-top:var(--space-1);
color:var(--c-text-secondary)">
Kostenlos
</span>`}
</div>
</div>
</div>
<!-- Mein Profil -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between">
<span>Mein Profil</span>
<button id="settings-profile-edit-btn"
class="btn btn-ghost"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
Profil bearbeiten
</button>
</div>
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-3)">
${memberSince
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Mitglied seit ${_esc(memberSince)}
</div>`
: ''}
${u.bio
? `<div style="font-size:var(--text-sm)">${_esc(u.bio)}</div>`
: ''}
${u.wohnort
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
📍 ${_esc(u.wohnort)}
</div>`
: ''}
${u.erfahrung && erfahrungLabel[u.erfahrung]
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
${_esc(erfahrungLabel[u.erfahrung])}
</div>`
: ''}
${u.social_link
? `<div style="font-size:var(--text-sm)">
<a href="${_esc(u.social_link)}" target="_blank" rel="noopener"
style="color:var(--c-primary)">${_esc(u.social_link)}</a>
</div>`
: ''}
${!u.bio && !u.wohnort && !u.erfahrung && !u.social_link
? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
Noch kein Profil ausgefüllt.
</div>`
: ''}
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
<span>Hunde-Profile</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-push-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
<span>Push-Benachrichtigungen</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-calendar-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg>
<span>Kalender abonnieren</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
<span>Abmelden</span>
</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
App-Einstellungen
</div>
<div class="card-body" style="padding:0">
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#eye-slash"></use></svg>
<div style="flex:1">
<div style="font-weight:500">Pocket-Modus beim Aufzeichnen</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Schwarzes Overlay hält den Bildschirm aktiv (GPS läuft) — ideal für die Hosentasche.
Helligkeit auf Minimum reduzieren für optimalen Akku-Schutz.
</div>
</div>
<label class="toggle" style="flex-shrink:0">
<input type="checkbox" id="toggle-pocket-mode"
${localStorage.getItem('by_pocket_mode') === 'true' ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- App installieren -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
App installieren
</div>
<div class="card-body" style="padding:0">
<div class="sidebar-item" id="settings-install-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
<span>Installations-Anleitung</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
</div>
</div>
<div style="text-align:center;color:var(--c-text-secondary);
font-size:var(--text-xs)">
Ban Yaro · banyaro.app<br>
Deine Daten liegen auf einem eigenen Server in Deutschland.
</div>
</div>
`;
// Avatar-Hover-Overlay
const avatarBtn = document.getElementById('settings-avatar-btn');
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
if (avatarBtn && avatarOverlay) {
avatarBtn.addEventListener('mouseenter', () => { avatarOverlay.style.opacity = '1'; });
avatarBtn.addEventListener('mouseleave', () => { avatarOverlay.style.opacity = '0'; });
}
// Avatar-Upload
avatarBtn?.addEventListener('click', () => {
document.getElementById('settings-avatar-input')?.click();
});
document.getElementById('settings-avatar-input')?.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
try {
const fd = new FormData();
fd.append('file', file);
const res = await API.post('/api/profile/avatar', fd);
_appState.user.avatar_url = res.avatar_url;
UI.toast.success('Avatar aktualisiert.');
_render();
} catch {
UI.toast.error('Avatar-Upload fehlgeschlagen.');
}
});
// Profil bearbeiten
document.getElementById('settings-profile-edit-btn')?.addEventListener('click', () => {
const u = _appState.user;
const inputStyle = `width:100%;box-sizing:border-box;padding:var(--space-2) var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-surface);color:var(--c-text)`;
const erfahrungOpts = [
['', 'Bitte wählen...'],
['einsteiger', 'Einsteiger (erster Hund)'],
['erfahren', 'Erfahrener Hundehalter'],
['trainer', 'Trainer / Ausbilder'],
['zuechter', 'Züchter'],
].map(([val, label]) =>
`<option value="${_esc(val)}" ${u.erfahrung === val ? 'selected' : ''}>${_esc(label)}</option>`
).join('');
const sichtbarkeitOpts = [
['public', 'Öffentlich'],
['friends', 'Nur Freunde'],
['private', 'Privat'],
].map(([val, label]) =>
`<option value="${_esc(val)}" ${(u.profil_sichtbarkeit || 'public') === val ? 'selected' : ''}>${_esc(label)}</option>`
).join('');
UI.modal.open({
title: 'Profil bearbeiten',
body: `
<form id="profile-form" style="display:flex;flex-direction:column;gap:var(--space-4)">
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Bio</label>
<textarea name="bio" maxlength="300" rows="4"
placeholder="Kurze Vorstellung (max. 300 Zeichen)"
style="${inputStyle};resize:vertical">${_esc(u.bio || '')}</textarea>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Wohnort</label>
<input name="wohnort" type="text" maxlength="60"
placeholder="z.B. München"
value="${_esc(u.wohnort || '')}"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Erfahrung</label>
<select name="erfahrung" style="${inputStyle}">${erfahrungOpts}</select>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Social-Link</label>
<input name="social_link" type="url" maxlength="120"
placeholder="https://instagram.com/dein-hundeaccount"
value="${_esc(u.social_link || '')}"
style="${inputStyle}">
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
</div>
</form>
`,
footer: `
<button type="submit" form="profile-form" class="btn btn-primary">Speichern</button>
<button type="button" class="btn btn-ghost" data-modal-close>Abbrechen</button>
`,
});
document.getElementById('profile-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="profile-form"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const updated = await API.patch('/api/profile', {
bio: fd.bio || '',
wohnort: fd.wohnort || '',
erfahrung: fd.erfahrung || '',
social_link: fd.social_link || '',
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
});
Object.assign(_appState.user, updated);
UI.modal.close?.();
UI.toast.success('Profil gespeichert.');
_render();
});
});
});
document.getElementById('settings-logout-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title : 'Abmelden?',
message: 'Du wirst aus deinem Konto abgemeldet.',
confirmText: 'Abmelden',
});
if (!ok) return;
try {
await API.auth.logout();
} catch { /* cookie wird trotzdem gelöscht */ }
_appState.user = null;
_appState.dogs = [];
_appState.activeDog = null;
UI.toast.info('Du wurdest abgemeldet.');
_render();
});
document.getElementById('settings-install-btn')?.addEventListener('click', () => {
App.navigate('welcome');
});
document.getElementById('settings-push-btn')?.addEventListener('click', async () => {
try {
await API.subscribeToPush();
UI.toast.success('Push-Benachrichtigungen aktiviert.');
} catch {
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
}
});
document.getElementById('settings-calendar-btn')?.addEventListener('click', async () => {
try {
const { token } = await API.webcal.getToken();
const url = `webcal://${location.host}/api/webcal/${token}.ics`;
const httpsUrl = `https://${location.host}/api/webcal/${token}.ics`;
UI.modal.open({
title: `${UI.icon('calendar-dots')} Kalender abonnieren`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Abonniere deinen persönlichen Ban-Yaro-Kalender. Er enthält Impf-Erinnerungen,
Läufigkeits-Termine, Events und Gassi-Treffen — immer aktuell.
</p>
<div style="background:var(--c-bg);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);color:var(--c-text-secondary);
word-break:break-all;margin-bottom:var(--space-4)">
${UI.escHtml(httpsUrl)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<a href="${UI.escHtml(url)}"
class="btn btn-primary"
style="text-align:center">
${UI.icon('calendar-dots')} In Kalender-App öffnen
</a>
<button class="btn btn-secondary" id="cal-copy-btn">
${UI.icon('clipboard-text')} URL kopieren
</button>
</div>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-4)">
Tipp: iOS → Einstellungen Kalender Accounts Account hinzufügen Andere Kalenderabo
</p>
`,
});
document.getElementById('cal-copy-btn')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(httpsUrl);
UI.toast.success('URL kopiert.');
} catch {
UI.toast.warning('Kopieren nicht möglich — URL oben manuell kopieren.');
}
});
} catch {
UI.toast.error('Kalender-Token konnte nicht geladen werden.');
}
});
document.getElementById('toggle-pocket-mode')?.addEventListener('change', e => {
localStorage.setItem('by_pocket_mode', String(e.target.checked));
UI.toast.info(e.target.checked
? 'Pocket-Modus aktiviert — Bildschirm bleibt bei Aufzeichnung an.'
: 'Pocket-Modus deaktiviert.');
});
}
// ----------------------------------------------------------
// NICHT EINGELOGGT — Login / Registrierung
// ----------------------------------------------------------
function _renderAuth(mode) {
_mode = mode;
_container.innerHTML = `
<div style="max-width:380px;margin:0 auto;padding:var(--space-6) var(--space-4)">
<!-- Logo -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:72px;height:72px;border-radius:var(--radius-lg);
margin-bottom:var(--space-3)">
<h1 style="font-size:var(--text-2xl);font-weight:700;margin:0">Ban Yaro</h1>
<p style="color:var(--c-text-secondary);margin:var(--space-1) 0 0">
Alles rund um deinen Hund
</p>
</div>
<!-- Tab-Toggle -->
<div style="display:flex;background:var(--c-surface-2);
border-radius:var(--radius-md);padding:3px;
margin-bottom:var(--space-5)">
<button id="tab-login"
class="btn flex-1 ${mode === 'login' ? 'btn-primary' : 'btn-ghost'}"
style="border-radius:calc(var(--radius-md) - 2px)">
Anmelden
</button>
<button id="tab-register"
class="btn flex-1 ${mode === 'register' ? 'btn-primary' : 'btn-ghost'}"
style="border-radius:calc(var(--radius-md) - 2px)">
Registrieren
</button>
</div>
${mode === 'login' ? _loginFormHTML() : _registerFormHTML()}
</div>
`;
document.getElementById('tab-login')
?.addEventListener('click', () => _renderAuth('login'));
document.getElementById('tab-register')
?.addEventListener('click', () => _renderAuth('register'));
if (mode === 'login') {
_bindLoginForm();
} else {
_bindRegisterForm();
}
}
function _loginFormHTML() {
return `
<form id="auth-form" autocomplete="on" novalidate>
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input class="form-control" type="password" name="password"
placeholder="Passwort" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Anmelden
</button>
</form>
`;
}
function _registerFormHTML() {
return `
<form id="auth-form" autocomplete="on" novalidate>
<div class="form-group">
<label class="form-label">Dein Name</label>
<input class="form-control" type="text" name="name"
placeholder="z. B. Maria" autocomplete="name" required>
</div>
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email"
placeholder="deine@email.de" autocomplete="email" required>
</div>
<div class="form-group">
<label class="form-label">Passwort</label>
<input class="form-control" type="password" name="password"
placeholder="Mindestens 8 Zeichen" autocomplete="new-password"
minlength="8" required>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Konto erstellen
</button>
<p style="text-align:center;font-size:var(--text-xs);
color:var(--c-text-secondary);margin-top:var(--space-3)">
Mit der Registrierung stimmst du unseren Datenschutzhinweisen zu.<br>
Deine Daten werden ausschließlich auf unserem Server gespeichert.
</p>
</form>
`;
}
function _bindLoginForm() {
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const result = await API.auth.login(fd.email, fd.password);
localStorage.setItem('by_token', result.token);
// User-Daten laden
_appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name;
// Hunde laden
try {
_appState.dogs = await API.dogs.list();
_appState.activeDog = _appState.dogs[0] || null;
} catch { /* keine Hunde = okay */ }
UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`);
// Nach Login: Tagebuch oder Profil anlegen
if (_appState.activeDog) {
App.navigate('diary');
} else {
App.navigate('dog-profile');
}
});
});
}
function _bindRegisterForm() {
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
if (!fd.name?.trim()) {
UI.toast.warning('Bitte einen Namen eingeben.');
return;
}
if ((fd.password || '').length < 8) {
UI.toast.warning('Passwort muss mindestens 8 Zeichen lang sein.');
return;
}
await UI.asyncButton(btn, async () => {
const result = await API.auth.register(fd.email, fd.password, fd.name.trim());
localStorage.setItem('by_token', result.token);
_appState.user = await API.auth.me();
document.getElementById('sidebar-username').textContent = _appState.user.name;
_appState.dogs = [];
_appState.activeDog = null;
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}! 🐕`);
// Direkt zur Profil-Anlage
App.navigate('dog-profile');
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();