Feature: Walk-Einladungen und RSVP-System

Gassi-Treffen bekommen ein vollständiges Einladungs- und RSVP-System:
- Neue Tabelle walk_invitations (walk_id, user_id, status, Zeitstempel)
- Backend: /invite-candidates, /invite, /rsvp, /participants Endpoints
- Push-Notification beim Einladen
- Frontend: RSVP-Buttons (Zusagen/Vielleicht/Absagen), Teilnehmerliste
  mit Avatar-Initialen und farbkodierten RSVP-Badges, Einladen-Modal
- SW by-v205, APP_VER 173
This commit is contained in:
rene 2026-04-18 14:14:31 +02:00
parent e3230237a2
commit 066b722c5e
7 changed files with 489 additions and 18 deletions

View file

@ -464,6 +464,8 @@ def _migrate(conn_factory):
("dogs", "foto_zoom", "REAL NOT NULL DEFAULT 1.0"),
("dogs", "foto_offset_x", "REAL NOT NULL DEFAULT 0.0"),
("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"),
# Tagebuch: Ortsname (POI/Adresse)
("diary", "location_name", "TEXT"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@ -736,3 +738,19 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_service_offers_type ON service_offers(type, aktiv);
CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type);
""")
# Walk-Einladungen (RSVP)
conn.executescript("""
CREATE TABLE IF NOT EXISTS walk_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'invited',
invited_at TEXT NOT NULL DEFAULT (datetime('now')),
responded_at TEXT,
UNIQUE(walk_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_walk_inv_walk ON walk_invitations(walk_id);
CREATE INDEX IF NOT EXISTS idx_walk_inv_user ON walk_invitations(user_id, status);
""")
logger.info("Migration: walk_invitations Tabelle bereit.")

View file

@ -8,6 +8,7 @@ from pydantic import BaseModel
from typing import Optional, List
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
router = APIRouter()
@ -51,6 +52,12 @@ class WalkUpdate(BaseModel):
class JoinRequest(BaseModel):
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
class InviteRequest(BaseModel):
friend_id: int
class RsvpRequest(BaseModel):
status: str # 'yes' | 'maybe' | 'no'
# ------------------------------------------------------------------
# GET /api/walks — alle offenen Treffen (ab heute, optional Umkreis)
@ -181,6 +188,161 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
return unique[:20]
# ------------------------------------------------------------------
# GET /api/walks/{id}/invite-candidates — einladbare Freunde
# WICHTIG: Muss VOR /{walk_id} stehen
# ------------------------------------------------------------------
@router.get("/{walk_id}/invite-candidates")
async def invite_candidates(walk_id: int, user=Depends(get_current_user)):
with db() as conn:
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
if not walk:
raise HTTPException(404, "Treffen nicht gefunden.")
if walk['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann diese Liste abrufen.")
# Freunde die noch nicht eingeladen wurden
already = {r[0] for r in conn.execute(
"SELECT user_id FROM walk_invitations WHERE walk_id=?", (walk_id,)
).fetchall()}
friends = _get_accepted_friends(user['id'])
return [f for f in friends if f['friend_id'] not in already]
# ------------------------------------------------------------------
# POST /api/walks/{id}/invite — Freund einladen (nur Veranstalter)
# WICHTIG: Muss VOR /{walk_id} stehen
# ------------------------------------------------------------------
@router.post("/{walk_id}/invite", status_code=201)
async def invite_friend(walk_id: int, data: InviteRequest, user=Depends(get_current_user)):
with db() as conn:
walk = conn.execute("SELECT * FROM walks WHERE id = ?", (walk_id,)).fetchone()
if not walk:
raise HTTPException(404, "Treffen nicht gefunden.")
if walk['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann einladen.")
# Freundschaft prüfen
friendship = conn.execute("""
SELECT 1 FROM friendships
WHERE status='accepted'
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (user['id'], data.friend_id, data.friend_id, user['id'])).fetchone()
if not friendship:
raise HTTPException(400, "Dieser Nutzer ist nicht in deiner Freundesliste.")
# Bereits eingeladen?
existing = conn.execute(
"SELECT status FROM walk_invitations WHERE walk_id=? AND user_id=?",
(walk_id, data.friend_id)
).fetchone()
if existing:
raise HTTPException(409, f"Nutzer wurde bereits eingeladen (Status: {existing['status']}).")
conn.execute(
"INSERT INTO walk_invitations (walk_id, user_id, status) VALUES (?, ?, 'invited')",
(walk_id, data.friend_id)
)
friend = conn.execute("SELECT name FROM users WHERE id=?", (data.friend_id,)).fetchone()
# Push-Notification
send_push_to_user(data.friend_id, {
"type": "walk_invite",
"title": f"Einladung: {walk['titel']}",
"body": f"{user['name']} lädt dich zu einem Gassi-Treffen ein ({walk['datum']} {walk['uhrzeit']})",
"walk_id": walk_id,
})
return {"status": "invited", "friend_name": friend['name'] if friend else ""}
# ------------------------------------------------------------------
# POST /api/walks/{id}/rsvp — RSVP setzen (yes / maybe / no)
# WICHTIG: Muss VOR /{walk_id} stehen
# ------------------------------------------------------------------
@router.post("/{walk_id}/rsvp")
async def rsvp_walk(walk_id: int, data: RsvpRequest, user=Depends(get_current_user)):
if data.status not in ('yes', 'maybe', 'no'):
raise HTTPException(400, "Ungültiger RSVP-Status. Erlaubt: yes, maybe, no")
with db() as conn:
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
if not walk:
raise HTTPException(404, "Treffen nicht gefunden.")
inv = conn.execute(
"SELECT * FROM walk_invitations WHERE walk_id=? AND user_id=?",
(walk_id, user['id'])
).fetchone()
if not inv:
raise HTTPException(403, "Du wurdest nicht zu diesem Treffen eingeladen.")
conn.execute(
"""UPDATE walk_invitations
SET status=?, responded_at=datetime('now')
WHERE walk_id=? AND user_id=?""",
(data.status, walk_id, user['id'])
)
return {"status": data.status}
# ------------------------------------------------------------------
# GET /api/walks/{id}/participants — Teilnehmerliste mit RSVP
# WICHTIG: Muss VOR /{walk_id} stehen
# ------------------------------------------------------------------
@router.get("/{walk_id}/participants")
async def get_participants(walk_id: int, user=Depends(get_current_user)):
with db() as conn:
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
if not walk:
raise HTTPException(404, "Treffen nicht gefunden.")
is_organizer = walk['user_id'] == user['id']
if is_organizer:
# Veranstalter sieht alle Einladungen
invitations = conn.execute("""
SELECT wi.user_id, wi.status, wi.invited_at, wi.responded_at,
u.name AS user_name,
GROUP_CONCAT(d.name, ', ') AS hunde
FROM walk_invitations wi
JOIN users u ON u.id = wi.user_id
LEFT JOIN walk_participant_dogs wpd
ON wpd.walk_id = wi.walk_id AND wpd.user_id = wi.user_id
LEFT JOIN dogs d ON d.id = wpd.dog_id
WHERE wi.walk_id = ?
GROUP BY wi.user_id
""", (walk_id,)).fetchall()
else:
# Eingeladene sehen nur Zugesagte + sich selbst
invitations = conn.execute("""
SELECT wi.user_id, wi.status, wi.invited_at, wi.responded_at,
u.name AS user_name,
GROUP_CONCAT(d.name, ', ') AS hunde
FROM walk_invitations wi
JOIN users u ON u.id = wi.user_id
LEFT JOIN walk_participant_dogs wpd
ON wpd.walk_id = wi.walk_id AND wpd.user_id = wi.user_id
LEFT JOIN dogs d ON d.id = wpd.dog_id
WHERE wi.walk_id = ? AND (wi.status = 'yes' OR wi.user_id = ?)
GROUP BY wi.user_id
""", (walk_id, user['id'])).fetchall()
my_invitation = conn.execute(
"SELECT status FROM walk_invitations WHERE walk_id=? AND user_id=?",
(walk_id, user['id'])
).fetchone()
return {
"invitations": [dict(r) for r in invitations],
"my_rsvp": my_invitation['status'] if my_invitation else None,
"is_organizer": is_organizer,
}
# ------------------------------------------------------------------
# GET /api/walks/{id} — Detail mit Teilnehmerliste
# ------------------------------------------------------------------
@ -214,6 +376,20 @@ async def get_walk(walk_id: int):
return result
# Helper: accepted friends list for invite-modal (reused from friends route)
def _get_accepted_friends(user_id: int):
with db() as conn:
rows = conn.execute("""
SELECT CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END AS friend_id,
u.name AS friend_name
FROM friendships f
JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END
WHERE (f.requester_id=? OR f.addressee_id=?) AND f.status='accepted'
ORDER BY u.name
""", (user_id, user_id, user_id, user_id)).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/walks/{id}
# ------------------------------------------------------------------

View file

@ -2602,6 +2602,112 @@ html.modal-open {
.walks-participant-name { font-weight: var(--weight-semibold); }
.walks-participant-dogs { color: var(--c-text-secondary); }
/* Walk-Einladungen + RSVP */
.walks-invitation-row,
.walks-invite-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--c-border);
font-size: var(--text-sm);
}
.walks-invitation-row:last-child,
.walks-invite-row:last-child { border-bottom: none; }
.walks-inv-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--c-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
flex-shrink: 0;
}
.walks-inv-avatar--sm {
width: 28px;
height: 28px;
}
.walks-inv-info { flex: 1; min-width: 0; }
.walks-inv-name {
font-weight: var(--weight-semibold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.walks-inv-hunde {
color: var(--c-text-secondary);
font-size: var(--text-xs);
}
.walks-inv-badge { flex-shrink: 0; }
/* RSVP-Badges */
.walks-rsvp-badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
}
.walks-rsvp--yes { background: #d1fae5; color: #065f46; }
.walks-rsvp--maybe { background: #fef3c7; color: #92400e; }
.walks-rsvp--no { background: #fee2e2; color: #991b1b; }
.walks-rsvp--invited { background: var(--c-surface-2); color: var(--c-text-secondary); }
/* RSVP-Section im Detail-Modal */
.walks-rsvp-section {
margin: var(--space-3) 0;
padding: var(--space-3);
background: var(--c-surface-2);
border-radius: var(--radius-md);
}
.walks-rsvp-buttons {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
margin-top: var(--space-2);
}
.walks-rsvp-btn {
flex: 1;
min-width: 80px;
padding: var(--space-2) var(--space-2);
border: 2px solid var(--c-border);
border-radius: var(--radius-md);
background: var(--c-surface);
color: var(--c-text);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
}
.walks-rsvp-btn:hover {
border-color: var(--c-primary);
background: var(--c-primary-bg, rgba(196,132,58,0.08));
}
.walks-rsvp-btn.active[data-rsvp="yes"] {
border-color: #10b981;
background: #d1fae5;
color: #065f46;
}
.walks-rsvp-btn.active[data-rsvp="maybe"] {
border-color: #f59e0b;
background: #fef3c7;
color: #92400e;
}
.walks-rsvp-btn.active[data-rsvp="no"] {
border-color: #ef4444;
background: #fee2e2;
color: #991b1b;
}
/* ------------------------------------------------------------
EVENTS (events.js)
------------------------------------------------------------ */

View file

@ -227,9 +227,13 @@ const API = (() => {
create(data) { return post('/walks', data); },
update(id, data) { return patch(`/walks/${id}`, data); },
cancel(id) { return del(`/walks/${id}`); },
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
leave(id) { return del(`/walks/${id}/join`); },
nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); },
join(id, dogIds) { return post(`/walks/${id}/join`, { dog_ids: dogIds }); },
leave(id) { return del(`/walks/${id}/join`); },
nearby(lat, lon) { return get(`/walks/nearby?lat=${lat}&lon=${lon}`); },
inviteCandidates(id) { return get(`/walks/${id}/invite-candidates`); },
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
participants(id) { return get(`/walks/${id}/participants`); },
};
// ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '169'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '173'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -269,13 +269,46 @@ window.Page_walks = (() => {
});
}
// ----------------------------------------------------------
// RSVP-Status → Label + Farbe
// ----------------------------------------------------------
function _rsvpBadge(status) {
if (status === 'yes') return `<span class="walks-rsvp-badge walks-rsvp--yes">Zusage</span>`;
if (status === 'maybe') return `<span class="walks-rsvp-badge walks-rsvp--maybe">Vielleicht</span>`;
if (status === 'no') return `<span class="walks-rsvp-badge walks-rsvp--no">Absage</span>`;
return `<span class="walks-rsvp-badge walks-rsvp--invited">Eingeladen</span>`;
}
function _avatarInitials(name) {
const parts = (name || '?').trim().split(/\s+/);
const initials = parts.length >= 2
? parts[0][0] + parts[parts.length - 1][0]
: parts[0].slice(0, 2);
return initials.toUpperCase();
}
function _invitationRowHTML(inv) {
return `
<div class="walks-invitation-row">
<div class="walks-inv-avatar">${_avatarInitials(inv.user_name)}</div>
<div class="walks-inv-info">
<div class="walks-inv-name">${_esc(inv.user_name)}</div>
${inv.hunde ? `<div class="walks-inv-hunde">${UI.icon('dog')} ${_esc(inv.hunde)}</div>` : ''}
</div>
<div class="walks-inv-badge">${_rsvpBadge(inv.status)}</div>
</div>`;
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
async function _openDetail(walkId) {
let walk;
let walk, participantData;
try {
walk = await API.walks.get(walkId);
if (_appState.user) {
try { participantData = await API.walks.participants(walkId); } catch {}
}
} catch (err) {
UI.toast.error(err.message);
return;
@ -286,15 +319,42 @@ window.Page_walks = (() => {
const isFull = walk.status === 'voll' || walk.teilnehmer_count >= walk.max_teilnehmer;
const isPast = _isPast(walk.datum);
const spots = walk.max_teilnehmer - walk.teilnehmer_count;
const myRsvp = participantData?.my_rsvp ?? null;
const isInvited = !!myRsvp;
const invitations = participantData?.invitations ?? [];
// Teilnehmerliste
// Teilnehmerliste (join-Teilnehmer, klassisch)
const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => `
<div class="walks-participant">
<span class="walks-participant-name">${UI.icon('user')} ${_esc(t.user_name)}</span>
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
<span class="walks-participant-name">${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${_esc(t.hunde)}</span>` : ''}
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
: '';
// Einladungsliste
const invListHTML = invitations.length
? invitations.map(inv => _invitationRowHTML(inv)).join('')
: `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Einladungen.</p>`;
// RSVP-Section für eingeladene Nutzer
const rsvpSectionHTML = (isInvited && !isOwn) ? `
<div class="walks-rsvp-section" id="wd-rsvp-section">
<div class="walks-detail-section-label">${UI.icon('check-circle')} Deine Antwort</div>
<div class="walks-rsvp-buttons">
<button type="button" class="walks-rsvp-btn ${myRsvp === 'yes' ? 'active' : ''}" data-rsvp="yes">
${UI.icon('check')} Zusagen
</button>
<button type="button" class="walks-rsvp-btn ${myRsvp === 'maybe' ? 'active' : ''}" data-rsvp="maybe">
${UI.icon('question')} Vielleicht
</button>
<button type="button" class="walks-rsvp-btn ${myRsvp === 'no' ? 'active' : ''}" data-rsvp="no">
${UI.icon('x')} Absagen
</button>
</div>
</div>
` : '';
const body = `
<div class="walks-detail-header">
@ -316,11 +376,23 @@ window.Page_walks = (() => {
<p style="margin:var(--space-4) 0;color:var(--c-text-secondary)">${_esc(walk.beschreibung)}</p>
` : ''}
${rsvpSectionHTML}
<div class="walks-detail-section">
<div class="walks-detail-section-label">${UI.icon('users')} Teilnehmer (${walk.teilnehmer_count}/${walk.max_teilnehmer})</div>
${teilnehmerHTML}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div class="walks-detail-section-label" style="margin-bottom:0">${UI.icon('users')} Einladungen</div>
${isOwn && !isPast ? `<button type="button" class="btn btn-secondary btn-sm" id="wd-invite-btn">${UI.icon('user-plus')} Einladen</button>` : ''}
</div>
<div id="wd-inv-list">${invListHTML}</div>
</div>
${walk.teilnehmer?.length ? `
<div class="walks-detail-section">
<div class="walks-detail-section-label">${UI.icon('check-circle')} Beigetreten (${walk.teilnehmer_count}/${walk.max_teilnehmer})</div>
${teilnehmerHTML}
</div>
` : ''}
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Veranstaltet von ${_esc(walk.veranstalter_name || 'Unbekannt')}
</p>
@ -380,6 +452,31 @@ window.Page_walks = (() => {
_showEditForm(walk);
});
// Einladen-Button
document.getElementById('wd-invite-btn')?.addEventListener('click', () => {
UI.modal.close();
_showInviteModal(walk);
});
// RSVP-Buttons
document.querySelectorAll('.walks-rsvp-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const status = btn.dataset.rsvp;
try {
await API.walks.rsvp(walk.id, status);
// Buttons aktualisieren
document.querySelectorAll('.walks-rsvp-btn').forEach(b => {
b.classList.toggle('active', b.dataset.rsvp === status);
});
UI.toast.success(
status === 'yes' ? 'Zugesagt!' :
status === 'maybe' ? 'Antwort: Vielleicht.' :
'Abgesagt.'
);
} catch (err) { UI.toast.error(err.message); }
});
});
// Stornieren: Zwei-Klick-Pattern (kein UI.modal.confirm im Modal)
let _cancelPending = false;
document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => {
@ -442,6 +539,66 @@ window.Page_walks = (() => {
});
}
// ----------------------------------------------------------
// Freunde einladen
// ----------------------------------------------------------
async function _showInviteModal(walk) {
let candidates;
try {
candidates = await API.walks.inviteCandidates(walk.id);
} catch (err) {
UI.toast.error(err.message);
return;
}
const listHTML = candidates.length
? candidates.map(f => `
<div class="walks-invite-row" data-friend-id="${f.friend_id}" data-friend-name="${_esc(f.friend_name)}">
<div class="walks-inv-avatar">${_avatarInitials(f.friend_name)}</div>
<div class="walks-inv-name" style="flex:1">${_esc(f.friend_name)}</div>
<button type="button" class="btn btn-primary btn-sm walks-invite-send">
${UI.icon('paper-plane-tilt')} Einladen
</button>
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Alle Freunde wurden bereits eingeladen.</p>`;
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${_fmtDate(walk.datum)} · ${walk.uhrzeit} Uhr
${walk.ort_name ? `· ${_esc(walk.ort_name)}` : ''}
</p>
<div id="invite-list">${listHTML}</div>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="invite-back">Zurück</button>
`;
UI.modal.open({ title: `${UI.icon('user-plus')} Freunde einladen`, body, footer });
document.getElementById('invite-back')?.addEventListener('click', () => {
UI.modal.close();
_openDetail(walk.id);
});
document.querySelectorAll('.walks-invite-send').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.walks-invite-row');
const friendId = parseInt(row.dataset.friendId);
const name = row.dataset.friendName;
await UI.asyncButton(btn, async () => {
await API.walks.invite(walk.id, friendId);
row.innerHTML = `
<div class="walks-inv-avatar">${_avatarInitials(name)}</div>
<div class="walks-inv-name" style="flex:1">${_esc(name)}</div>
<span class="walks-rsvp-badge walks-rsvp--invited">Eingeladen</span>
`;
UI.toast.success(`${name} eingeladen.`);
});
});
});
}
// ----------------------------------------------------------
// Beitreten-Formular (Hunde wählen)
// ----------------------------------------------------------
@ -547,7 +704,15 @@ window.Page_walks = (() => {
<!-- Mini-Karte -->
<div style="position:relative">
<div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:180px;background:var(--c-surface-2)"></div>
<div id="wf-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:200px;background:var(--c-surface-2)"></div>
<button type="button" id="wf-map-pin-here" style="
position:absolute;bottom:10px;left:50%;transform:translateX(-50%);
z-index:1000;background:var(--c-primary);color:#fff;border:none;
border-radius:var(--radius-full);padding:6px 14px;font-size:var(--text-xs);
font-weight:600;box-shadow:var(--shadow-md);cursor:pointer;
display:flex;align-items:center;gap:6px;white-space:nowrap">
${UI.icon('map-pin')} Pin hier setzen
</button>
</div>
<!-- Ort-Chip -->
@ -615,7 +780,7 @@ window.Page_walks = (() => {
function _placeMarker(lat, lon) {
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
_miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap);
_miniMarker = L.marker([lat, lon], { draggable: true, icon: _mkIcon() }).addTo(_miniMap);
_miniMarker.on('dragend', () => {
const p = _miniMarker.getLatLng();
_locLat = p.lat; _locLon = p.lng;
@ -649,16 +814,18 @@ window.Page_walks = (() => {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_miniMap);
_miniMap.invalidateSize();
if (_locLat) {
_placeMarker(lat, lon);
_miniMarker.dragging.disable();
}
if (_locLat) _placeMarker(lat, lon);
_miniMap.on('click', e => {
if (!_mapEditing) return;
_setCoords(e.latlng.lat, e.latlng.lng);
_placeMarker(_locLat, _locLon);
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
document.getElementById('wf-map-pin-here')?.addEventListener('click', () => {
const c = _miniMap.getCenter();
_setCoords(c.lat, c.lng);
_placeMarker(c.lat, c.lng);
document.getElementById('wf-location-btn-label').textContent = 'POI suchen';
});
}, 150);
});

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v203';
const CACHE_VERSION = 'by-v205';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten