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:
parent
390176383f
commit
9a78121a3e
25 changed files with 2487 additions and 248 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue