Session 2026-04-19: Navigation, Kompass, Übungsfortschritt

Routen-Navigation:
- POI-Marker: farbige Kreise mit Phosphor-Icons (wie Hauptkarte)
- Screensaver: Navi-Pfeil dreht sich via DeviceOrientationEvent (iOS+Android)
- Pfeil-Dämpfung: EMA α=0.12 mit Wrap-Around
- GPS-Distanz-Bug: Fortschritt nur wenn <500m zur Route
- fitBounds: User-Position nur wenn <20km von Route
- Screensaver: "zur Route" vs "verbleibend" kontextabhängig
- Richtungspfeile entlang Route (blau, max 7 Stück)
- Umkehren ins Route-Detail verschoben, Detail-Map rebuildet sich
- rk-header z-index:10 (Leaflet-Tiles liefen drüber)
- 2-Sek. Screensaver-Entsperrung

km-Tracking:
- route_walks Tabelle
- POST /api/routes/{id}/walked (≥50%)
- total_km = erstellte Routes + gelaufene route_walks
- Toast bei neuem Badge

Übungsfortschritt:
- exercise_progress + training_plan_progress Tabellen
- GET/POST /api/training/progress, /plan-progress, /suggestions
- uebungen.js: API-first + localStorage-Fallback + Auto-Migration
- Empfehlungs-Banner (regelbasiert)
- Toast bei "sitzt"
This commit is contained in:
rene 2026-04-19 20:33:01 +02:00
parent 390176383f
commit 9a78121a3e
25 changed files with 2487 additions and 248 deletions

View file

@ -225,6 +225,11 @@ window.Page_admin = (() => {
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
🗺 ${u.route_count} Routen · ${u.total_km} km
· 📍 ${u.poi_count} POIs
${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''}
</div>
</div>
<!-- Aktionen -->

View file

@ -34,25 +34,68 @@ window.Page_chat = (() => {
// ----------------------------------------------------------
// Conversation list
// ----------------------------------------------------------
const _isDesktop = () => window.innerWidth >= 768;
function _listPaneHTML() {
return `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:0 var(--space-4);height:56px;box-sizing:border-box;
flex-shrink:0;border-bottom:1px solid var(--c-border);
background:var(--c-surface)">
<h2 style="font-size:var(--text-base);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
<button class="btn btn-primary btn-sm" id="chat-new-btn">
${UI.icon('pencil-simple')} Neue Nachricht
</button>
</div>
<div id="chat-list-body" style="overflow-y:auto;flex:1"></div>`;
}
async function _showList() {
_view = 'list';
_stopPolling();
_convId = null;
_container.innerHTML = `
<div style="background:var(--c-surface)">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:var(--space-4) var(--space-4) var(--space-2)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
<button class="btn btn-primary btn-sm" id="chat-new-btn">
${UI.icon('pencil-simple')} Neue Nachricht
</button>
</div>
<div id="chat-list-body"></div>
</div>
`;
if (_isDesktop()) {
// Split-Pane: linke Spalte bleibt, rechte zeigt Placeholder
if (!document.getElementById('chat-split')) {
_container.innerHTML = `
<div id="chat-split" style="display:flex;flex:1;min-height:0;overflow:hidden;
position:absolute;inset:0">
<div id="chat-list-pane" style="width:320px;flex-shrink:0;display:flex;
flex-direction:column;border-right:1px solid var(--c-border);
background:var(--c-surface);min-height:0">
${_listPaneHTML()}
</div>
<div id="chat-thread-pane" style="flex:1;min-width:0;display:flex;
align-items:center;justify-content:center;
background:var(--c-bg);color:var(--c-text-muted);font-size:var(--text-sm)">
${UI.icon('chat-circle-dots')} Gespräch auswählen
</div>
</div>`;
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
} else {
// Split existiert — nur rechte Seite zurücksetzen
const pane = document.getElementById('chat-thread-pane');
if (pane) {
pane.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;background:var(--c-bg);color:var(--c-text-muted);font-size:var(--text-sm)';
pane.innerHTML = `${UI.icon('chat-circle-dots')} Gespräch auswählen`;
}
}
} else {
_container.innerHTML = `
<div style="background:var(--c-surface)">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:var(--space-4) var(--space-4) var(--space-2)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:0">Nachrichten</h2>
<button class="btn btn-primary btn-sm" id="chat-new-btn">
${UI.icon('pencil-simple')} Neue Nachricht
</button>
</div>
<div id="chat-list-body"></div>
</div>`;
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
}
document.getElementById('chat-new-btn')?.addEventListener('click', _showNewMessagePicker);
await _loadList();
await _updateChatBadge();
}
@ -122,12 +165,18 @@ window.Page_chat = (() => {
_view = 'thread';
_stopPolling();
_container.innerHTML = `
// Aktive Markierung in der Liste
document.querySelectorAll('.chat-conv-item').forEach(el =>
el.classList.toggle('active', el.getAttribute('onclick')?.includes(String(convId)))
);
const threadHTML = `
<div class="chat-thread" id="chat-thread">
<div class="chat-thread-header">
${_isDesktop() ? '' : `
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button>
</button>`}
<div style="position:relative;flex-shrink:0">
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" style="display:none"></span>
@ -154,6 +203,14 @@ window.Page_chat = (() => {
</div>
`;
const threadPane = document.getElementById('chat-thread-pane');
if (_isDesktop() && threadPane) {
threadPane.style.cssText = 'flex:1;min-width:0;display:flex;flex-direction:column';
threadPane.innerHTML = threadHTML;
} else {
_container.innerHTML = threadHTML;
}
// Auto-resize textarea
const input = document.getElementById('chat-input');
input.addEventListener('input', () => {

View file

@ -371,17 +371,53 @@ window.Page_diary = (() => {
// ----------------------------------------------------------
// LIGHTBOX
// ----------------------------------------------------------
function _showLightbox(src) {
// ----------------------------------------------------------
// LIGHTBOX — Fotos mit Vor/Zurück-Navigation
// ----------------------------------------------------------
function _showLightbox(urls, startIdx = 0) {
const photos = Array.isArray(urls) ? urls : [urls];
let idx = startIdx;
const lb = document.createElement('div');
lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out';
lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
lb.addEventListener('click', () => lb.remove());
lb.id = 'diary-lightbox';
lb.style.cssText = 'position:fixed;inset:0;z-index:1100;background:#000;display:flex;flex-direction:column';
const render = () => {
lb.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:calc(env(safe-area-inset-top,0px)+10px) 16px 10px;flex-shrink:0">
<button id="lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;
width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;
display:flex;align-items:center;justify-content:center"></button>
${photos.length > 1
? `<span style="color:rgba(255,255,255,.7);font-size:13px">${idx+1} / ${photos.length}</span>`
: ''}
<div style="width:40px"></div>
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<img src="${UI.escape(photos[idx])}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom;display:block">
${photos.length > 1 ? `
<button id="lb-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;border-radius:50%;width:44px;height:44px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center
${idx === 0 ? ';opacity:.3;pointer-events:none' : ''}"></button>
<button id="lb-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);
background:rgba(255,255,255,.15);border:none;border-radius:50%;width:44px;height:44px;
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center
${idx === photos.length-1 ? ';opacity:.3;pointer-events:none' : ''}"></button>
` : ''}
</div>
`;
lb.querySelector('#lb-close').addEventListener('click', () => lb.remove());
lb.querySelector('#lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } });
lb.querySelector('#lb-next')?.addEventListener('click', () => { if (idx < photos.length-1) { idx++; render(); } });
};
render();
document.body.appendChild(lb);
}
// ----------------------------------------------------------
// DETAIL-ANSICHT
// DETAIL-ANSICHT — Fullscreen (DayOne-Stil)
// ----------------------------------------------------------
function _openDetail(entryId) {
const entry = _entries.find(e => e.id === entryId);
@ -390,113 +426,110 @@ window.Page_diary = (() => {
const typ = TYPEN[entry.typ] || TYPEN.eintrag;
const isMile = entry.is_milestone || entry.typ === 'meilenstein';
const tags = (entry.tags || []).filter(t => t && t.trim());
const allMedia = _allMedia(entry);
const photo = allMedia.length > 0
? (allMedia.length === 1
? `<div style="position:relative;margin-bottom:var(--space-4)">
${_mediaHtml(allMedia[0].url)}
</div>`
: `<div class="diary-gallery" style="margin-bottom:var(--space-4)">
${allMedia.map(m => `
<div class="diary-gallery-wrap" style="position:relative">
${m.media_type === 'video'
? `<video src="${m.url}" controls playsinline class="diary-gallery-item"></video>`
: `<img src="${m.url}" alt="Foto" class="diary-gallery-item">`}
<button type="button"
class="diary-cover-btn${m.is_cover ? ' diary-cover-btn--active' : ''}"
data-media-id="${m.id}"
aria-label="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
title="${m.is_cover ? 'Cover-Bild' : 'Als Cover setzen'}"
style="background:${m.is_cover ? '#f5c518' : 'rgba(0,0,0,.45)'};color:${m.is_cover ? '#fff' : 'rgba(255,255,255,.7)'}"><svg style="width:16px;height:16px;display:block" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg></button>
</div>`).join('')}
</div>`)
: '';
// Hunde-Anzeige wenn mehrere beteiligt
const dogIds = entry.dog_ids || [entry.dog_id];
const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs">
? `<div class="diary-detail-dogs" style="margin-bottom:var(--space-3)">
${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${UI.escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${UI.escape(dog.name)}</span>
</div>` : '';
</div><span>${UI.escape(dog.name)}</span></div>` : '';
}).join('')}
</div>`
: '';
</div>` : '';
const body = `
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)">
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span>
const view = document.createElement('div');
view.id = 'diary-detail-view';
view.style.cssText = 'position:fixed;inset:0;z-index:800;background:var(--c-bg);overflow-y:auto;display:flex;flex-direction:column';
// Medien-HTML für Hero-Bereich
const _heroHtml = (m) => m.media_type === 'pdf'
? `<a href="${UI.escape(m.url)}" target="_blank" rel="noopener"
style="display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:12px;padding:32px 16px;background:var(--c-surface-2);text-decoration:none;color:var(--c-text)">
<svg class="ph-icon" style="width:48px;height:48px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>
<span style="font-size:14px;font-weight:600">${UI.escape(m.url.split('/').pop())}</span>
<span style="font-size:12px;color:var(--c-text-secondary)">PDF öffnen</span>
</a>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" controls playsinline style="width:100%;max-height:55vh;display:block;object-fit:contain;background:#000"></video>`
: `<img src="${UI.escape(m.url)}" data-idx="${allMedia.indexOf(m)}" style="width:100%;max-height:55vh;object-fit:cover;display:block;cursor:zoom-in">`;
let mediaSection = '';
if (allMedia.length === 1) {
mediaSection = `<div style="background:#000;flex-shrink:0" id="diary-dv-hero">${_heroHtml(allMedia[0])}</div>`;
} else if (allMedia.length > 1) {
mediaSection = `
<div style="background:#000;flex-shrink:0" id="diary-dv-hero">${_heroHtml(allMedia[0])}</div>
<div style="display:flex;gap:3px;padding:3px;overflow-x:auto;background:#111;flex-shrink:0" id="diary-dv-thumbs">
${allMedia.map((m, i) => `
<div data-idx="${i}" style="flex-shrink:0;width:64px;height:64px;border-radius:4px;overflow:hidden;
cursor:pointer;border:2px solid ${i===0?'var(--c-primary)':'transparent'};box-sizing:border-box">
${m.media_type === 'pdf'
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)"><svg class="ph-icon" style="width:28px;height:28px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`
: m.media_type === 'video'
? `<video src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>`
: `<img src="${UI.escape(m.url)}" style="width:100%;height:100%;object-fit:cover">`}
</div>`).join('')}
</div>`;
}
view.innerHTML = `
<div style="position:sticky;top:0;z-index:10;display:flex;align-items:center;
justify-content:space-between;
padding:calc(env(safe-area-inset-top,0px) + 8px) 16px 8px;
background:var(--c-surface);border-bottom:1px solid var(--c-border);flex-shrink:0">
<button id="diary-dv-back" style="display:flex;align-items:center;gap:6px;background:none;
border:none;color:var(--c-primary);font-size:16px;cursor:pointer;padding:4px 0">
Zurück
</button>
${!_appState?.activeDog?.is_guest
? `<button id="diary-dv-edit" style="background:none;border:none;color:var(--c-primary);
font-size:14px;cursor:pointer;padding:4px 0">Bearbeiten</button>`
: '<div></div>'}
</div>
${mediaSection}
<div style="padding:var(--space-4);flex:1">
${isMile ? `<div class="diary-detail-milestone-badge" style="margin-bottom:var(--space-3)">${UI.icon('trophy')} Meilenstein</div>` : ''}
${entry.titel ? `<h2 style="margin:0 0 var(--space-2);font-size:1.3rem;font-weight:700;color:var(--c-text)">${UI.escape(entry.titel)}</h2>` : ''}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3);flex-wrap:wrap">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
<span style="color:var(--c-text-secondary);font-size:var(--text-sm)">
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span>
</div>
${entry.location_name ? `
<div class="diary-detail-location" style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat
? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}"
target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>`
: UI.escape(entry.location_name)}
</div>` : ''}
${dogsHtml}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.7;color:var(--c-text);margin:0 0 var(--space-4)">${UI.escape(_cleanText(entry.text))}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: ''}
</div>
${entry.location_name ? `
<div class="diary-detail-location" style="margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${UI.escape(entry.location_name)}</a>` : UI.escape(entry.location_name)}
</div>` : ''}
${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text);margin-bottom:var(--space-4)">${UI.escape(_cleanText(entry.text))}</p>`
: ''}
${tags.length
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-bottom:var(--space-4)">
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>`
: ''}
${dogsHtml}
${photo}
${!_appState?.activeDog?.is_guest ? `<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-4)" id="detail-edit">Bearbeiten</button>` : ''}
`;
UI.modal.open({ title: entry.titel || typ.label, body });
document.body.appendChild(view);
// Bilder anklickbar machen (Lightbox)
document.querySelector('#modal-container .modal-body')?.querySelectorAll('img').forEach(img => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', () => _showLightbox(img.src));
});
// Zurück
view.querySelector('#diary-dv-back').addEventListener('click', () => view.remove());
// Stern-Buttons: Cover-Bild setzen
document.querySelectorAll('.diary-cover-btn').forEach(btn => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const mediaId = parseInt(btn.dataset.mediaId);
try {
await API.diary.setCover(_appState.activeDog.id, entry.id, mediaId);
// Lokalen State aktualisieren
if (entry.media_items) {
entry.media_items.forEach(m => { m.is_cover = m.id === mediaId ? 1 : 0; });
}
entry.cover_url = (entry.media_items || []).find(m => m.id === mediaId)?.url || null;
_updateEntryInList(entry);
// Alle Sterne im Modal aktualisieren
document.querySelectorAll('.diary-cover-btn').forEach(b => {
const active = parseInt(b.dataset.mediaId) === mediaId;
b.classList.toggle('diary-cover-btn--active', active);
b.style.background = active ? '#f5c518' : 'rgba(0,0,0,.45)';
b.style.color = active ? '#fff' : 'rgba(255,255,255,.7)';
b.setAttribute('aria-label', active ? 'Cover-Bild' : 'Als Cover setzen');
b.setAttribute('title', active ? 'Cover-Bild' : 'Als Cover setzen');
const use = b.querySelector('use');
if (use) use.setAttribute('href', `/icons/phosphor.svg#${active ? 'star-fill' : 'star'}`);
});
UI.toast.success('Cover-Bild gesetzt.');
} catch {
UI.toast.error('Cover konnte nicht gesetzt werden.');
}
});
});
document.getElementById('detail-edit')?.addEventListener('click', async () => {
UI.modal.close();
// Nur nachladen wenn location_name/gps_lat fehlen (älterer In-Memory-Eintrag)
// Bearbeiten
view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => {
view.remove();
if (entry.location_name !== undefined || entry.gps_lat !== undefined) {
_showForm(entry);
} else {
@ -505,11 +538,41 @@ window.Page_diary = (() => {
const idx = _entries.findIndex(e => e.id === entry.id);
if (idx !== -1) _entries[idx] = fresh;
_showForm(fresh);
} catch {
_showForm(entry);
}
} catch { _showForm(entry); }
}
});
// Foto in Hero → Lightbox
const photoUrls = allMedia.filter(m => m.media_type !== 'video').map(m => m.url);
view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', e => {
const clickedIdx = parseInt(e.target.dataset.idx ?? 0);
const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1;
_showLightbox(photoUrls, Math.max(0, photoIdx));
});
// Thumbnail-Strip → Hero wechseln
view.querySelector('#diary-dv-thumbs')?.addEventListener('click', e => {
const thumb = e.target.closest('[data-idx]');
if (!thumb) return;
const i = parseInt(thumb.dataset.idx);
const hero = view.querySelector('#diary-dv-hero');
if (hero) hero.innerHTML = _heroHtml(allMedia[i]);
// Foto in neuem Hero → Lightbox
hero?.querySelector('img')?.addEventListener('click', ev => {
const clickedIdx = parseInt(ev.target.dataset.idx ?? i);
const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1;
_showLightbox(photoUrls, Math.max(0, photoIdx));
});
// Aktive Markierung
view.querySelectorAll('#diary-dv-thumbs [data-idx]').forEach((t, j) => {
t.style.borderColor = j === i ? 'var(--c-primary)' : 'transparent';
});
});
// Cover-Button: Stern-Icon auf aktiven Medien (optional, nur für eingeloggte)
if (!_appState?.activeDog?.is_guest && allMedia.some(m => m.id)) {
// Cover-Verwaltung über Edit-Dialog
}
}
// ----------------------------------------------------------
@ -618,11 +681,11 @@ window.Page_diary = (() => {
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*" multiple style="display:none">
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
<!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="margin-top:var(--space-2);cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg>
Fotos / Videos hinzufügen
</label>
</div>
@ -661,7 +724,13 @@ window.Page_diary = (() => {
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin-bottom:8px';
grid.innerHTML = _newFiles.map((f, i) => {
const objUrl = URL.createObjectURL(f);
const thumb = f.type.startsWith('video/')
const thumb = f.type === 'application/pdf' || f.name?.endsWith('.pdf')
? `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;gap:4px;padding:8px;text-align:center">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-danger)" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>
<div style="font-size:10px;color:var(--c-text-secondary);word-break:break-all;line-height:1.2">${f.name}</div>
</div>`
: f.type.startsWith('video/')
? `<video src="${objUrl}" class="diary-media-thumb" muted playsinline></video>`
: `<img src="${objUrl}" alt="" class="diary-media-thumb">`;
return `<div style="position:relative;aspect-ratio:1;border-radius:8px;overflow:hidden;background:var(--c-surface-2)" data-new-idx="${i}">
@ -786,6 +855,7 @@ window.Page_diary = (() => {
}
});
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
// Milestone-Toggle

View file

@ -16,6 +16,7 @@ window.Page_forum = (() => {
let _mapLoaded = false;
let _leafletLoaded = false;
let _map = null;
let _clusterGroup = null;
let _activeSection = 'list'; // 'list' | 'map'
const LIMIT = 30;
@ -238,7 +239,9 @@ window.Page_forum = (() => {
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview
? `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>`
: `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
: '';
return `
@ -321,10 +324,17 @@ window.Page_forum = (() => {
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
</div>` : '';
const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf'))
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u))
return `<video src="${_esc(u)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
};
const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
: '';
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
@ -789,10 +799,10 @@ window.Page_forum = (() => {
<div id="forum-location-picker"></div>
</div>
<div class="form-group">
<label class="form-label">Fotos (max. 5)</label>
<label class="form-label">Fotos / Dateien (max. 5)</label>
<div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('camera')} Fotos auswählen</label>
<input type="file" id="forum-thread-files" accept="image/*" multiple style="display:none">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label>
<input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple style="display:none">
</div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div>
@ -817,17 +827,52 @@ window.Page_forum = (() => {
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('ff-rules-link')?.addEventListener('click', _showRules);
// Foto-Vorschau
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
// Foto-Vorschau — eigenes Array damit iOS-Mehrfachauswahl akkumuliert
let _threadFiles = [];
const _renderThreadPreviews = () => {
const previews = document.getElementById('forum-thread-previews');
if (!previews) return;
previews.innerHTML = '';
Array.from(e.target.files || []).slice(0, 5).forEach(file => {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'forum-upload-thumb';
previews.appendChild(img);
_threadFiles.forEach((file, i) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;display:inline-block';
let thumb;
if (file.type === 'application/pdf' || file.name.endsWith('.pdf')) {
thumb = document.createElement('div');
thumb.className = 'forum-upload-thumb';
thumb.style.cssText = 'display:flex;align-items:center;justify-content:center;background:var(--c-surface-2);font-size:11px;color:var(--c-text-secondary);text-align:center;padding:4px';
thumb.textContent = '📄 PDF';
} else if (file.type.startsWith('video/')) {
thumb = document.createElement('video');
thumb.src = URL.createObjectURL(file);
thumb.className = 'forum-upload-thumb';
thumb.muted = true;
} else {
thumb = document.createElement('img');
thumb.src = URL.createObjectURL(file);
thumb.className = 'forum-upload-thumb';
}
const del = document.createElement('button');
del.type = 'button';
del.textContent = '×';
del.style.cssText = 'position:absolute;top:-4px;right:-4px;width:18px;height:18px;border-radius:50%;' +
'background:var(--c-danger);color:#fff;border:none;font-size:12px;line-height:1;cursor:pointer;' +
'display:flex;align-items:center;justify-content:center;padding:0';
del.addEventListener('click', () => { _threadFiles.splice(i, 1); _renderThreadPreviews(); });
wrap.appendChild(thumb);
wrap.appendChild(del);
previews.appendChild(wrap);
});
};
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
const neu = Array.from(e.target.files || []);
neu.forEach(f => {
if (_threadFiles.length < 5) _threadFiles.push(f);
});
e.target.value = ''; // reset damit dieselbe Datei nochmal wählbar ist
_renderThreadPreviews();
});
document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
@ -853,8 +898,7 @@ window.Page_forum = (() => {
});
// Fotos hochladen
const files = Array.from(document.getElementById('forum-thread-files')?.files || []);
for (const file of files.slice(0, 5)) {
for (const file of _threadFiles.slice(0, 5)) {
try {
await API.forum.uploadThreadFoto(created.id, file);
} catch (e) { /* ignorieren */ }
@ -899,8 +943,31 @@ window.Page_forum = (() => {
if (show) {
try {
const pos = await API.getLocation();
await API.forum.setLocation(pos.lat, pos.lon, true);
UI.toast.success('Standort geteilt.');
// Ortsmitte via Nominatim: erst Ort (zoom=10), dann Nominatim-Suche nach Ortsname
let lat = pos.lat, lon = pos.lon;
try {
const rev = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10&addressdetails=1&accept-language=de`,
{ cache: 'no-store' }
);
const d = await rev.json();
const a = d.address || {};
const ort = a.city || a.town || a.village || a.municipality || '';
if (ort) {
// Ortsname vorwärts-geocodieren um echte Ortsmitte zu bekommen
const fwd = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(ort)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const results = await fwd.json();
if (results[0]?.lat && results[0]?.lon) {
lat = parseFloat(results[0].lat);
lon = parseFloat(results[0].lon);
}
}
} catch {}
await API.forum.setLocation(lat, lon, true);
UI.toast.success('Ortsmitte geteilt — dein genauer Standort bleibt privat.');
_loadMembersOnMap();
} catch (err) {
e.target.checked = false;
@ -910,6 +977,7 @@ window.Page_forum = (() => {
try {
await API.forum.setLocation(null, null, false);
UI.toast.success('Standort versteckt.');
_loadMembersOnMap();
} catch (err) { UI.toast.error(err.message); }
}
});
@ -930,7 +998,25 @@ window.Page_forum = (() => {
async function _loadMembersOnMap() {
if (!_map) return;
try {
// MarkerCluster laden falls nicht vorhanden
if (!window.L.markerClusterGroup) {
await Promise.all([
new Promise((res, rej) => {
if (document.querySelector('link[href*="MarkerCluster"]')) { res(); return; }
const l1 = document.createElement('link'); l1.rel='stylesheet'; l1.href='/css/MarkerCluster.css'; l1.onload=res; l1.onerror=rej; document.head.appendChild(l1);
}),
new Promise((res, rej) => {
const s = document.createElement('script'); s.src='/js/leaflet.markercluster.js'; s.onload=res; s.onerror=rej; document.head.appendChild(s);
}),
]);
}
const members = await API.forum.membersMap();
// Alte Cluster-Gruppe sauber entfernen
if (_clusterGroup) { _map.removeLayer(_clusterGroup); _clusterGroup = null; }
_clusterGroup = L.markerClusterGroup({ maxClusterRadius: 60 });
members.forEach(m => {
const icon = L.divIcon({
className: '',
@ -941,10 +1027,12 @@ window.Page_forum = (() => {
border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
.addTo(_map);
_clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
);
});
_map.addLayer(_clusterGroup);
} catch (err) {
console.error('Mitgliederkarte Fehler:', err);
}

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,7 @@ window.Page_settings = (() => {
// ----------------------------------------------------------
// EINGELOGGT — Account-Übersicht
// ----------------------------------------------------------
function _renderAccount() {
async function _renderAccount() {
const u = _appState.user;
// Avatar: Bild oder Buchstabe
@ -61,7 +61,7 @@ window.Page_settings = (() => {
};
_container.innerHTML = `
<div style="max-width:400px;margin:0 auto;padding:var(--space-4) 0">
<div style="width:100%;max-width:640px;margin:0 auto;box-sizing:border-box;overflow-x:hidden;align-self:center">
<div class="card" style="padding:var(--space-5);margin-bottom:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-4)">
@ -145,6 +145,26 @@ window.Page_settings = (() => {
</div>
</div>
<div class="card" id="settings-stats-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)">Aktivität</div>
<div id="settings-stats-body" style="padding:var(--space-4);display:flex;justify-content:space-around">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</div>
</div>
<div id="settings-streak" style="display:flex;align-items:center;gap:8px;
padding:0 var(--space-4) var(--space-3);flex-wrap:wrap"></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)">Trophäen</div>
<div id="settings-badges-body" style="padding:var(--space-4)">
<div style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</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"
@ -208,22 +228,6 @@ window.Page_settings = (() => {
</select>
</div>
<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>
@ -265,6 +269,109 @@ window.Page_settings = (() => {
</div>
`;
// Achievements laden (Streak + Stats + Badges)
API.get('/achievements/me').then(a => {
const statsEl = document.getElementById('settings-stats-body');
const badgesEl = document.getElementById('settings-badges-body');
if (!statsEl) return;
const s = a.stats || {}, streak = a.streak || {};
const stat = (val, label) => `
<div style="text-align:center">
<div style="font-size:1.3rem;font-weight:700;color:var(--c-text)">${val}</div>
<div style="font-size:10px;color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em;margin-top:2px">${label}</div>
</div>`;
statsEl.innerHTML =
stat((s.total_km ?? 0) + ' km', 'gelaufen') +
stat(s.routen ?? 0, 'Routen') +
stat(s.pois ?? 0, 'POIs') +
stat('#' + (a.rang ?? ''), 'Rang');
const streakEl = document.getElementById('settings-streak');
if (streakEl) {
const cur = streak.current || 0, mx = streak.max || 0;
streakEl.innerHTML = cur > 0
? `<span style="font-size:1.3rem">🔥</span>
<span style="font-weight:700;font-size:1.05rem">${cur} Tage Streak</span>
${mx > cur ? `<span style="color:var(--c-text-muted);font-size:11px;margin-left:auto">Best: ${mx}</span>` : ''}`
: `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">🔥 Noch kein Streak — heute aktiv werden!</span>`;
}
if (badgesEl && a.categories) {
// SVG-Schild für jede Kategorie
const shield = (color, dark, emoji, opacity = 1) => `
<svg viewBox="0 0 60 72" xmlns="http://www.w3.org/2000/svg"
style="width:56px;height:56px;filter:drop-shadow(0 2px 6px ${color}66)">
<defs>
<linearGradient id="g_${color.replace('#','')}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${color}"/>
<stop offset="100%" stop-color="${dark}"/>
</linearGradient>
</defs>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="url(#g_${color.replace('#','')})" opacity="${opacity}"/>
<path d="M30 3 L57 15 L57 38 Q57 60 30 70 Q3 60 3 38 L3 15 Z"
fill="none" stroke="rgba(255,255,255,.25)" stroke-width="1.5"/>
<text x="30" y="43" text-anchor="middle" dominant-baseline="middle"
font-size="22" style="user-select:none">${emoji}</text>
</svg>`;
badgesEl.innerHTML = (a.categories || []).map(cat => {
const cur = cat.current_tier;
const nxt = cat.next_tier;
const val = cat.current_value;
// Alle Stufen als kleine Punkte
const dots = (cat.alle_stufen || []).map(s =>
`<div title="${_esc(s.name)}" style="width:8px;height:8px;border-radius:50%;
background:${s.earned ? s.color : 'var(--c-border)'}"></div>`
).join('');
// Aktuelles Schild
const shieldSvg = cur
? shield(cur.color, cur.dark, cat.emoji)
: shield('#9ca3af', '#6b7280', cat.emoji, 0.5);
// Fortschrittsbalken
const progressBar = nxt ? `
<div style="font-size:10px;color:var(--c-text-muted);margin-top:4px">
${val}${cat.einheit} / ${nxt.schwelle}${cat.einheit} ${_esc(nxt.name)}
</div>
<div style="height:4px;background:var(--c-border);border-radius:2px;margin-top:4px;overflow:hidden">
<div style="height:100%;width:${cat.progress}%;background:${nxt.color};border-radius:2px;transition:width .4s"></div>
</div>` : `
<div style="font-size:10px;color:var(--c-primary);font-weight:600;margin-top:4px">
Höchste Stufe erreicht! 🎉
</div>`;
return `
<div style="display:flex;gap:14px;align-items:flex-start;padding:12px 0;
border-bottom:1px solid var(--c-border)">
${shieldSvg}
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(cat.name)}</span>
${cur ? `<span style="font-size:10px;font-weight:600;padding:1px 6px;border-radius:999px;
background:${cur.color};color:${cur.text}">${_esc(cur.name)}</span>` : ''}
</div>
<div style="display:flex;gap:4px;margin-bottom:6px">${dots}</div>
${progressBar}
</div>
</div>`;
}).join('');
}
// Neue Badges als Toast
if (a.new_badges?.length) {
a.new_badges.forEach(b => {
UI.toast.success(`${b.emoji} ${b.name}${b.tier} freigeschaltet!`);
});
}
}).catch(() => {
const el = document.getElementById('settings-stats-body');
if (el) el.innerHTML = '<div style="color:var(--c-text-muted);font-size:var(--text-sm)"></div>';
});
// Avatar-Hover-Overlay
const avatarBtn = document.getElementById('settings-avatar-btn');
const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay');
@ -495,7 +602,7 @@ window.Page_settings = (() => {
const r = await API.auth.referral();
el.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<div style="flex:1;background:var(--c-surface-2);border-radius:var(--radius-md);
<div style="flex:1;min-width:0;background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);font-family:monospace;font-size:var(--text-sm);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.link}</div>
<button class="btn btn-primary btn-sm" id="ref-share-btn">${UI.icon('arrow-square-out')} Teilen</button>
@ -518,7 +625,7 @@ window.Page_settings = (() => {
UI.toast.success('Link kopiert!');
}
});
} catch { el.innerHTML = '<p style="color:var(--c-text-muted)">Nicht verfügbar.</p>'; }
} catch { el.innerHTML = ''; }
}
// ----------------------------------------------------------
@ -677,6 +784,7 @@ window.Page_settings = (() => {
_appState.activeDog = _appState.dogs[0] || null;
} catch { /* keine Hunde = okay */ }
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen zurück, ${_appState.user.name}!`);
// Push-Benachrichtigungen anbieten wenn noch nicht entschieden
@ -721,8 +829,8 @@ window.Page_settings = (() => {
_appState.dogs = [];
_appState.activeDog = null;
document.getElementById('header-login-btn')?.remove();
UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`);
// Onboarding-Modal direkt zeigen (SPA — kein Reload)
App.showOnboarding();
});
});

View file

@ -36,16 +36,26 @@ window.Page_uebungen = (() => {
return `ub_status_${tab}_${name.replace(/\s+/g, '_')}`;
}
// In-memory cache (loaded from API on init)
let _progressCache = {}; // key → statusId
function _progressKey(tab, name) {
return `${tab}_${name.replace(/[\s/]+/g, '_')}`;
}
function _getStatus(tab, name) {
return localStorage.getItem(_statusKey(tab, name)) || null;
const k = _progressKey(tab, name);
// Fallback to localStorage while API loads
return _progressCache[k] !== undefined
? _progressCache[k]
: localStorage.getItem(_statusKey(tab, name)) || null;
}
function _setStatus(tab, name, statusId) {
if (statusId === null) {
localStorage.removeItem(_statusKey(tab, name));
} else {
localStorage.setItem(_statusKey(tab, name), statusId);
}
const k = _progressKey(tab, name);
_progressCache[k] = statusId;
localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync
API.training.setProgress(k, statusId).catch(() => {});
}
function _nextStatus(currentId) {
@ -352,6 +362,31 @@ window.Page_uebungen = (() => {
_container = container;
_appState = appState;
_render();
// Progress vom Server laden
API.training.getProgress().then(rows => {
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
// localStorage-Daten migrieren falls noch nicht im Backend
Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => {
const parts = lsKey.replace('ub_status_', '').split('_');
const tab = parts[0];
const name = parts.slice(1).join('_');
const apiKey = `${tab}_${name}`;
if (_progressCache[apiKey] === undefined) {
const val = localStorage.getItem(lsKey);
if (val) {
_progressCache[apiKey] = val;
API.training.setProgress(apiKey, val).catch(() => {});
}
}
});
_renderContent(); // Re-render with loaded progress
}).catch(() => {});
// Empfehlungen laden
API.training.getSuggestions().then(suggestions => {
if (suggestions.length) _showSuggestions(suggestions);
}).catch(() => {});
}
function refresh() {}
@ -364,6 +399,7 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
<div id="ueb-wrap">
${_renderTabs()}
<div id="ueb-suggestions" style="padding:0 var(--space-4);display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-2)"></div>
<div id="ueb-content"></div>
</div>
`;
@ -384,6 +420,54 @@ window.Page_uebungen = (() => {
`;
}
function _showSuggestions(suggestions) {
const el = _container.querySelector('#ueb-suggestions');
if (!el || !suggestions.length) return;
const COLORS = {
help: { bg: '#fef2f2', border: '#fca5a5', text: '#dc2626' },
boost: { bg: '#fff7ed', border: '#fdba74', text: '#ea580c' },
next: { bg: '#f0fdf4', border: '#86efac', text: '#16a34a' },
start: { bg: 'var(--c-primary-subtle)', border: 'var(--c-primary-light)', text: 'var(--c-primary)' },
};
el.innerHTML = suggestions.map(s => {
const c = COLORS[s.type] || COLORS.start;
return `
<div style="background:${c.bg};border:1px solid ${c.border};border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);display:flex;gap:var(--space-3);align-items:flex-start;cursor:pointer"
data-action-tab="${_esc(s.action_tab || '')}"
data-action-name="${_esc(s.action_name || '')}"
class="ueb-suggestion-card">
<svg class="ph-icon" style="width:20px;height:20px;flex-shrink:0;color:${c.text};margin-top:2px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(s.icon)}"></use>
</svg>
<div style="min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:${c.text};margin-bottom:2px">
${_esc(s.title)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
${_esc(s.text)}
</div>
</div>
</div>
`;
}).join('');
el.querySelectorAll('.ueb-suggestion-card').forEach(card => {
card.addEventListener('click', () => {
const tab = card.dataset.actionTab;
if (tab && tab !== _activeTab) {
_activeTab = tab;
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab)
);
_renderContent();
}
});
});
}
function _bindTabs() {
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
@ -551,6 +635,7 @@ window.Page_uebungen = (() => {
const cur = _getStatus(tab, name);
const next = _nextStatus(cur);
_setStatus(tab, name, next);
if (next === 'sitzt') UI.toast.success(`🏆 „${name}" sitzt! Gut gemacht!`);
// Update button in place (no full re-render)
const sm = _statusMeta(next);

View file

@ -34,7 +34,7 @@ window.Page_welcome = (() => {
<div style="text-align:center;margin-bottom:var(--space-8)">
<img src="/icons/icon-180.png" alt="Ban Yaro"
style="width:88px;height:88px;border-radius:var(--radius-xl);
box-shadow:var(--shadow-md);margin-bottom:var(--space-4)">
box-shadow:var(--shadow-md);margin:0 auto var(--space-4);display:block">
<h1 style="font-size:var(--text-2xl);font-weight:var(--weight-bold);
color:var(--c-text);margin:0 0 var(--space-2)">Ban Yaro</h1>
<p style="font-size:var(--text-base);color:var(--c-text-secondary);