diff --git a/backend/database.py b/backend/database.py index 3559537..e2712ce 100644 --- a/backend/database.py +++ b/backend/database.py @@ -249,6 +249,52 @@ def init_db(): created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + -- EVENTS (Hundeveranstaltungen) + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + titel TEXT NOT NULL, + datum TEXT NOT NULL, -- YYYY-MM-DD + uhrzeit TEXT, + lat REAL, + lon REAL, + ort_name TEXT, + typ TEXT NOT NULL DEFAULT 'sonstiges', + beschreibung TEXT, + link TEXT, + status TEXT NOT NULL DEFAULT 'aktiv', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_events_datum ON events(datum ASC); + + -- SITTING — Sitter-Profile + CREATE TABLE IF NOT EXISTS sitters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + beschreibung TEXT, + preis_pro_tag REAL DEFAULT 0, + max_hunde INTEGER DEFAULT 1, + lat REAL, + lon REAL, + radius_km INTEGER DEFAULT 20, + services TEXT DEFAULT '[]', -- JSON: ['tagesbetreuung','uebernachtung','gassi','hausbesuch'] + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- SITTING — Anfragen + CREATE TABLE IF NOT EXISTS sitting_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), -- Anfragender + sitter_id INTEGER NOT NULL REFERENCES sitters(id), + dog_ids TEXT DEFAULT '[]', -- JSON Array + von TEXT NOT NULL, -- YYYY-MM-DD + bis TEXT NOT NULL, + nachricht TEXT, + status TEXT NOT NULL DEFAULT 'offen', -- offen|angenommen|abgelehnt|abgebrochen + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- TIERÄRZTE (user-level, nie löschen — Historien-Erhalt bei Umzug) CREATE TABLE IF NOT EXISTS tieraerzte ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/main.py b/backend/main.py index d7dc7d6..13b6b3a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -60,6 +60,8 @@ from routes.tieraerzte import router as tieraerzte_router from routes.places import router as places_router from routes.routen import router as routen_router from routes.walks import router as walks_router +from routes.events import router as events_router +from routes.sitting import router as sitting_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -72,6 +74,8 @@ app.include_router(tieraerzte_router, prefix="/api/tieraerzte", tags=["Tierärzt app.include_router(places_router, prefix="/api/places", tags=["Orte"]) app.include_router(routen_router, prefix="/api/routes", tags=["Routen"]) app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"]) +app.include_router(events_router, prefix="/api/events", tags=["Events"]) +app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) # ------------------------------------------------------------------ diff --git a/backend/routes/events.py b/backend/routes/events.py new file mode 100644 index 0000000..85e1811 --- /dev/null +++ b/backend/routes/events.py @@ -0,0 +1,153 @@ +"""BAN YARO — Events (Hundeveranstaltungen)""" + +import math +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + +TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'} + + +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 EventCreate(BaseModel): + titel: str + datum: str # YYYY-MM-DD + uhrzeit: Optional[str] = None + lat: Optional[float] = None + lon: Optional[float] = None + ort_name: Optional[str] = None + typ: str = 'sonstiges' + beschreibung: Optional[str] = None + link: Optional[str] = None + +class EventUpdate(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 + typ: Optional[str] = None + beschreibung: Optional[str] = None + link: Optional[str] = None + + +# ------------------------------------------------------------------ +# GET /api/events +# ------------------------------------------------------------------ +@router.get("") +async def list_events( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 50000, + typ: Optional[str] = None, + alle: bool = False, +): + today = date.today().isoformat() + with db() as conn: + q = "SELECT e.*, u.name AS veranstalter_name FROM events e LEFT JOIN users u ON u.id = e.user_id WHERE e.status = 'aktiv'" + if not alle: + q += f" AND e.datum >= '{today}'" + if typ and typ in TYPEN: + q += f" AND e.typ = '{typ}'" + q += " ORDER BY e.datum ASC, e.uhrzeit ASC" + rows = conn.execute(q).fetchall() + + result = [dict(r) for r in rows] + if lat is not None and lon is not None: + result = [r for r in result + if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius] + return result + + +# ------------------------------------------------------------------ +# POST /api/events +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_event(data: EventCreate, user=Depends(get_current_user)): + if data.typ not in TYPEN: + raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(TYPEN)}") + with db() as conn: + cur = conn.execute(""" + INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user['id'], data.titel, data.datum, data.uhrzeit, + data.lat, data.lon, data.ort_name, + data.typ, data.beschreibung, data.link)) + row = conn.execute( + "SELECT e.*, u.name AS veranstalter_name FROM events e " + "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + (cur.lastrowid,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/events/{id} +# ------------------------------------------------------------------ +@router.get("/{event_id}") +async def get_event(event_id: int): + with db() as conn: + row = conn.execute( + "SELECT e.*, u.name AS veranstalter_name FROM events e " + "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + (event_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Event nicht gefunden.") + return dict(row) + + +# ------------------------------------------------------------------ +# PATCH /api/events/{id} +# ------------------------------------------------------------------ +@router.patch("/{event_id}") +async def update_event(event_id: int, data: EventUpdate, user=Depends(get_current_user)): + with db() as conn: + ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() + if not ev: + raise HTTPException(404, "Event nicht gefunden.") + if ev['user_id'] != user['id']: + raise HTTPException(403, "Nur der Veranstalter kann das Event bearbeiten.") + updates = data.model_dump(exclude_none=True) + if updates: + if 'typ' in updates and updates['typ'] not in TYPEN: + raise HTTPException(400, "Ungültiger Typ.") + cols = ', '.join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id]) + row = conn.execute( + "SELECT e.*, u.name AS veranstalter_name FROM events e " + "LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?", + (event_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/events/{id} +# ------------------------------------------------------------------ +@router.delete("/{event_id}", status_code=204) +async def delete_event(event_id: int, user=Depends(get_current_user)): + with db() as conn: + ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone() + if not ev: + raise HTTPException(404, "Event nicht gefunden.") + if ev['user_id'] != user['id']: + raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.") + conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,)) diff --git a/backend/routes/sitting.py b/backend/routes/sitting.py new file mode 100644 index 0000000..acfa2e6 --- /dev/null +++ b/backend/routes/sitting.py @@ -0,0 +1,259 @@ +"""BAN YARO — Hundesitting""" + +import json +import math +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() + +SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'} + + +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 SitterCreate(BaseModel): + beschreibung: Optional[str] = None + preis_pro_tag: float = 0 + max_hunde: int = 1 + lat: Optional[float] = None + lon: Optional[float] = None + radius_km: int = 20 + services: List[str] = [] + +class SitterUpdate(BaseModel): + beschreibung: Optional[str] = None + preis_pro_tag: Optional[float] = None + max_hunde: Optional[int] = None + lat: Optional[float] = None + lon: Optional[float] = None + radius_km: Optional[int] = None + services: Optional[List[str]] = None + aktiv: Optional[int] = None + +class RequestCreate(BaseModel): + sitter_id: int + dog_ids: List[int] = [] + von: str # YYYY-MM-DD + bis: str + nachricht: Optional[str] = None + +class RequestUpdate(BaseModel): + status: str # angenommen | abgelehnt | abgebrochen + + +# ------------------------------------------------------------------ +# GET /api/sitting — Sitter-Liste +# ------------------------------------------------------------------ +@router.get("") +async def list_sitters( + lat: Optional[float] = None, + lon: Optional[float] = None, + radius: int = 30000, + service: Optional[str] = None, +): + with db() as conn: + rows = conn.execute(""" + SELECT s.*, u.name AS sitter_name + FROM sitters s + JOIN users u ON u.id = s.user_id + WHERE s.aktiv = 1 + """).fetchall() + + result = [] + for r in rows: + d = dict(r) + d['services'] = json.loads(d['services'] or '[]') + if service and service not in d['services']: + continue + if lat is not None and lon is not None and d['lat'] and d['lon']: + dist = _haversine(lat, lon, d['lat'], d['lon']) + if dist > radius: + continue + d['distanz_m'] = round(dist) + result.append(d) + + if lat is not None: + result.sort(key=lambda x: x.get('distanz_m', 999999)) + return result + + +# ------------------------------------------------------------------ +# GET /api/sitting/me — eigenes Sitter-Profil +# ------------------------------------------------------------------ +@router.get("/me") +async def get_my_sitter_profile(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT * FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if not row: + return None + d = dict(row) + d['services'] = json.loads(d['services'] or '[]') + return d + + +# ------------------------------------------------------------------ +# POST /api/sitting — Sitter-Profil erstellen oder aktualisieren +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_sitter(data: SitterCreate, user=Depends(get_current_user)): + services_json = json.dumps([s for s in data.services if s in SERVICES]) + with db() as conn: + existing = conn.execute( + "SELECT id FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if existing: + raise HTTPException(409, "Du hast bereits ein Sitter-Profil. Nutze PATCH zum Aktualisieren.") + cur = conn.execute(""" + INSERT INTO sitters (user_id, beschreibung, preis_pro_tag, max_hunde, lat, lon, radius_km, services) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (user['id'], data.beschreibung, data.preis_pro_tag, + data.max_hunde, data.lat, data.lon, data.radius_km, services_json)) + row = conn.execute("SELECT * FROM sitters WHERE id = ?", (cur.lastrowid,)).fetchone() + d = dict(row) + d['services'] = json.loads(d['services'] or '[]') + return d + + +# ------------------------------------------------------------------ +# PATCH /api/sitting/me — eigenes Profil bearbeiten +# ------------------------------------------------------------------ +@router.patch("/me") +async def update_sitter(data: SitterUpdate, user=Depends(get_current_user)): + with db() as conn: + sitter = conn.execute( + "SELECT * FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if not sitter: + raise HTTPException(404, "Kein Sitter-Profil gefunden.") + updates = data.model_dump(exclude_none=True) + if 'services' in updates: + updates['services'] = json.dumps([s for s in updates['services'] if s in SERVICES]) + if updates: + cols = ', '.join(f"{k} = ?" for k in updates) + conn.execute(f"UPDATE sitters SET {cols} WHERE user_id = ?", + [*updates.values(), user['id']]) + row = conn.execute("SELECT * FROM sitters WHERE user_id = ?", (user['id'],)).fetchone() + d = dict(row) + d['services'] = json.loads(d['services'] or '[]') + return d + + +# ------------------------------------------------------------------ +# GET /api/sitting/requests — meine Anfragen (als Anfragender) +# ------------------------------------------------------------------ +@router.get("/requests") +async def list_my_requests(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute(""" + SELECT sr.*, u.name AS sitter_name + FROM sitting_requests sr + JOIN sitters s ON s.id = sr.sitter_id + JOIN users u ON u.id = s.user_id + WHERE sr.user_id = ? + ORDER BY sr.created_at DESC + """, (user['id'],)).fetchall() + result = [] + for r in rows: + d = dict(r) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + result.append(d) + return result + + +# ------------------------------------------------------------------ +# GET /api/sitting/inbox — eingehende Anfragen (als Sitter) +# ------------------------------------------------------------------ +@router.get("/inbox") +async def sitter_inbox(user=Depends(get_current_user)): + with db() as conn: + sitter = conn.execute( + "SELECT id FROM sitters WHERE user_id = ?", (user['id'],) + ).fetchone() + if not sitter: + return [] + rows = conn.execute(""" + SELECT sr.*, u.name AS anfragender_name + FROM sitting_requests sr + JOIN users u ON u.id = sr.user_id + WHERE sr.sitter_id = ? + ORDER BY sr.created_at DESC + """, (sitter['id'],)).fetchall() + result = [] + for r in rows: + d = dict(r) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + result.append(d) + return result + + +# ------------------------------------------------------------------ +# POST /api/sitting/requests — Anfrage senden +# ------------------------------------------------------------------ +@router.post("/requests", status_code=201) +async def create_request(data: RequestCreate, user=Depends(get_current_user)): + with db() as conn: + sitter = conn.execute( + "SELECT * FROM sitters WHERE id = ?", (data.sitter_id,) + ).fetchone() + if not sitter or not sitter['aktiv']: + raise HTTPException(404, "Sitter nicht gefunden oder nicht aktiv.") + if sitter['user_id'] == user['id']: + raise HTTPException(400, "Du kannst keine Anfrage an dich selbst schicken.") + cur = conn.execute(""" + INSERT INTO sitting_requests (user_id, sitter_id, dog_ids, von, bis, nachricht) + VALUES (?, ?, ?, ?, ?, ?) + """, (user['id'], data.sitter_id, json.dumps(data.dog_ids), + data.von, data.bis, data.nachricht)) + row = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (cur.lastrowid,)).fetchone() + d = dict(row) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + return d + + +# ------------------------------------------------------------------ +# PATCH /api/sitting/requests/{id} — Status ändern +# ------------------------------------------------------------------ +@router.patch("/requests/{req_id}") +async def update_request(req_id: int, data: RequestUpdate, user=Depends(get_current_user)): + allowed = {'angenommen', 'abgelehnt', 'abgebrochen'} + if data.status not in allowed: + raise HTTPException(400, f"Status muss einer von {allowed} sein.") + with db() as conn: + req = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (req_id,)).fetchone() + if not req: + raise HTTPException(404, "Anfrage nicht gefunden.") + # Anfragender kann nur abbrechen; Sitter kann annehmen/ablehnen + sitter = conn.execute( + "SELECT * FROM sitters WHERE id = ?", (req['sitter_id'],) + ).fetchone() + is_requester = req['user_id'] == user['id'] + is_sitter = sitter['user_id'] == user['id'] + if not is_requester and not is_sitter: + raise HTTPException(403, "Kein Zugriff.") + if is_requester and data.status != 'abgebrochen': + raise HTTPException(403, "Du kannst die Anfrage nur abbrechen.") + if is_sitter and data.status == 'abgebrochen': + raise HTTPException(403, "Als Sitter kannst du nur annehmen oder ablehnen.") + conn.execute( + "UPDATE sitting_requests SET status = ? WHERE id = ?", (data.status, req_id) + ) + row = conn.execute("SELECT * FROM sitting_requests WHERE id = ?", (req_id,)).fetchone() + d = dict(row) + d['dog_ids'] = json.loads(d['dog_ids'] or '[]') + return d diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 123905b..31e84c7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1740,3 +1740,285 @@ textarea.form-control { .walks-participant:last-child { border-bottom: none; } .walks-participant-name { font-weight: var(--weight-semibold); } .walks-participant-dogs { color: var(--c-text-secondary); } + +/* ------------------------------------------------------------ + EVENTS (events.js) + ------------------------------------------------------------ */ +.events-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; +} +.events-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); +} +.events-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; +} +.events-view-btn.active { + background: var(--c-surface); + color: var(--c-text); + box-shadow: var(--shadow-xs); +} +.events-filter-bar { + display: flex; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + overflow-x: auto; + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + flex-shrink: 0; + scrollbar-width: none; +} +.events-filter-bar::-webkit-scrollbar { display: none; } +.events-filter-btn { + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + border: 1.5px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-secondary); + font-size: var(--text-sm); + cursor: pointer; + white-space: nowrap; + transition: all 0.15s; + flex-shrink: 0; +} +.events-filter-btn.active { + background: var(--c-primary); + color: #fff; + border-color: var(--c-primary); +} +.events-list { + flex: 1; + overflow-y: auto; + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.events-map { + flex: 1; + position: relative; +} +.events-month-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + padding: var(--space-2) 0 var(--space-1); +} +.events-card { + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border); + border-left: 3px solid var(--c-primary); + padding: var(--space-3) var(--space-4); + display: grid; + grid-template-columns: 52px 1fr auto; + gap: var(--space-3); + cursor: pointer; + transition: box-shadow 0.15s; + align-items: center; + box-shadow: var(--shadow-xs); +} +.events-card:hover { box-shadow: var(--shadow-md); } +.events-date-badge { + display: flex; + flex-direction: column; + align-items: center; + background: var(--c-bg); + border-radius: var(--radius-md); + padding: var(--space-1); +} +.events-date-badge .day { font-size: var(--text-xs); color: var(--c-text-secondary); } +.events-date-badge .num { font-size: 1.5rem; font-weight: var(--weight-bold); line-height: 1.1; } +.events-date-badge .month { font-size: var(--text-xs); color: var(--c-text-secondary); } +.events-card-body { min-width: 0; } +.events-card-title { + font-weight: var(--weight-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: var(--space-1); +} +.events-card-meta { + font-size: var(--text-sm); + color: var(--c-text-secondary); + display: flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; +} +.events-badge { + display: inline-block; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); +} +.events-detail-row { + margin-bottom: var(--space-2); + color: var(--c-text); +} +.events-detail-desc { + background: var(--c-bg); + border-radius: var(--radius-md); + padding: var(--space-3); + margin: var(--space-3) 0; + color: var(--c-text-secondary); + white-space: pre-wrap; +} + +/* ------------------------------------------------------------ + SITTING (sitting.js) + ------------------------------------------------------------ */ +.sitting-tabs { + display: flex; + border-bottom: 1px solid var(--c-border); + background: var(--c-surface); + flex-shrink: 0; +} +.sitting-tab { + flex: 1; + padding: var(--space-3); + border: none; + background: transparent; + color: var(--c-text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} +.sitting-tab.active { + color: var(--c-primary); + border-bottom-color: var(--c-primary); +} +.sitting-content { + flex: 1; + overflow-y: auto; + padding: var(--space-4); +} +.sitting-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.sitting-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: 48px 1fr auto; + gap: var(--space-3); + align-items: flex-start; + cursor: pointer; + transition: box-shadow 0.15s; + box-shadow: var(--shadow-xs); +} +.sitting-card:hover { box-shadow: var(--shadow-md); } +.sitting-card-avatar { + width: 48px; + height: 48px; + background: var(--c-bg); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} +.sitting-card-name { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); } +.sitting-card-dist { font-size: var(--text-xs); color: var(--c-text-muted); margin-bottom: var(--space-1); } +.sitting-card-desc { font-size: var(--text-sm); color: var(--c-text-secondary); margin-bottom: var(--space-2); } +.sitting-price { font-weight: var(--weight-bold); color: var(--c-primary); } +.sitting-dogs { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: var(--space-1); } +.sitting-services { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} +.sit-service-badge { + background: var(--c-primary-light, #dbeafe); + color: var(--c-primary); + padding: 2px var(--space-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--weight-medium); +} +.sitting-empty-profil { + text-align: center; + padding: var(--space-8) var(--space-4); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); +} +.sitting-my-profil { display: flex; flex-direction: column; gap: var(--space-3); } +.sitting-profil-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.sitting-profil-status { + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); +} +.sitting-profil-status.active { background: #d1fae5; color: #065f46; } +.sitting-profil-status.inactive { background: #f3f4f6; color: #6b7280; } +.sitting-profil-facts { + display: flex; + gap: var(--space-4); +} +.sitting-profil-fact { font-size: var(--text-sm); color: var(--c-text-secondary); } +.sitting-section-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + margin-bottom: var(--space-2); +} +.sitting-request-card { + background: var(--c-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--c-border); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-2); + box-shadow: var(--shadow-xs); +} +.sitting-req-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-1); +} +.sitting-req-name { font-weight: var(--weight-semibold); } +.sitting-req-status { font-size: var(--text-sm); font-weight: var(--weight-semibold); text-transform: capitalize; } +.sitting-req-dates { font-size: var(--text-sm); color: var(--c-text-secondary); } +.sitting-req-msg { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); } +.sitting-req-actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-2); +} +.sitting-detail-avatar { + font-size: 4rem; + text-align: center; + margin-bottom: var(--space-2); +} diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 705b826..0809ac6 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -207,6 +207,39 @@ const API = (() => { leave(id) { return del(`/walks/${id}/join`); }, }; + // ---------------------------------------------------------- + // EVENTS + // ---------------------------------------------------------- + const events = { + list(params = {}) { + const q = new URLSearchParams(params).toString(); + return get(`/events${q ? '?' + q : ''}`); + }, + get(id) { return get(`/events/${id}`); }, + create(data) { return post('/events', data); }, + update(id, data) { return patch(`/events/${id}`, data); }, + delete(id) { return del(`/events/${id}`); }, + }; + + // ---------------------------------------------------------- + // SITTING + // ---------------------------------------------------------- + const sitting = { + list(lat = null, lon = null, radius = 30000, service = null) { + const p = new URLSearchParams({ radius }); + if (lat !== null) { p.set('lat', lat); p.set('lon', lon); } + if (service) { p.set('service', service); } + return get(`/sitting?${p}`); + }, + me() { return get('/sitting/me'); }, + create(data) { return post('/sitting', data); }, + updateMe(data) { return patch('/sitting/me', data); }, + requests() { return get('/sitting/requests'); }, + inbox() { return get('/sitting/inbox'); }, + sendRequest(data){ return post('/sitting/requests', data); }, + updateRequest(id, status) { return patch(`/sitting/requests/${id}`, { status }); }, + }; + // ---------------------------------------------------------- // WETTER // ---------------------------------------------------------- @@ -284,7 +317,7 @@ const API = (() => { return { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, - places, routes, walks, weather, push, + places, routes, walks, events, sitting, weather, push, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js new file mode 100644 index 0000000..3fb6bd5 --- /dev/null +++ b/backend/static/js/pages/events.js @@ -0,0 +1,419 @@ +/* ============================================================ + BAN YARO — Events (Hundeveranstaltungen) + Liste/Karte · Filter · Erstellen/Bearbeiten + ============================================================ */ + +const Page_events = (() => { + + // ---------------------------------------------------------- + // Konstanten + // ---------------------------------------------------------- + const TYPEN = [ + { id: 'alle', label: 'Alle', icon: '🎪' }, + { id: 'ausstellung', label: 'Ausstellung', icon: '🏆' }, + { id: 'training', label: 'Training', icon: '🎓' }, + { id: 'treffen', label: 'Treffen', icon: '🐕' }, + { id: 'markt', label: 'Markt', icon: '🛍️' }, + { id: 'wettkampf', label: 'Wettkampf', icon: '🥇' }, + { id: 'sonstiges', label: 'Sonstiges', icon: '📌' }, + ]; + + const TYP_COLOR = { + ausstellung: '#8b5cf6', + training: '#3b82f6', + treffen: '#10b981', + markt: '#f59e0b', + wettkampf: '#ef4444', + sonstiges: '#6b7280', + }; + + // ---------------------------------------------------------- + // State + // ---------------------------------------------------------- + let _container = null; + let _state = null; + let _events = []; + let _filter = 'alle'; + let _view = 'liste'; // liste | karte + let _map = null; + let _markers = []; + + // ---------------------------------------------------------- + // init + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _state = appState; + _render(); + await _load(); + } + + async function refresh() { + await _load(); + } + + // ---------------------------------------------------------- + // Render Grundstruktur + // ---------------------------------------------------------- + function _render() { + _container.innerHTML = ` +
+ + + + + + `; + + _container.addEventListener('click', _onClick); + } + + // ---------------------------------------------------------- + // Daten laden + // ---------------------------------------------------------- + async function _load() { + const listEl = document.getElementById('ev-list'); + if (!listEl) return; + listEl.innerHTML = UI.skeleton(3); + try { + _events = await API.events.list(); + _renderList(); + } catch (e) { + UI.toast(e.message, 'error'); + } + } + + // ---------------------------------------------------------- + // Liste rendern + // ---------------------------------------------------------- + function _renderList() { + const listEl = document.getElementById('ev-list'); + if (!listEl) return; + + const filtered = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter); + if (!filtered.length) { + listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' }); + return; + } + + // Monats-Gruppierung + const groups = {}; + for (const ev of filtered) { + const d = new Date(ev.datum + 'T00:00:00'); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + const label = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + if (!groups[key]) groups[key] = { label, items: [] }; + groups[key].items.push(ev); + } + + let html = ''; + for (const key of Object.keys(groups).sort()) { + const g = groups[key]; + html += `Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.
+ +${UI.escHtml(s.beschreibung)}
` : ''} +${UI.escHtml(s.beschreibung)}
` : ''} +