Sprint 7: Gassi-Treffen — Meetup-Feature komplett
- Backend: walks.py mit allen Endpoints (CRUD, join/leave, Haversine-Filter) - DB: walks, walk_participants, walk_participant_dogs Tabellen (bereits in database.py) - Frontend: walks.js — Liste/Karte-Toggle, Heute/Demnächst-Gruppierung, Detail-Modal mit Teilnehmerliste, Beitreten/Verlassen, Erstellen/Bearbeiten-Formulare - CSS: Walks-Komponenten (Card, Date-Badge, Spots-Anzeige, Map-View) - api.js: walks-Abschnitt (list, get, create, update, cancel, join, leave) - SW-Cache: by-v20 → by-v21
This commit is contained in:
parent
b9df636535
commit
ec17dfb029
6 changed files with 977 additions and 2 deletions
|
|
@ -59,6 +59,7 @@ from routes.ki import router as ki_router
|
||||||
from routes.tieraerzte import router as tieraerzte_router
|
from routes.tieraerzte import router as tieraerzte_router
|
||||||
from routes.places import router as places_router
|
from routes.places import router as places_router
|
||||||
from routes.routen import router as routen_router
|
from routes.routen import router as routen_router
|
||||||
|
from routes.walks import router as walks_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -70,6 +71,7 @@ app.include_router(ki_router, prefix="/api/ki", tags=["KI"])
|
||||||
app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"])
|
app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzte"])
|
||||||
app.include_router(places_router, prefix="/api/places", tags=["Orte"])
|
app.include_router(places_router, prefix="/api/places", tags=["Orte"])
|
||||||
app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
|
app.include_router(routen_router, prefix="/api/routes", tags=["Routen"])
|
||||||
|
app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
255
backend/routes/walks.py
Normal file
255
backend/routes/walks.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
"""BAN YARO — Gassi-Treffen"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from datetime import date
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from database import db
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine(lat1, lon1, lat2, lon2):
|
||||||
|
R = 6_371_000
|
||||||
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dp = math.radians(lat2 - lat1)
|
||||||
|
dl = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||||
|
return 2 * R * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Schemas
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
class WalkCreate(BaseModel):
|
||||||
|
titel: str
|
||||||
|
datum: str # YYYY-MM-DD
|
||||||
|
uhrzeit: str # HH:MM
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
ort_name: Optional[str] = None
|
||||||
|
max_teilnehmer: int = 10
|
||||||
|
beschreibung: Optional[str] = None
|
||||||
|
|
||||||
|
class WalkUpdate(BaseModel):
|
||||||
|
titel: Optional[str] = None
|
||||||
|
datum: Optional[str] = None
|
||||||
|
uhrzeit: Optional[str] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lon: Optional[float] = None
|
||||||
|
ort_name: Optional[str] = None
|
||||||
|
max_teilnehmer: Optional[int] = None
|
||||||
|
beschreibung: Optional[str] = None
|
||||||
|
|
||||||
|
class JoinRequest(BaseModel):
|
||||||
|
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/walks — alle offenen Treffen (ab heute, optional Umkreis)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("")
|
||||||
|
async def list_walks(
|
||||||
|
lat: Optional[float] = None,
|
||||||
|
lon: Optional[float] = None,
|
||||||
|
radius: int = 20000,
|
||||||
|
alle: bool = False, # True → auch vergangene / stornierte
|
||||||
|
):
|
||||||
|
today = date.today().isoformat()
|
||||||
|
with db() as conn:
|
||||||
|
q = """
|
||||||
|
SELECT w.*,
|
||||||
|
u.name AS veranstalter_name,
|
||||||
|
COUNT(DISTINCT wp.user_id) AS teilnehmer_count
|
||||||
|
FROM walks w
|
||||||
|
LEFT JOIN users u ON u.id = w.user_id
|
||||||
|
LEFT JOIN walk_participants wp ON wp.walk_id = w.id
|
||||||
|
WHERE w.status != 'storniert'
|
||||||
|
"""
|
||||||
|
if not alle:
|
||||||
|
q += f" AND w.datum >= '{today}'"
|
||||||
|
q += " GROUP BY w.id ORDER BY w.datum ASC, w.uhrzeit ASC"
|
||||||
|
rows = conn.execute(q).fetchall()
|
||||||
|
|
||||||
|
result = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# Umkreis-Filter
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/walks — Treffen erstellen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_walk(data: WalkCreate, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
cur = conn.execute("""
|
||||||
|
INSERT INTO walks (user_id, titel, datum, uhrzeit, lat, lon,
|
||||||
|
ort_name, max_teilnehmer, beschreibung)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (user['id'], data.titel, data.datum, data.uhrzeit,
|
||||||
|
data.lat, data.lon, data.ort_name,
|
||||||
|
data.max_teilnehmer, data.beschreibung))
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT w.*, u.name AS veranstalter_name FROM walks w "
|
||||||
|
"LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?",
|
||||||
|
(cur.lastrowid,)
|
||||||
|
).fetchone()
|
||||||
|
return {**dict(row), 'teilnehmer_count': 0}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/walks/{id} — Detail mit Teilnehmerliste
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/{walk_id}")
|
||||||
|
async def get_walk(walk_id: int):
|
||||||
|
with db() as conn:
|
||||||
|
walk = conn.execute(
|
||||||
|
"SELECT w.*, u.name AS veranstalter_name FROM walks w "
|
||||||
|
"LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?",
|
||||||
|
(walk_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not walk:
|
||||||
|
raise HTTPException(404, "Treffen nicht gefunden.")
|
||||||
|
|
||||||
|
# Teilnehmer mit Hunden
|
||||||
|
participants = conn.execute("""
|
||||||
|
SELECT wp.user_id, u.name AS user_name,
|
||||||
|
GROUP_CONCAT(d.name, ', ') AS hunde
|
||||||
|
FROM walk_participants wp
|
||||||
|
JOIN users u ON u.id = wp.user_id
|
||||||
|
LEFT JOIN walk_participant_dogs wpd
|
||||||
|
ON wpd.walk_id = wp.walk_id AND wpd.user_id = wp.user_id
|
||||||
|
LEFT JOIN dogs d ON d.id = wpd.dog_id
|
||||||
|
WHERE wp.walk_id = ?
|
||||||
|
GROUP BY wp.user_id
|
||||||
|
""", (walk_id,)).fetchall()
|
||||||
|
|
||||||
|
result = dict(walk)
|
||||||
|
result['teilnehmer'] = [dict(p) for p in participants]
|
||||||
|
result['teilnehmer_count'] = len(result['teilnehmer'])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PATCH /api/walks/{id}
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.patch("/{walk_id}")
|
||||||
|
async def update_walk(walk_id: int, data: WalkUpdate, 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 das Treffen bearbeiten.")
|
||||||
|
|
||||||
|
updates = data.model_dump(exclude_none=True)
|
||||||
|
if updates:
|
||||||
|
cols = ', '.join(f"{k} = ?" for k in updates)
|
||||||
|
conn.execute(f"UPDATE walks SET {cols} WHERE id = ?", [*updates.values(), walk_id])
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT w.*, u.name AS veranstalter_name FROM walks w "
|
||||||
|
"LEFT JOIN users u ON u.id = w.user_id WHERE w.id = ?",
|
||||||
|
(walk_id,)
|
||||||
|
).fetchone()
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
return {**dict(row), 'teilnehmer_count': count}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DELETE /api/walks/{id} — stornieren
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.delete("/{walk_id}", status_code=204)
|
||||||
|
async def cancel_walk(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 das Treffen stornieren.")
|
||||||
|
conn.execute("UPDATE walks SET status = 'storniert' WHERE id = ?", (walk_id,))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/walks/{id}/join — beitreten
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/{walk_id}/join")
|
||||||
|
async def join_walk(walk_id: int, data: JoinRequest, 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['status'] != 'offen':
|
||||||
|
raise HTTPException(400, "Dieses Treffen ist nicht mehr offen.")
|
||||||
|
|
||||||
|
# Bereits beigetreten?
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT 1 FROM walk_participants WHERE walk_id = ? AND user_id = ?",
|
||||||
|
(walk_id, user['id'])
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(409, "Du nimmst bereits teil.")
|
||||||
|
|
||||||
|
# Platz frei?
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
if count >= walk['max_teilnehmer']:
|
||||||
|
raise HTTPException(400, "Das Treffen ist bereits voll.")
|
||||||
|
|
||||||
|
# Beitreten
|
||||||
|
primary_dog = data.dog_ids[0] if data.dog_ids else None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO walk_participants (walk_id, user_id, dog_id) VALUES (?, ?, ?)",
|
||||||
|
(walk_id, user['id'], primary_dog)
|
||||||
|
)
|
||||||
|
# Hunde eintragen
|
||||||
|
for dog_id in data.dog_ids:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO walk_participant_dogs (walk_id, user_id, dog_id) VALUES (?, ?, ?)",
|
||||||
|
(walk_id, user['id'], dog_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
new_count = count + 1
|
||||||
|
if new_count >= walk['max_teilnehmer']:
|
||||||
|
conn.execute("UPDATE walks SET status = 'voll' WHERE id = ?", (walk_id,))
|
||||||
|
|
||||||
|
return {"status": "joined", "teilnehmer_count": new_count}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# DELETE /api/walks/{id}/join — verlassen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.delete("/{walk_id}/join", status_code=200)
|
||||||
|
async def leave_walk(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(400, "Als Veranstalter kannst du nicht austreten — storniere das Treffen stattdessen.")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM walk_participants WHERE walk_id = ? AND user_id = ?",
|
||||||
|
(walk_id, user['id'])
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM walk_participant_dogs WHERE walk_id = ? AND user_id = ?",
|
||||||
|
(walk_id, user['id'])
|
||||||
|
)
|
||||||
|
# Status ggf. wieder auf offen setzen
|
||||||
|
count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM walk_participants WHERE walk_id = ?", (walk_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
if walk['status'] == 'voll':
|
||||||
|
conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,))
|
||||||
|
|
||||||
|
return {"status": "left", "teilnehmer_count": count}
|
||||||
|
|
@ -1619,3 +1619,124 @@ textarea.form-control {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------
|
||||||
|
GASSI-TREFFEN (walks.js)
|
||||||
|
------------------------------------------------------------ */
|
||||||
|
.walks-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.walks-view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
background: var(--c-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.walks-view-btn {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.walks-view-btn.active {
|
||||||
|
background: var(--c-surface);
|
||||||
|
color: var(--c-text);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
.walks-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.walks-section-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
.walks-card {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px 1fr auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
.walks-card:hover { box-shadow: var(--shadow-md); }
|
||||||
|
.walks-card.today { border-left: 3px solid var(--c-amber, #f59e0b); }
|
||||||
|
.walks-card.full { opacity: 0.6; }
|
||||||
|
.walks-date-badge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--c-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
min-width: 52px;
|
||||||
|
}
|
||||||
|
.walks-date-badge .day { font-size: var(--text-xs); color: var(--c-text-secondary); }
|
||||||
|
.walks-date-badge .num { font-size: 1.5rem; font-weight: var(--weight-bold); line-height: 1.1; }
|
||||||
|
.walks-date-badge .month { font-size: var(--text-xs); color: var(--c-text-secondary); }
|
||||||
|
.walks-date-badge.today-badge .num { color: var(--c-amber, #f59e0b); }
|
||||||
|
.walks-card-body { min-width: 0; }
|
||||||
|
.walks-card-title {
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.walks-card-meta {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
.walks-card-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.walks-spots {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--c-success);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.walks-spots.full { color: var(--c-text-muted); }
|
||||||
|
.walks-spots.today { color: var(--c-amber, #f59e0b); }
|
||||||
|
.walks-map {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.walks-participant {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.walks-participant:last-child { border-bottom: none; }
|
||||||
|
.walks-participant-name { font-weight: var(--weight-semibold); }
|
||||||
|
.walks-participant-dogs { color: var(--c-text-secondary); }
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,23 @@ const API = (() => {
|
||||||
delete(id) { return del(`/routes/${id}`); },
|
delete(id) { return del(`/routes/${id}`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// GASSI-TREFFEN
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const walks = {
|
||||||
|
list(lat = null, lon = null, radius = 20000) {
|
||||||
|
const params = new URLSearchParams({ radius });
|
||||||
|
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
|
||||||
|
return get(`/walks?${params}`);
|
||||||
|
},
|
||||||
|
get(id) { return get(`/walks/${id}`); },
|
||||||
|
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`); },
|
||||||
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// WETTER
|
// WETTER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -267,7 +284,7 @@ const API = (() => {
|
||||||
return {
|
return {
|
||||||
get, post, put, patch, del, upload,
|
get, post, put, patch, del, upload,
|
||||||
auth, dogs, diary, health, tieraerzte, poison,
|
auth, dogs, diary, health, tieraerzte, poison,
|
||||||
places, routes, weather, push,
|
places, routes, walks, weather, push,
|
||||||
subscribeToPush, getLocation,
|
subscribeToPush, getLocation,
|
||||||
APIError,
|
APIError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
580
backend/static/js/pages/walks.js
Normal file
580
backend/static/js/pages/walks.js
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
/* ============================================================
|
||||||
|
BAN YARO — Gassi-Treffen
|
||||||
|
Treffen entdecken, erstellen, beitreten
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
window.Page_walks = (() => {
|
||||||
|
|
||||||
|
let _container = null;
|
||||||
|
let _appState = null;
|
||||||
|
let _data = [];
|
||||||
|
let _view = 'liste'; // 'liste' | 'karte'
|
||||||
|
let _map = null;
|
||||||
|
let _markers = [];
|
||||||
|
let _leafletLoaded = false;
|
||||||
|
let _userPos = null;
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datum deutsch formatieren: "2026-04-20" → "Sonntag, 20. April 2026"
|
||||||
|
function _fmtDate(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso + 'T12:00:00');
|
||||||
|
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datum kurz: "So, 20.04."
|
||||||
|
function _fmtDateShort(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso + 'T12:00:00');
|
||||||
|
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isToday(iso) {
|
||||||
|
return iso === new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isPast(iso) {
|
||||||
|
return iso < new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// INIT
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function init(container, appState) {
|
||||||
|
_container = container;
|
||||||
|
_appState = appState;
|
||||||
|
_render();
|
||||||
|
try { _userPos = await API.getLocation(); } catch {}
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() { _loadData(); }
|
||||||
|
function onDogChange() {}
|
||||||
|
function openNew() { _showCreateForm(); }
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// RENDER — Grundstruktur
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _render() {
|
||||||
|
_container.innerHTML = `
|
||||||
|
<div class="walks-layout">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="walks-toolbar">
|
||||||
|
<div class="walks-view-toggle" id="walks-view-toggle">
|
||||||
|
<button class="walks-view-btn active" data-view="liste">📋 Liste</button>
|
||||||
|
<button class="walks-view-btn" data-view="karte">🗺️ Karte</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="walks-create-btn">+ Treffen planen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste -->
|
||||||
|
<div id="walks-list-view" class="walks-content">
|
||||||
|
<div id="walks-list">
|
||||||
|
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Karte -->
|
||||||
|
<div id="walks-map-view" class="walks-content" style="display:none">
|
||||||
|
<div id="walks-map" class="walks-map"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('walks-view-toggle').addEventListener('click', e => {
|
||||||
|
const btn = e.target.closest('.walks-view-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
_switchView(btn.dataset.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('walks-create-btn').addEventListener('click', () => {
|
||||||
|
if (!_appState.user) {
|
||||||
|
UI.toast.warning('Bitte zuerst anmelden.');
|
||||||
|
App.navigate('settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showCreateForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _switchView(view) {
|
||||||
|
_view = view;
|
||||||
|
document.querySelectorAll('.walks-view-btn').forEach(b =>
|
||||||
|
b.classList.toggle('active', b.dataset.view === view));
|
||||||
|
document.getElementById('walks-list-view').style.display = view === 'liste' ? '' : 'none';
|
||||||
|
document.getElementById('walks-map-view').style.display = view === 'karte' ? '' : 'none';
|
||||||
|
|
||||||
|
if (view === 'karte') {
|
||||||
|
_loadLeaflet().then(() => {
|
||||||
|
_initMap();
|
||||||
|
setTimeout(() => _map?.invalidateSize(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Daten laden
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _loadData() {
|
||||||
|
try {
|
||||||
|
_data = await API.walks.list(
|
||||||
|
_userPos?.lat ?? null,
|
||||||
|
_userPos?.lon ?? null
|
||||||
|
);
|
||||||
|
_renderList();
|
||||||
|
_renderMarkers();
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error(err.message || 'Fehler beim Laden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Liste rendern
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _renderList() {
|
||||||
|
const el = document.getElementById('walks-list');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (!_data.length) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
|
||||||
|
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐕</div>
|
||||||
|
<p style="color:var(--c-text-secondary)">Noch keine Treffen in deiner Nähe.</p>
|
||||||
|
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="walks-first-btn">
|
||||||
|
Erstes Treffen planen
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('walks-first-btn')?.addEventListener('click', _showCreateForm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heute + zukünftige Treffen
|
||||||
|
const heute = _data.filter(w => _isToday(w.datum));
|
||||||
|
const upcoming = _data.filter(w => !_isToday(w.datum) && !_isPast(w.datum));
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (heute.length) {
|
||||||
|
html += `<div class="walks-section-label">🌟 Heute</div>`;
|
||||||
|
html += heute.map(w => _walkCardHTML(w)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcoming.length) {
|
||||||
|
html += `<div class="walks-section-label">📅 Demnächst</div>`;
|
||||||
|
html += upcoming.map(w => _walkCardHTML(w)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `<div class="walks-list-inner">${html}</div>`;
|
||||||
|
|
||||||
|
el.querySelectorAll('.walks-card').forEach(card => {
|
||||||
|
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _walkCardHTML(w) {
|
||||||
|
const isOwn = _appState.user?.id === w.user_id;
|
||||||
|
const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer;
|
||||||
|
const today = _isToday(w.datum);
|
||||||
|
const spots = w.max_teilnehmer - w.teilnehmer_count;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="walks-card ${today ? 'walks-card--today' : ''}" data-id="${w.id}">
|
||||||
|
<div class="walks-card-date">
|
||||||
|
<div class="walks-card-day">${_fmtDateShort(w.datum)}</div>
|
||||||
|
<div class="walks-card-time">${w.uhrzeit}</div>
|
||||||
|
</div>
|
||||||
|
<div class="walks-card-body">
|
||||||
|
<div class="walks-card-title">${_esc(w.titel)}</div>
|
||||||
|
${w.ort_name ? `<div class="walks-card-ort">📍 ${_esc(w.ort_name)}</div>` : ''}
|
||||||
|
<div class="walks-card-meta">
|
||||||
|
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
|
||||||
|
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
|
||||||
|
</span>
|
||||||
|
<span class="walks-badge">🐾 ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
|
||||||
|
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="walks-card-arrow">›</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Leaflet + Karte
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _loadLeaflet() {
|
||||||
|
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
await new Promise(resolve => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = '/js/leaflet.js'; s.onload = resolve;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
_leafletLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _initMap() {
|
||||||
|
const el = document.getElementById('walks-map');
|
||||||
|
if (!el || !window.L || _map) return;
|
||||||
|
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
|
||||||
|
_map = L.map('walks-map', { zoomControl: true, attributionControl: false })
|
||||||
|
.setView(center, _userPos ? 12 : 6);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
|
||||||
|
_renderMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderMarkers() {
|
||||||
|
if (!_map || !window.L) return;
|
||||||
|
_markers.forEach(m => m.remove());
|
||||||
|
_markers = [];
|
||||||
|
_data.forEach(w => {
|
||||||
|
const isFull = w.status === 'voll' || w.teilnehmer_count >= w.max_teilnehmer;
|
||||||
|
const color = _isToday(w.datum) ? '#C4843A' : (isFull ? '#6B7280' : '#22C55E');
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="background:${color};color:#fff;font-size:14px;font-weight:700;
|
||||||
|
width:32px;height:32px;border-radius:50%;display:flex;align-items:center;
|
||||||
|
justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);
|
||||||
|
border:2px solid rgba(255,255,255,0.8)">🐕</div>`,
|
||||||
|
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||||
|
});
|
||||||
|
const m = L.marker([w.lat, w.lon], { icon })
|
||||||
|
.addTo(_map)
|
||||||
|
.bindTooltip(`${w.titel} · ${_fmtDateShort(w.datum)} ${w.uhrzeit}`, { direction: 'top', offset: [0,-16] })
|
||||||
|
.on('click', () => _openDetail(w.id));
|
||||||
|
_markers.push(m);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Detail-Modal
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _openDetail(walkId) {
|
||||||
|
let walk;
|
||||||
|
try {
|
||||||
|
walk = await API.walks.get(walkId);
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwn = _appState.user?.id === walk.user_id;
|
||||||
|
const isJoined = walk.teilnehmer?.some(t => t.user_id === _appState.user?.id);
|
||||||
|
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 teilnehmerHTML = walk.teilnehmer?.length
|
||||||
|
? walk.teilnehmer.map(t => `
|
||||||
|
<div class="walks-participant">
|
||||||
|
<span class="walks-participant-name">🧑 ${_esc(t.user_name)}</span>
|
||||||
|
${t.hunde ? `<span class="walks-participant-hunde">🐕 ${_esc(t.hunde)}</span>` : ''}
|
||||||
|
</div>`).join('')
|
||||||
|
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<div class="walks-detail-header">
|
||||||
|
<div class="walks-detail-date">
|
||||||
|
${_fmtDate(walk.datum)}<br>
|
||||||
|
<strong>um ${walk.uhrzeit} Uhr</strong>
|
||||||
|
</div>
|
||||||
|
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">📍 ${_esc(walk.ort_name)}</div>` : ''}
|
||||||
|
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
|
||||||
|
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
|
||||||
|
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
|
||||||
|
</span>
|
||||||
|
<span class="walks-badge">🐾 ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
|
||||||
|
${isOwn ? '<span class="walks-badge walks-badge--own">Dein Treffen</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${walk.beschreibung ? `
|
||||||
|
<p style="margin:var(--space-4) 0;color:var(--c-text-secondary)">${_esc(walk.beschreibung)}</p>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="walks-detail-section">
|
||||||
|
<div class="walks-detail-section-label">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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let footer;
|
||||||
|
if (isOwn) {
|
||||||
|
footer = `
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="wd-cancel-walk" style="color:var(--c-danger)">Stornieren</button>
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" id="wd-edit">Bearbeiten</button>
|
||||||
|
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
|
||||||
|
`;
|
||||||
|
} else if (!_appState.user) {
|
||||||
|
footer = `
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
|
||||||
|
<button type="button" class="btn btn-primary flex-1" id="wd-login">Anmelden zum Beitreten</button>
|
||||||
|
`;
|
||||||
|
} else if (isJoined) {
|
||||||
|
footer = `
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="wd-leave" style="color:var(--c-danger)">Nicht mehr teilnehmen</button>
|
||||||
|
<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>
|
||||||
|
`;
|
||||||
|
} else if (isPast || isFull) {
|
||||||
|
footer = `<button type="button" class="btn btn-primary flex-1" id="wd-close">Schließen</button>`;
|
||||||
|
} else {
|
||||||
|
footer = `
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
|
||||||
|
<button type="button" class="btn btn-primary flex-1" id="wd-join">🐕 Mitmachen</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
UI.modal.open({ title: `🐕 ${walk.titel}`, body, footer });
|
||||||
|
|
||||||
|
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
document.getElementById('wd-login')?.addEventListener('click', () => {
|
||||||
|
UI.modal.close();
|
||||||
|
App.navigate('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('wd-edit')?.addEventListener('click', () => {
|
||||||
|
UI.modal.close();
|
||||||
|
_showEditForm(walk);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('wd-cancel-walk')?.addEventListener('click', async () => {
|
||||||
|
const ok = await UI.modal.confirm({
|
||||||
|
title: 'Treffen stornieren?',
|
||||||
|
message: 'Alle Teilnehmer werden benachrichtigt. Nicht rückgängig.',
|
||||||
|
confirmText: 'Stornieren', danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await API.walks.cancel(walk.id);
|
||||||
|
_data = _data.filter(w => w.id !== walk.id);
|
||||||
|
UI.modal.close();
|
||||||
|
_renderList();
|
||||||
|
_renderMarkers();
|
||||||
|
UI.toast.success('Treffen storniert.');
|
||||||
|
} catch (err) { UI.toast.error(err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('wd-join')?.addEventListener('click', () => {
|
||||||
|
UI.modal.close();
|
||||||
|
_showJoinForm(walk);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('wd-leave')?.addEventListener('click', async () => {
|
||||||
|
const ok = await UI.modal.confirm({
|
||||||
|
title: 'Nicht mehr teilnehmen?',
|
||||||
|
message: `Du verlässt „${walk.titel}".`,
|
||||||
|
confirmText: 'Austreten',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const res = await API.walks.leave(walk.id);
|
||||||
|
const idx = _data.findIndex(w => w.id === walk.id);
|
||||||
|
if (idx !== -1) _data[idx].teilnehmer_count = res.teilnehmer_count;
|
||||||
|
UI.modal.close();
|
||||||
|
_renderList();
|
||||||
|
UI.toast.success('Du nimmst nicht mehr teil.');
|
||||||
|
} catch (err) { UI.toast.error(err.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Beitreten-Formular (Hunde wählen)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _showJoinForm(walk) {
|
||||||
|
const dogs = _appState.dogs || [];
|
||||||
|
const dogsHtml = dogs.length
|
||||||
|
? dogs.map(d => `
|
||||||
|
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;
|
||||||
|
padding:var(--space-2) 0">
|
||||||
|
<input type="checkbox" name="dog" value="${d.id}" checked>
|
||||||
|
🐕 ${_esc(d.name)}
|
||||||
|
</label>`).join('')
|
||||||
|
: `<p style="color:var(--c-text-muted)">Keine Hunde im Profil — du kannst trotzdem mitmachen.</p>`;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||||
|
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br>
|
||||||
|
${walk.ort_name ? `📍 ${_esc(walk.ort_name)}` : ''}
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Mit welchen Hunden?</label>
|
||||||
|
${dogsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const footer = `
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
|
||||||
|
<button type="button" class="btn btn-primary flex-1" id="join-confirm">🐕 Mitmachen</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
UI.modal.open({ title: `Treffen beitreten`, body, footer });
|
||||||
|
|
||||||
|
document.getElementById('join-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
document.getElementById('join-confirm')?.addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('join-confirm');
|
||||||
|
const checked = [...document.querySelectorAll('[name="dog"]:checked')];
|
||||||
|
const dogIds = checked.map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
const res = await API.walks.join(walk.id, dogIds);
|
||||||
|
const idx = _data.findIndex(w => w.id === walk.id);
|
||||||
|
if (idx !== -1) _data[idx].teilnehmer_count = res.teilnehmer_count;
|
||||||
|
UI.modal.close();
|
||||||
|
_renderList();
|
||||||
|
_renderMarkers();
|
||||||
|
UI.toast.success(`Du nimmst teil! 🎉`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Treffen erstellen
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _showCreateForm(prefill = {}) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
_showWalkForm(null, { datum: today, uhrzeit: '10:00', ...prefill });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showEditForm(walk) {
|
||||||
|
_showWalkForm(walk);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showWalkForm(walk, defaults = {}) {
|
||||||
|
const isEdit = !!walk;
|
||||||
|
const v = walk || defaults;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<form id="walk-form" autocomplete="off">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titel *</label>
|
||||||
|
<input class="form-control" type="text" name="titel"
|
||||||
|
value="${_esc(v.titel || '')}"
|
||||||
|
placeholder="z. B. Sonntagsspaziergang im Stadtpark" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Datum *</label>
|
||||||
|
<input class="form-control" type="date" name="datum"
|
||||||
|
value="${_esc(v.datum || '')}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Uhrzeit *</label>
|
||||||
|
<input class="form-control" type="time" name="uhrzeit"
|
||||||
|
value="${_esc(v.uhrzeit || '10:00')}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Treffpunkt</label>
|
||||||
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
|
<input class="form-control" type="text" name="ort_name"
|
||||||
|
value="${_esc(v.ort_name || '')}"
|
||||||
|
placeholder="z. B. Parkeingang Nordseite, U-Bahn Volkspark"
|
||||||
|
style="flex:1">
|
||||||
|
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">📍</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="lat" id="walk-lat" value="${v.lat || ''}">
|
||||||
|
<input type="hidden" name="lon" id="walk-lon" value="${v.lon || ''}">
|
||||||
|
<small id="walk-gps-hint" style="color:var(--c-text-secondary)">
|
||||||
|
${v.lat ? '✅ Position gespeichert' : 'GPS-Button für aktuellen Standort'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Max. Teilnehmer</label>
|
||||||
|
<input class="form-control" type="number" name="max_teilnehmer"
|
||||||
|
value="${v.max_teilnehmer || 10}" min="2" max="50">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||||
|
<textarea class="form-control" name="beschreibung" rows="3"
|
||||||
|
placeholder="Treffpunkt-Details, Streckenlänge, Hundefreundlichkeit…">${_esc(v.beschreibung || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const footer = `
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button>
|
||||||
|
<button type="submit" form="walk-form" class="btn btn-primary flex-1">
|
||||||
|
${isEdit ? 'Speichern' : '📅 Treffen planen'}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : '🐕 Treffen planen', body, footer });
|
||||||
|
|
||||||
|
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
document.getElementById('walk-gps-btn')?.addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('walk-gps-btn');
|
||||||
|
UI.setLoading(btn, true);
|
||||||
|
try {
|
||||||
|
const pos = await API.getLocation({ enableHighAccuracy: true });
|
||||||
|
_userPos = pos;
|
||||||
|
document.getElementById('walk-lat').value = pos.lat;
|
||||||
|
document.getElementById('walk-lon').value = pos.lon;
|
||||||
|
document.getElementById('walk-gps-hint').textContent = '✅ Standort ermittelt';
|
||||||
|
} catch { UI.toast.error('GPS nicht verfügbar.'); }
|
||||||
|
UI.setLoading(btn, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('walk-form')?.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.querySelector('[form="walk-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
|
||||||
|
const fd = UI.formData(e.target);
|
||||||
|
|
||||||
|
if (!fd.lat || !fd.lon) {
|
||||||
|
UI.toast.warning('Bitte GPS-Position ermitteln (📍).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
const payload = {
|
||||||
|
titel: fd.titel?.trim(),
|
||||||
|
datum: fd.datum,
|
||||||
|
uhrzeit: fd.uhrzeit,
|
||||||
|
lat: parseFloat(fd.lat),
|
||||||
|
lon: parseFloat(fd.lon),
|
||||||
|
ort_name: fd.ort_name || null,
|
||||||
|
max_teilnehmer: parseInt(fd.max_teilnehmer) || 10,
|
||||||
|
beschreibung: fd.beschreibung || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
const updated = await API.walks.update(walk.id, payload);
|
||||||
|
const idx = _data.findIndex(w => w.id === walk.id);
|
||||||
|
if (idx !== -1) _data[idx] = { ..._data[idx], ...updated };
|
||||||
|
UI.toast.success('Treffen aktualisiert.');
|
||||||
|
} else {
|
||||||
|
const created = await API.walks.create(payload);
|
||||||
|
_data.unshift({ ...created, teilnehmer_count: 0 });
|
||||||
|
// Beim eigenen neuen Treffen gleich beitreten?
|
||||||
|
// Nein — Veranstalter ist automatisch dabei (für Teilnehmer-Sicht)
|
||||||
|
UI.toast.success('Treffen geplant! 🎉');
|
||||||
|
}
|
||||||
|
|
||||||
|
UI.modal.close();
|
||||||
|
_renderList();
|
||||||
|
_renderMarkers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, refresh, onDogChange, openNew };
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications
|
Offline-Cache + Push Notifications
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v20';
|
const CACHE_VERSION = 'by-v21';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
|
|
||||||
// Diese Dateien werden beim Install gecacht (App Shell)
|
// Diese Dateien werden beim Install gecacht (App Shell)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue