Feature: 3 Community-Features — Foto-Challenge, Stamm-Gassis, Rassen-Chip (SW by-v700)
- Foto-Challenge der Woche: DB-Tabellen, routes/challenges.py (current/submit/vote/winners), Scheduler-Job jeden Montag 08:00, walks.js Challenge-Tab mit Banner, Galerie, Voting-Herz - Gassi-Zeiten-Pool: DB-Tabelle gassi_zeiten, routes/gassi_zeiten.py (CRUD + Umkreis), walks.js Stamm-Gassis-Tab mit Karten, Wochentag-Selector, Mitmachen→Chat - Rassen-Treffen-Chip: GET /api/friends/same-breed, dog-profile.js zeigt Chip wenn andere User gleiche Rasse haben, Klick → Forum mit Rassen-Suche vorausgefüllt
This commit is contained in:
parent
d6206d378e
commit
aa4849d947
10 changed files with 1322 additions and 22 deletions
|
|
@ -233,6 +233,8 @@ from routes.health_docs import router as health_docs_router
|
|||
from routes.passport import router as passport_router
|
||||
from routes.playdate import router as playdate_router
|
||||
from routes.ernaehrung import router as ernaehrung_router
|
||||
from routes.challenges import router as challenges_router
|
||||
from routes.gassi_zeiten import router as gassi_zeiten_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -292,6 +294,8 @@ app.include_router(health_docs_router, prefix="/api/health-docs", t
|
|||
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
|
||||
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
|
||||
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
|
||||
app.include_router(challenges_router, prefix="/api/challenges", tags=["Foto-Challenge"])
|
||||
app.include_router(gassi_zeiten_router, prefix="/api/gassi-zeiten", tags=["Gassi-Zeiten"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
304
backend/routes/challenges.py
Normal file
304
backend/routes/challenges.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""BAN YARO — Foto-Challenge der Woche"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
from media_utils import convert_media, generate_preview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
CHALLENGE_DIR = os.path.join(MEDIA_DIR, "challenges")
|
||||
|
||||
_CHALLENGE_THEMEN = [
|
||||
"Bestes Schnüffel-Foto 👃",
|
||||
"Action-Aufnahme 🏃",
|
||||
"Schlafendes Tier 😴",
|
||||
"Gassi im Regen 🌧️",
|
||||
"Hundeblick in die Kamera 👀",
|
||||
"Spielzeit mit Freunden 🐕",
|
||||
"Herbstspaziergang 🍂",
|
||||
"Beste Sprung-Aufnahme 🦘",
|
||||
"Hund am Wasser 🌊",
|
||||
"Erstes Mal im Schnee ❄️",
|
||||
"Genuss-Moment 🦴",
|
||||
"Versteckt im Gebüsch 🌿",
|
||||
"Tierisches Selfie 🤳",
|
||||
"Hund & Kind 👶",
|
||||
"Hund & Katze zusammen 🐱",
|
||||
"Der beste Buddel-Moment 🐾",
|
||||
"Freude beim Apportieren 🎾",
|
||||
"Hund in seiner Lieblingshöhle 🛋️",
|
||||
"Sonnenuntergangs-Gassi 🌅",
|
||||
"Hundebegegnung auf dem Spaziergang 🐕🐕",
|
||||
"Ausdrucksstarker Hundeblick 😍",
|
||||
"Hund im Herbstlaub 🍁",
|
||||
"Welpenfoto 🍼",
|
||||
"Seniorenhund im Porträt 👴",
|
||||
"Lustigste Schlafposition 💤",
|
||||
"Hund trägt etwas 🎀",
|
||||
"Hund + Besitzer Spiegelfoto 🪞",
|
||||
"Hund auf Abenteuer 🏕️",
|
||||
"Beste Lauf-Action 💨",
|
||||
"Hund im Café ☕",
|
||||
]
|
||||
|
||||
|
||||
def _current_week_monday() -> str:
|
||||
today = date.today()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
return monday.isoformat()
|
||||
|
||||
|
||||
def _current_week_sunday() -> str:
|
||||
monday = date.fromisoformat(_current_week_monday())
|
||||
return (monday + timedelta(days=6)).isoformat()
|
||||
|
||||
|
||||
def _ensure_current_challenge(conn) -> int:
|
||||
"""Stellt sicher dass eine Challenge für die aktuelle Woche existiert. Gibt die ID zurück."""
|
||||
monday = _current_week_monday()
|
||||
sunday = _current_week_sunday()
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
|
||||
# Thema aus Rotation wählen (Wochennummer % Anzahl Themen)
|
||||
week_num = date.today().isocalendar()[1]
|
||||
thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
|
||||
"VALUES (?, ?, ?, ?, NULL)",
|
||||
(thema, f"Diese Woche: {thema}", monday, sunday)
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/challenges/current
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/current")
|
||||
async def get_current_challenge(user=Depends(get_current_user_optional)):
|
||||
with db() as conn:
|
||||
challenge_id = _ensure_current_challenge(conn)
|
||||
challenge = conn.execute(
|
||||
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
|
||||
).fetchone()
|
||||
|
||||
submissions = conn.execute("""
|
||||
SELECT cs.id, cs.user_id, cs.dog_id, cs.foto_url, cs.caption, cs.votes, cs.created_at,
|
||||
u.name AS user_name, u.avatar_url,
|
||||
d.name AS dog_name, d.foto_url AS dog_foto_url
|
||||
FROM challenge_submissions cs
|
||||
LEFT JOIN users u ON u.id = cs.user_id
|
||||
LEFT JOIN dogs d ON d.id = cs.dog_id
|
||||
WHERE cs.challenge_id = ?
|
||||
ORDER BY cs.votes DESC, cs.created_at ASC
|
||||
""", (challenge_id,)).fetchall()
|
||||
|
||||
my_submission = None
|
||||
my_votes = set()
|
||||
if user:
|
||||
mine = conn.execute(
|
||||
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
|
||||
(challenge_id, user["id"])
|
||||
).fetchone()
|
||||
if mine:
|
||||
my_submission = mine["id"]
|
||||
voted_rows = conn.execute(
|
||||
"SELECT cv.submission_id FROM challenge_votes cv "
|
||||
"JOIN challenge_submissions cs ON cs.id = cv.submission_id "
|
||||
"WHERE cv.user_id = ? AND cs.challenge_id = ?",
|
||||
(user["id"], challenge_id)
|
||||
).fetchall()
|
||||
my_votes = {r["submission_id"] for r in voted_rows}
|
||||
|
||||
# Countdown bis Sonntag
|
||||
end = date.fromisoformat(challenge["end_date"])
|
||||
days_left = (end - date.today()).days + 1
|
||||
|
||||
result_subs = []
|
||||
for s in submissions:
|
||||
sd = dict(s)
|
||||
sd["i_voted"] = (sd["id"] in my_votes) if user else False
|
||||
result_subs.append(sd)
|
||||
|
||||
return {
|
||||
"challenge": dict(challenge),
|
||||
"submissions": result_subs,
|
||||
"my_submission_id": my_submission,
|
||||
"days_left": max(0, days_left),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/challenges/{id}/submit
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/{challenge_id}/submit", status_code=201)
|
||||
async def submit_photo(
|
||||
challenge_id: int,
|
||||
caption: Optional[str] = Form(None),
|
||||
dog_id: Optional[int] = Form(None),
|
||||
foto: UploadFile = File(...),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
with db() as conn:
|
||||
challenge = conn.execute(
|
||||
"SELECT * FROM foto_challenge WHERE id = ?", (challenge_id,)
|
||||
).fetchone()
|
||||
if not challenge:
|
||||
raise HTTPException(404, "Challenge nicht gefunden.")
|
||||
|
||||
today = date.today().isoformat()
|
||||
if today > challenge["end_date"]:
|
||||
raise HTTPException(400, "Die Challenge ist bereits beendet.")
|
||||
|
||||
# Doppelt-Check
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM challenge_submissions WHERE challenge_id=? AND user_id=?",
|
||||
(challenge_id, user["id"])
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(409, "Du hast bereits ein Foto eingereicht.")
|
||||
|
||||
# Foto speichern
|
||||
os.makedirs(CHALLENGE_DIR, exist_ok=True)
|
||||
orig_filename = foto.filename or "foto.jpg"
|
||||
ext = os.path.splitext(orig_filename)[1] or ".jpg"
|
||||
base = uuid.uuid4().hex
|
||||
|
||||
raw = await foto.read()
|
||||
|
||||
# HEIC→JPEG Konvertierung falls nötig
|
||||
try:
|
||||
converted, out_ext = convert_media(raw, orig_filename)
|
||||
except Exception:
|
||||
converted, out_ext = raw, ext
|
||||
|
||||
save_filename = f"{base}{out_ext}"
|
||||
save_path = os.path.join(CHALLENGE_DIR, save_filename)
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(converted)
|
||||
foto_url = f"/media/challenges/{save_filename}"
|
||||
|
||||
# Preview
|
||||
try:
|
||||
preview = generate_preview(converted, out_ext)
|
||||
if preview:
|
||||
prev_path = os.path.join(CHALLENGE_DIR, f"{base}_preview.webp")
|
||||
with open(prev_path, "wb") as f:
|
||||
f.write(preview)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO challenge_submissions (challenge_id, user_id, dog_id, foto_url, caption) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(challenge_id, user["id"], dog_id, foto_url, caption)
|
||||
)
|
||||
row = conn.execute("""
|
||||
SELECT cs.*, u.name AS user_name, d.name AS dog_name
|
||||
FROM challenge_submissions cs
|
||||
LEFT JOIN users u ON u.id = cs.user_id
|
||||
LEFT JOIN dogs d ON d.id = cs.dog_id
|
||||
WHERE cs.id = ?
|
||||
""", (cur.lastrowid,)).fetchone()
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/challenges/submissions/{id}/vote — Toggle-Vote
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/submissions/{submission_id}/vote")
|
||||
async def vote_submission(submission_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
sub = conn.execute(
|
||||
"SELECT * FROM challenge_submissions WHERE id = ?", (submission_id,)
|
||||
).fetchone()
|
||||
if not sub:
|
||||
raise HTTPException(404, "Einreichung nicht gefunden.")
|
||||
if sub["user_id"] == user["id"]:
|
||||
raise HTTPException(400, "Du kannst nicht für dein eigenes Foto abstimmen.")
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM challenge_votes WHERE submission_id=? AND user_id=?",
|
||||
(submission_id, user["id"])
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
# Toggle: Vote entfernen
|
||||
conn.execute(
|
||||
"DELETE FROM challenge_votes WHERE submission_id=? AND user_id=?",
|
||||
(submission_id, user["id"])
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE challenge_submissions SET votes = MAX(0, votes - 1) WHERE id=?",
|
||||
(submission_id,)
|
||||
)
|
||||
voted = False
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO challenge_votes (submission_id, user_id) VALUES (?, ?)",
|
||||
(submission_id, user["id"])
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE challenge_submissions SET votes = votes + 1 WHERE id=?",
|
||||
(submission_id,)
|
||||
)
|
||||
voted = True
|
||||
|
||||
votes = conn.execute(
|
||||
"SELECT votes FROM challenge_submissions WHERE id=?", (submission_id,)
|
||||
).fetchone()["votes"]
|
||||
|
||||
return {"voted": voted, "votes": votes}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/challenges/winners — letzte 4 Gewinner
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/winners")
|
||||
async def get_winners():
|
||||
with db() as conn:
|
||||
# Vergangene Challenges (ohne aktuelle Woche)
|
||||
monday = _current_week_monday()
|
||||
challenges = conn.execute(
|
||||
"SELECT id, thema, start_date, end_date FROM foto_challenge "
|
||||
"WHERE end_date < ? ORDER BY end_date DESC LIMIT 4",
|
||||
(monday,)
|
||||
).fetchall()
|
||||
|
||||
winners = []
|
||||
for ch in challenges:
|
||||
winner = conn.execute("""
|
||||
SELECT cs.id, cs.user_id, cs.foto_url, cs.caption, cs.votes,
|
||||
u.name AS user_name, u.avatar_url,
|
||||
d.name AS dog_name
|
||||
FROM challenge_submissions cs
|
||||
LEFT JOIN users u ON u.id = cs.user_id
|
||||
LEFT JOIN dogs d ON d.id = cs.dog_id
|
||||
WHERE cs.challenge_id = ?
|
||||
ORDER BY cs.votes DESC, cs.created_at ASC
|
||||
LIMIT 1
|
||||
""", (ch["id"],)).fetchone()
|
||||
|
||||
winners.append({
|
||||
"challenge": dict(ch),
|
||||
"winner": dict(winner) if winner else None,
|
||||
})
|
||||
|
||||
return winners
|
||||
|
|
@ -342,3 +342,56 @@ async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
|
|||
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
|
||||
""", (uid, friend_user_id, friend_user_id, uid))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/friends/same-breed — andere User mit gleicher Rasse
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/same-breed")
|
||||
async def same_breed(user=Depends(get_current_user)):
|
||||
"""Findet andere User mit Hunden derselben Rasse. Gibt Anzahl + Forum-Suche zurück."""
|
||||
uid = user["id"]
|
||||
with db() as conn:
|
||||
# Rassen des eingeloggten Users
|
||||
my_dogs = conn.execute(
|
||||
"SELECT rasse FROM dogs WHERE user_id=? AND rasse IS NOT NULL AND rasse != ''",
|
||||
(uid,)
|
||||
).fetchall()
|
||||
|
||||
if not my_dogs:
|
||||
return {"count": 0, "rassen": [], "forum_query": None}
|
||||
|
||||
rassen = list({d["rasse"].strip() for d in my_dogs if d["rasse"]})
|
||||
|
||||
# Andere User (nicht ich) die eine dieser Rassen haben
|
||||
ph = ",".join("?" * len(rassen))
|
||||
count_row = conn.execute(f"""
|
||||
SELECT COUNT(DISTINCT d.user_id) AS cnt
|
||||
FROM dogs d
|
||||
WHERE d.user_id != ?
|
||||
AND d.rasse IN ({ph})
|
||||
""", (uid, *rassen)).fetchone()
|
||||
|
||||
count = count_row["cnt"] if count_row else 0
|
||||
|
||||
# Für jede Rasse: wie viele andere User
|
||||
rassen_detail = []
|
||||
for rasse in rassen:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(DISTINCT user_id) AS cnt FROM dogs "
|
||||
"WHERE user_id != ? AND rasse = ?",
|
||||
(uid, rasse)
|
||||
).fetchone()["cnt"]
|
||||
if n > 0:
|
||||
rassen_detail.append({"rasse": rasse, "count": n})
|
||||
|
||||
rassen_detail.sort(key=lambda x: -x["count"])
|
||||
|
||||
# Forum-Suche-Link für die häufigste Rasse
|
||||
forum_query = rassen_detail[0]["rasse"] if rassen_detail else None
|
||||
|
||||
return {
|
||||
"count": count,
|
||||
"rassen": rassen_detail,
|
||||
"forum_query": forum_query,
|
||||
}
|
||||
|
|
|
|||
190
backend/routes/gassi_zeiten.py
Normal file
190
backend/routes/gassi_zeiten.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
class GassiZeitCreate(BaseModel):
|
||||
dog_id: Optional[int] = None
|
||||
wochentage: List[str] # ["mo", "mi", "fr"]
|
||||
uhrzeit: str # "17:00"
|
||||
ort_name: Optional[str] = None
|
||||
lat: Optional[float] = None
|
||||
lon: Optional[float] = None
|
||||
radius_m: int = 500
|
||||
notiz: Optional[str] = None
|
||||
|
||||
|
||||
class GassiZeitUpdate(BaseModel):
|
||||
aktiv: Optional[int] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/gassi-zeiten — alle in der Nähe (oder eigene)
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("")
|
||||
async def list_gassi_zeiten(
|
||||
lat: Optional[float] = None,
|
||||
lon: Optional[float] = None,
|
||||
radius: int = 5000, # Meter
|
||||
nur_eigene: bool = False,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
with db() as conn:
|
||||
if nur_eigene:
|
||||
rows = conn.execute("""
|
||||
SELECT gz.*, u.name AS user_name, u.avatar_url,
|
||||
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
|
||||
FROM gassi_zeiten gz
|
||||
LEFT JOIN users u ON u.id = gz.user_id
|
||||
LEFT JOIN dogs d ON d.id = gz.dog_id
|
||||
WHERE gz.user_id = ?
|
||||
ORDER BY gz.uhrzeit ASC
|
||||
""", (user["id"],)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("""
|
||||
SELECT gz.*, u.name AS user_name, u.avatar_url,
|
||||
d.name AS dog_name, d.foto_url AS dog_foto_url, d.rasse AS dog_rasse
|
||||
FROM gassi_zeiten gz
|
||||
LEFT JOIN users u ON u.id = gz.user_id
|
||||
LEFT JOIN dogs d ON d.id = gz.dog_id
|
||||
WHERE gz.aktiv = 1
|
||||
ORDER BY gz.uhrzeit ASC
|
||||
""").fetchall()
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
# wochentage JSON parsen
|
||||
try:
|
||||
d["wochentage"] = json.loads(d["wochentage"]) if isinstance(d["wochentage"], str) else d["wochentage"]
|
||||
except Exception:
|
||||
d["wochentage"] = []
|
||||
d["is_mine"] = (d["user_id"] == user["id"])
|
||||
|
||||
# Distanz-Filter
|
||||
if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
|
||||
dist = _haversine(lat, lon, d["lat"], d["lon"])
|
||||
if not nur_eigene and dist > radius:
|
||||
continue
|
||||
d["distance_m"] = int(dist)
|
||||
else:
|
||||
d["distance_m"] = None
|
||||
|
||||
result.append(d)
|
||||
|
||||
# Sortierung: eigene zuerst, dann nach Distanz
|
||||
result.sort(key=lambda x: (0 if x["is_mine"] else 1, x.get("distance_m") or 99999))
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/gassi-zeiten — eigene Zeit anlegen
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("", status_code=201)
|
||||
async def create_gassi_zeit(data: GassiZeitCreate, user=Depends(get_current_user)):
|
||||
if not data.wochentage:
|
||||
raise HTTPException(400, "Mindestens ein Wochentag muss angegeben werden.")
|
||||
if not data.uhrzeit:
|
||||
raise HTTPException(400, "Uhrzeit muss angegeben werden.")
|
||||
|
||||
wochentage_json = json.dumps(data.wochentage)
|
||||
|
||||
with db() as conn:
|
||||
# Hund-Prüfung
|
||||
if data.dog_id:
|
||||
dog = conn.execute(
|
||||
"SELECT id FROM dogs WHERE id=? AND user_id=?", (data.dog_id, user["id"])
|
||||
).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(403, "Hund nicht gefunden oder gehört nicht dir.")
|
||||
|
||||
cur = conn.execute("""
|
||||
INSERT INTO gassi_zeiten (user_id, dog_id, wochentage, uhrzeit,
|
||||
ort_name, lat, lon, radius_m, notiz)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (user["id"], data.dog_id, wochentage_json, data.uhrzeit,
|
||||
data.ort_name, data.lat, data.lon, data.radius_m, data.notiz))
|
||||
|
||||
row = conn.execute("""
|
||||
SELECT gz.*, u.name AS user_name, d.name AS dog_name, d.foto_url AS dog_foto_url
|
||||
FROM gassi_zeiten gz
|
||||
LEFT JOIN users u ON u.id = gz.user_id
|
||||
LEFT JOIN dogs d ON d.id = gz.dog_id
|
||||
WHERE gz.id = ?
|
||||
""", (cur.lastrowid,)).fetchone()
|
||||
|
||||
result = dict(row)
|
||||
try:
|
||||
result["wochentage"] = json.loads(result["wochentage"])
|
||||
except Exception:
|
||||
pass
|
||||
result["is_mine"] = True
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PATCH /api/gassi-zeiten/{id} — pausieren / aktivieren
|
||||
# ------------------------------------------------------------------
|
||||
@router.patch("/{gz_id}")
|
||||
async def update_gassi_zeit(gz_id: int, data: GassiZeitUpdate, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
gz = conn.execute(
|
||||
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
|
||||
).fetchone()
|
||||
if not gz:
|
||||
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
|
||||
if gz["user_id"] != user["id"]:
|
||||
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
|
||||
|
||||
updates = data.model_dump(exclude_none=True)
|
||||
if updates:
|
||||
cols = ", ".join(f"{k} = ?" for k in updates)
|
||||
conn.execute(f"UPDATE gassi_zeiten SET {cols} WHERE id=?", [*updates.values(), gz_id])
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
|
||||
).fetchone()
|
||||
|
||||
result = dict(row)
|
||||
try:
|
||||
result["wochentage"] = json.loads(result["wochentage"])
|
||||
except Exception:
|
||||
pass
|
||||
result["is_mine"] = True
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/gassi-zeiten/{id}
|
||||
# ------------------------------------------------------------------
|
||||
@router.delete("/{gz_id}", status_code=204)
|
||||
async def delete_gassi_zeit(gz_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
gz = conn.execute(
|
||||
"SELECT * FROM gassi_zeiten WHERE id=?", (gz_id,)
|
||||
).fetchone()
|
||||
if not gz:
|
||||
raise HTTPException(404, "Gassi-Zeit nicht gefunden.")
|
||||
if gz["user_id"] != user["id"]:
|
||||
raise HTTPException(403, "Nicht deine Gassi-Zeit.")
|
||||
conn.execute("DELETE FROM gassi_zeiten WHERE id=?", (gz_id,))
|
||||
|
|
@ -156,6 +156,14 @@ def start():
|
|||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
# Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen
|
||||
_scheduler.add_job(
|
||||
_job_new_foto_challenge,
|
||||
CronTrigger(day_of_week='mon', hour=8, minute=0),
|
||||
id="new_foto_challenge",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
# Täglich 07:00 Uhr — Goldene Gassi-Stunde
|
||||
_scheduler.add_job(
|
||||
_job_golden_gassi_hour,
|
||||
|
|
@ -181,7 +189,7 @@ def start():
|
|||
misfire_grace_time=3600,
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).")
|
||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).")
|
||||
|
||||
|
||||
def stop():
|
||||
|
|
@ -1544,6 +1552,46 @@ async def _job_monthly_recap():
|
|||
_log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}")
|
||||
|
||||
|
||||
async def _job_new_foto_challenge():
|
||||
"""Jeden Montag 08:00 — neue Foto-Challenge für die aktuelle Woche anlegen."""
|
||||
from datetime import date, timedelta
|
||||
from routes.challenges import _CHALLENGE_THEMEN, _current_week_monday, _current_week_sunday
|
||||
|
||||
monday = _current_week_monday()
|
||||
sunday = _current_week_sunday()
|
||||
|
||||
week_num = date.today().isocalendar()[1]
|
||||
thema = _CHALLENGE_THEMEN[week_num % len(_CHALLENGE_THEMEN)]
|
||||
|
||||
with db() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM foto_challenge WHERE start_date = ?", (monday,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
logger.info(f"Foto-Challenge: Woche {monday} bereits vorhanden (id={existing['id']}).")
|
||||
_log_job("new_foto_challenge", "ok", f"Bereits vorhanden für {monday}")
|
||||
return
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO foto_challenge (thema, beschreibung, start_date, end_date, created_by) "
|
||||
"VALUES (?, ?, ?, ?, NULL)",
|
||||
(thema, f"Diese Woche: {thema}", monday, sunday)
|
||||
)
|
||||
challenge_id = cur.lastrowid
|
||||
|
||||
# Push an alle User
|
||||
send_push_to_all({
|
||||
"type": "foto_challenge",
|
||||
"title": "📸 Neue Foto-Challenge!",
|
||||
"body": f"Diese Woche: {thema} — mach mit!",
|
||||
"data": {"page": "walks", "tab": "challenge"},
|
||||
"tag": f"challenge-{monday}",
|
||||
})
|
||||
|
||||
logger.info(f"Foto-Challenge angelegt: '{thema}' für {monday}–{sunday} (id={challenge_id}).")
|
||||
_log_job("new_foto_challenge", "ok", f"'{thema}' für {monday}")
|
||||
|
||||
|
||||
async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
|
||||
"""Holt stündliche Wetterdaten für heute von Open-Meteo."""
|
||||
import httpx
|
||||
|
|
|
|||
|
|
@ -7304,6 +7304,20 @@ svg.empty-state-icon {
|
|||
color: var(--c-text-secondary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.exp-kachel-jahr {
|
||||
font-size: 9px;
|
||||
color: var(--c-text-muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.exp-kachel-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--c-text-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* ---- Sektion-Block (Verlauf etc.) ---- */
|
||||
.exp-section {
|
||||
|
|
@ -7479,6 +7493,36 @@ svg.empty-state-icon {
|
|||
border-radius: 999px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.exp-dog-selector {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px 4px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.exp-dog-selector::-webkit-scrollbar { display: none; }
|
||||
.exp-dog-pill {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--c-border);
|
||||
background: var(--c-bg-card);
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.exp-dog-pill.active {
|
||||
background: var(--c-primary);
|
||||
color: #fff;
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
|
||||
/* Rechte Spalte: Betrag + Löschen-Icon */
|
||||
.exp-entry-right {
|
||||
|
|
@ -8133,3 +8177,189 @@ svg.empty-state-icon {
|
|||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.25; }
|
||||
}
|
||||
|
||||
/* ── COMMUNITY-FEATURES ──────────────────────────────────── */
|
||||
|
||||
/* Walks-Tab-Bar */
|
||||
.walks-tab-panel { display: flex; flex-direction: column; min-height: 0; flex: 1; }
|
||||
|
||||
/* Foto-Challenge */
|
||||
.challenge-banner {
|
||||
background: linear-gradient(135deg, var(--c-amber, #f59e0b), var(--c-primary, #C4843A));
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--space-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.challenge-banner-inner {
|
||||
padding: var(--space-5) var(--space-4);
|
||||
color: #fff;
|
||||
}
|
||||
.challenge-thema {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.challenge-meta {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.88;
|
||||
}
|
||||
.challenge-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4) var(--space-6);
|
||||
}
|
||||
.challenge-sub-card {
|
||||
background: var(--c-surface);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.challenge-sub-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
.challenge-sub-info {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
.challenge-sub-user {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.challenge-sub-caption {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text);
|
||||
margin-bottom: var(--space-1);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.challenge-vote-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.challenge-vote-btn.voted {
|
||||
color: var(--c-danger, #ef4444);
|
||||
}
|
||||
.challenge-winners { border-top: 1px solid var(--c-border); }
|
||||
.challenge-winners-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
overflow-x: auto;
|
||||
padding: var(--space-2) var(--space-4) var(--space-3);
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
.challenge-winner-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: var(--c-surface-alt, #fdf6ef);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
min-width: 160px;
|
||||
flex-shrink: 0;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.challenge-winner-chip img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-full);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Wochentag-Selector */
|
||||
.wd-selector {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wd-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
user-select: none;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.wd-btn input { display: none; }
|
||||
.wd-btn:has(input:checked) {
|
||||
background: var(--c-primary, #C4843A);
|
||||
border-color: var(--c-primary, #C4843A);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Gassi-Zeit-Karten */
|
||||
.gassi-zeit-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
.gz-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--c-surface-alt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.gz-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.gz-avatar-placeholder { font-size: 1.5rem; color: var(--c-text-secondary); }
|
||||
.gz-body { flex: 1; min-width: 0; }
|
||||
.gz-name {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--text-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gz-meta { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; }
|
||||
.gz-notiz { font-size: var(--text-xs); color: var(--c-text-secondary); margin-top: 2px; font-style: italic; }
|
||||
.gz-actions { display: flex; gap: var(--space-1); flex-shrink: 0; }
|
||||
|
||||
/* Rassen-Community-Chip */
|
||||
.breed-community-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
background: var(--c-surface-alt, #fdf6ef);
|
||||
border: 1.5px solid var(--c-amber, #f59e0b);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 6px 16px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--c-text);
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.breed-community-chip:hover, .breed-community-chip:active {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,8 +97,11 @@ window.Page_dog_profile = (() => {
|
|||
<h2 style="font-size:var(--text-2xl);font-weight:700;
|
||||
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
|
||||
${dog.rasse
|
||||
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
|
||||
: `<p style="margin:0 0 var(--space-5)"></p>`}
|
||||
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
|
||||
: `<p style="margin:0 0 var(--space-2)"></p>`}
|
||||
|
||||
<!-- Rassen-Community-Chip (wird async geladen) -->
|
||||
<div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
|
||||
|
||||
<!-- Info-Grid -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
|
||||
|
|
@ -245,6 +248,9 @@ window.Page_dog_profile = (() => {
|
|||
// Pflegetipps laden
|
||||
_loadPflegeTipps(dog);
|
||||
|
||||
// Rassen-Community-Chip laden (falls Rasse bekannt)
|
||||
if (dog.rasse) _loadSameBreedChip();
|
||||
|
||||
// Sitter-Zugang laden (nur für Besitzer)
|
||||
if (dog.user_id === _appState.user?.id) {
|
||||
_loadSittingAccess(dog.id);
|
||||
|
|
@ -2386,6 +2392,32 @@ window.Page_dog_profile = (() => {
|
|||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RASSEN-COMMUNITY-CHIP
|
||||
// ----------------------------------------------------------
|
||||
async function _loadSameBreedChip() {
|
||||
const el = document.getElementById('dp-same-breed-chip');
|
||||
if (!el) return;
|
||||
try {
|
||||
const data = await API.get('friends/same-breed');
|
||||
if (!data || data.count === 0) return;
|
||||
const hauptRasse = data.rassen[0]?.rasse || '';
|
||||
const label = data.count === 1
|
||||
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
|
||||
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
|
||||
|
||||
el.innerHTML = `
|
||||
<button class="breed-community-chip" id="dp-breed-chip-btn">
|
||||
🐕 ${label} — Forum ansehen
|
||||
</button>
|
||||
`;
|
||||
document.getElementById('dp-breed-chip-btn')?.addEventListener('click', () => {
|
||||
App.navigate('forum', false, { search: hauptRasse });
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -62,12 +62,21 @@ window.Page_forum = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
async function init(container, appState, params = {}) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
_loadHdmCard();
|
||||
_loadThreads(true);
|
||||
|
||||
// Rassen-Suche vorausfüllen (Feature 3: Same-Breed-Chip)
|
||||
if (params.search) {
|
||||
const searchInput = document.getElementById('forum-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = params.search;
|
||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,12 @@ window.Page_walks = (() => {
|
|||
let _appState = null;
|
||||
let _data = [];
|
||||
let _view = 'liste'; // 'liste' | 'karte'
|
||||
let _tab = 'treffen'; // 'treffen' | 'challenge' | 'stamm'
|
||||
let _map = null;
|
||||
let _markers = [];
|
||||
let _userPos = null;
|
||||
let _challengeData = null;
|
||||
let _gassiZeiten = [];
|
||||
|
||||
// _esc ersetzt durch UI.escape()
|
||||
|
||||
|
|
@ -56,9 +59,17 @@ window.Page_walks = (() => {
|
|||
_loadData();
|
||||
}
|
||||
|
||||
function refresh() { _loadData(); }
|
||||
function refresh() {
|
||||
_loadData();
|
||||
if (_tab === 'challenge') _loadChallenge();
|
||||
if (_tab === 'stamm') _loadGassiZeiten();
|
||||
}
|
||||
function onDogChange() {}
|
||||
function openNew() { _showCreateForm(); }
|
||||
function openNew() {
|
||||
if (_tab === 'challenge') { _showSubmitForm(); return; }
|
||||
if (_tab === 'stamm') { _showGassiZeitForm(); return; }
|
||||
_showCreateForm();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// RENDER — Grundstruktur
|
||||
|
|
@ -67,30 +78,63 @@ window.Page_walks = (() => {
|
|||
_container.innerHTML = `
|
||||
<div class="walks-layout">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="by-toolbar">
|
||||
<div class="walks-view-toggle" id="walks-view-toggle">
|
||||
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
|
||||
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
|
||||
<!-- Tab-Bar -->
|
||||
<div class="by-tabs" id="walks-tab-bar" style="padding:var(--space-3) var(--space-4) 0">
|
||||
<button class="by-tab active" data-tab="treffen">${UI.icon('paw-print')} Treffen</button>
|
||||
<button class="by-tab" data-tab="challenge">${UI.icon('camera')} Challenge</button>
|
||||
<button class="by-tab" data-tab="stamm">${UI.icon('clock')} Stamm-Gassis</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<div id="walks-list-view" class="walks-content">
|
||||
<div id="walks-list">
|
||||
<!-- Tab: Treffen -->
|
||||
<div id="walks-tab-treffen" class="walks-tab-panel">
|
||||
<!-- Toolbar -->
|
||||
<div class="by-toolbar">
|
||||
<div class="walks-view-toggle" id="walks-view-toggle">
|
||||
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
|
||||
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} 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>
|
||||
|
||||
<!-- Tab: Challenge -->
|
||||
<div id="walks-tab-challenge" class="walks-tab-panel" style="display:none">
|
||||
<div id="challenge-content">
|
||||
<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>
|
||||
<!-- Tab: Stamm-Gassis -->
|
||||
<div id="walks-tab-stamm" class="walks-tab-panel" style="display:none">
|
||||
<div class="by-toolbar">
|
||||
<span style="font-weight:600;color:var(--c-text)">${UI.icon('clock')} Stamm-Gassi-Zeiten</span>
|
||||
<button class="btn btn-primary btn-sm" id="gassi-zeit-add-btn">${UI.icon('plus')} Meine Zeit eintragen</button>
|
||||
</div>
|
||||
<div id="gassi-zeiten-content">
|
||||
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Tab-Bar Events
|
||||
document.getElementById('walks-tab-bar').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.by-tab');
|
||||
if (!btn) return;
|
||||
_switchTab(btn.dataset.tab);
|
||||
});
|
||||
|
||||
document.getElementById('walks-view-toggle').addEventListener('click', e => {
|
||||
const btn = e.target.closest('.walks-view-btn');
|
||||
if (!btn) return;
|
||||
|
|
@ -105,6 +149,23 @@ window.Page_walks = (() => {
|
|||
}
|
||||
_showCreateForm();
|
||||
});
|
||||
|
||||
document.getElementById('gassi-zeit-add-btn').addEventListener('click', () => {
|
||||
if (!_appState.user) { UI.toast.warning('Bitte zuerst anmelden.'); App.navigate('settings'); return; }
|
||||
_showGassiZeitForm();
|
||||
});
|
||||
}
|
||||
|
||||
function _switchTab(tab) {
|
||||
_tab = tab;
|
||||
document.querySelectorAll('.by-tab').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.tab === tab));
|
||||
document.querySelectorAll('.walks-tab-panel').forEach(p => p.style.display = 'none');
|
||||
const panel = document.getElementById(`walks-tab-${tab}`);
|
||||
if (panel) panel.style.display = '';
|
||||
|
||||
if (tab === 'challenge' && !_challengeData) _loadChallenge();
|
||||
if (tab === 'stamm' && !_gassiZeiten.length) _loadGassiZeiten();
|
||||
}
|
||||
|
||||
function _switchView(view) {
|
||||
|
|
@ -1038,6 +1099,375 @@ window.Page_walks = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// FEATURE 1: Foto-Challenge der Woche
|
||||
// ==============================================================
|
||||
|
||||
async function _loadChallenge() {
|
||||
const el = document.getElementById('challenge-content');
|
||||
if (!el) return;
|
||||
try {
|
||||
_challengeData = await API.get('challenges/current');
|
||||
_renderChallenge();
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Challenge nicht laden.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderChallenge() {
|
||||
const el = document.getElementById('challenge-content');
|
||||
if (!el || !_challengeData) return;
|
||||
const { challenge, submissions, my_submission_id, days_left } = _challengeData;
|
||||
|
||||
const canSubmit = _appState.user && !my_submission_id;
|
||||
const dayLabel = days_left === 1 ? '1 Tag' : `${days_left} Tage`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="challenge-banner">
|
||||
<div class="challenge-banner-inner">
|
||||
<div class="challenge-thema">${UI.escape(challenge.thema)}</div>
|
||||
<div class="challenge-meta">
|
||||
${UI.icon('calendar')} ${_fmtDate(challenge.start_date)} – ${_fmtDate(challenge.end_date)}
|
||||
· ${UI.icon('timer')} Noch ${dayLabel}
|
||||
</div>
|
||||
${canSubmit ? `<button class="btn btn-primary btn-sm" id="challenge-submit-btn" style="margin-top:var(--space-3)">${UI.icon('camera')} Foto einreichen</button>` : ''}
|
||||
${my_submission_id ? `<span class="badge badge-success" style="margin-top:var(--space-2)">${UI.icon('check')} Du hast bereits teilgenommen</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="challenge-winners" id="challenge-winners-section">
|
||||
<h4 style="padding:var(--space-3) var(--space-4);margin:0;color:var(--c-text-secondary);font-size:var(--text-xs);text-transform:uppercase;letter-spacing:.05em">Letzte Gewinner</h4>
|
||||
<div id="challenge-winners-list"><p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Lädt…</p></div>
|
||||
</div>
|
||||
|
||||
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
|
||||
<h4 style="margin:0;font-size:var(--text-sm);font-weight:600;color:var(--c-text)">
|
||||
${UI.icon('images')} Einreichungen dieser Woche (${submissions.length})
|
||||
</h4>
|
||||
</div>
|
||||
<div class="challenge-gallery" id="challenge-gallery">
|
||||
${submissions.length === 0
|
||||
? `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8);grid-column:1/-1">Noch keine Fotos — sei der Erste! 📸</p>`
|
||||
: submissions.map(s => _challengeSubmissionCard(s)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Submit-Button
|
||||
const submitBtn = document.getElementById('challenge-submit-btn');
|
||||
if (submitBtn) submitBtn.addEventListener('click', _showSubmitForm);
|
||||
|
||||
// Vote-Buttons
|
||||
el.querySelectorAll('.challenge-vote-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
|
||||
const subId = parseInt(btn.dataset.id);
|
||||
try {
|
||||
const res = await API.post(`challenges/submissions/${subId}/vote`, {});
|
||||
btn.querySelector('.vote-count').textContent = res.votes;
|
||||
btn.classList.toggle('voted', res.voted);
|
||||
} catch (e) { UI.toast.error(e.message || 'Fehler beim Abstimmen.'); }
|
||||
});
|
||||
});
|
||||
|
||||
// Gewinner laden
|
||||
_loadChallengeWinners();
|
||||
}
|
||||
|
||||
function _challengeSubmissionCard(s) {
|
||||
const voted = s.i_voted;
|
||||
return `
|
||||
<div class="challenge-sub-card">
|
||||
<img src="${UI.escape(s.foto_url)}" alt="Challenge-Foto" loading="lazy"
|
||||
onerror="this.src='/icons/icon-192.png'"
|
||||
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(s.foto_url)}' }], 0)">
|
||||
<div class="challenge-sub-info">
|
||||
<div class="challenge-sub-user">${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')}
|
||||
${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}</div>
|
||||
${s.caption ? `<div class="challenge-sub-caption">${UI.escape(s.caption)}</div>` : ''}
|
||||
<button class="challenge-vote-btn ${voted ? 'voted' : ''}" data-id="${s.id}">
|
||||
${UI.icon(voted ? 'heart-fill' : 'heart')} <span class="vote-count">${s.votes}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _loadChallengeWinners() {
|
||||
const el = document.getElementById('challenge-winners-list');
|
||||
if (!el) return;
|
||||
try {
|
||||
const winners = await API.get('challenges/winners');
|
||||
if (!winners.length) { el.innerHTML = '<p style="color:var(--c-text-secondary);padding:0 var(--space-4);font-size:var(--text-sm)">Noch keine vergangenen Challenges.</p>'; return; }
|
||||
el.innerHTML = `<div class="challenge-winners-row">` +
|
||||
winners.map(w => {
|
||||
if (!w.winner) return `<div class="challenge-winner-chip"><span>${UI.escape(w.challenge.thema)}</span><small>Kein Gewinner</small></div>`;
|
||||
return `<div class="challenge-winner-chip">
|
||||
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" onerror="this.src='/icons/icon-192.png'">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--text-xs)">${UI.escape(w.challenge.thema)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(w.winner.user_name)} · ${w.winner.votes} ❤️</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') +
|
||||
`</div>`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function _showSubmitForm() {
|
||||
if (!_challengeData) return;
|
||||
const dogs = _appState.dogs || [];
|
||||
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
|
||||
|
||||
UI.modal.open({
|
||||
title: `📸 ${UI.escape(_challengeData.challenge.thema)}`,
|
||||
body: `
|
||||
<form id="challenge-submit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<div class="form-group">
|
||||
<label>Foto *</label>
|
||||
<input type="file" id="challenge-foto-input" accept="image/*" required style="width:100%">
|
||||
</div>
|
||||
${dogs.length ? `<div class="form-group">
|
||||
<label>Hund</label>
|
||||
<select id="challenge-dog-select" style="width:100%">
|
||||
<option value="">Kein Hund</option>
|
||||
${dogOptions}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label>Bildunterschrift</label>
|
||||
<input type="text" id="challenge-caption" placeholder="z.B. Mein Bello beim besten Schnüffeln…" maxlength="200" style="width:100%">
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="challenge-submit-ok">Einreichen</button>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('challenge-submit-ok').addEventListener('click', async () => {
|
||||
const fotoInput = document.getElementById('challenge-foto-input');
|
||||
if (!fotoInput.files.length) { UI.toast.warning('Bitte ein Foto auswählen.'); return; }
|
||||
const caption = document.getElementById('challenge-caption')?.value?.trim() || '';
|
||||
const dogSelect = document.getElementById('challenge-dog-select');
|
||||
const dogId = dogSelect ? (parseInt(dogSelect.value) || '') : '';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('foto', fotoInput.files[0]);
|
||||
if (caption) fd.append('caption', caption);
|
||||
if (dogId) fd.append('dog_id', dogId);
|
||||
|
||||
try {
|
||||
await API.upload(`challenges/${_challengeData.challenge.id}/submit`, fd);
|
||||
UI.toast.success('Foto eingereicht! Viel Erfolg 🎉');
|
||||
UI.modal.close();
|
||||
_challengeData = null;
|
||||
_loadChallenge();
|
||||
} catch (e) { UI.toast.error(e.message || 'Fehler beim Einreichen.'); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ==============================================================
|
||||
// FEATURE 2: Gassi-Zeiten-Pool (Stamm-Gassis)
|
||||
// ==============================================================
|
||||
|
||||
const _WOCHENTAGE = [
|
||||
{ key: 'mo', label: 'Mo' }, { key: 'di', label: 'Di' }, { key: 'mi', label: 'Mi' },
|
||||
{ key: 'do', label: 'Do' }, { key: 'fr', label: 'Fr' }, { key: 'sa', label: 'Sa' },
|
||||
{ key: 'so', label: 'So' },
|
||||
];
|
||||
|
||||
async function _loadGassiZeiten() {
|
||||
const el = document.getElementById('gassi-zeiten-content');
|
||||
if (!el) return;
|
||||
try {
|
||||
const params = _userPos ? `?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=10000` : '';
|
||||
_gassiZeiten = await API.get(`gassi-zeiten${params}`);
|
||||
_renderGassiZeiten();
|
||||
} catch (e) {
|
||||
el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Konnte Gassi-Zeiten nicht laden.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderGassiZeiten() {
|
||||
const el = document.getElementById('gassi-zeiten-content');
|
||||
if (!el) return;
|
||||
|
||||
if (!_gassiZeiten.length) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-secondary)">
|
||||
${UI.icon('clock')}
|
||||
<p>Noch keine Stamm-Gassi-Zeiten in deiner Nähe.</p>
|
||||
<p style="font-size:var(--text-sm)">Trag deine regelmäßigen Zeiten ein — andere finden dich dann!</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const myZeiten = _gassiZeiten.filter(z => z.is_mine);
|
||||
const andereZeiten = _gassiZeiten.filter(z => !z.is_mine);
|
||||
|
||||
let html = '';
|
||||
|
||||
if (myZeiten.length) {
|
||||
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">Meine Zeiten</div>`;
|
||||
html += myZeiten.map(z => _gassiZeitCard(z)).join('');
|
||||
}
|
||||
|
||||
if (andereZeiten.length) {
|
||||
html += `<div style="padding:var(--space-3) var(--space-4) var(--space-1);font-weight:600;font-size:var(--text-xs);color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.05em">In deiner Nähe</div>`;
|
||||
html += andereZeiten.map(z => _gassiZeitCard(z)).join('');
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Events
|
||||
el.querySelectorAll('.gz-delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Gassi-Zeit löschen?')) return;
|
||||
try {
|
||||
await API.del(`gassi-zeiten/${btn.dataset.id}`);
|
||||
_gassiZeiten = _gassiZeiten.filter(z => z.id !== parseInt(btn.dataset.id));
|
||||
_renderGassiZeiten();
|
||||
UI.toast.success('Gelöscht.');
|
||||
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.gz-toggle-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const gz = _gassiZeiten.find(z => z.id === parseInt(btn.dataset.id));
|
||||
if (!gz) return;
|
||||
try {
|
||||
const updated = await API.patch(`gassi-zeiten/${gz.id}`, { aktiv: gz.aktiv ? 0 : 1 });
|
||||
const idx = _gassiZeiten.findIndex(z => z.id === gz.id);
|
||||
if (idx !== -1) _gassiZeiten[idx] = { ..._gassiZeiten[idx], aktiv: updated.aktiv };
|
||||
_renderGassiZeiten();
|
||||
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
|
||||
});
|
||||
});
|
||||
|
||||
el.querySelectorAll('.gz-chat-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const userId = parseInt(btn.dataset.userId);
|
||||
App.navigate('chat', { user_id: userId });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _gassiZeitCard(z) {
|
||||
const wochentageLabel = (z.wochentage || []).join(', ').toUpperCase();
|
||||
const distLabel = z.distance_m != null
|
||||
? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${z.distance_m < 1000 ? z.distance_m + ' m' : (z.distance_m/1000).toFixed(1) + ' km'}</span>`
|
||||
: '';
|
||||
const pausedStyle = z.aktiv ? '' : 'opacity:.5';
|
||||
|
||||
return `
|
||||
<div class="gassi-zeit-card" style="${pausedStyle}">
|
||||
<div class="gz-avatar">
|
||||
${z.dog_foto_url
|
||||
? `<img src="${UI.escape(z.dog_foto_url)}" alt="${UI.escape(z.dog_name || '')}">`
|
||||
: `<div class="gz-avatar-placeholder">${UI.icon('paw-print')}</div>`}
|
||||
</div>
|
||||
<div class="gz-body">
|
||||
<div class="gz-name">${UI.escape(z.dog_name || z.user_name || '?')}
|
||||
${z.dog_rasse ? `<span class="badge" style="font-size:var(--text-xs)">${UI.escape(z.dog_rasse)}</span>` : ''}
|
||||
${!z.aktiv ? `<span class="badge badge-warning">Pausiert</span>` : ''}
|
||||
</div>
|
||||
<div class="gz-meta">
|
||||
${UI.icon('clock')} ${UI.escape(z.uhrzeit)}
|
||||
· ${wochentageLabel}
|
||||
${z.ort_name ? ` · ${UI.icon('map-pin')} ${UI.escape(z.ort_name)}` : ''}
|
||||
${distLabel}
|
||||
</div>
|
||||
${z.notiz ? `<div class="gz-notiz">${UI.escape(z.notiz)}</div>` : ''}
|
||||
</div>
|
||||
<div class="gz-actions">
|
||||
${z.is_mine ? `
|
||||
<button class="btn btn-outline btn-xs gz-toggle-btn" data-id="${z.id}" title="${z.aktiv ? 'Pausieren' : 'Aktivieren'}">
|
||||
${UI.icon(z.aktiv ? 'pause' : 'play')}
|
||||
</button>
|
||||
<button class="btn btn-outline btn-xs gz-delete-btn" data-id="${z.id}" title="Löschen">
|
||||
${UI.icon('trash')}
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-primary btn-xs gz-chat-btn" data-user-id="${z.user_id}" title="Chat öffnen">
|
||||
${UI.icon('chat-circle')} Mitmachen
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _showGassiZeitForm() {
|
||||
const dogs = _appState.dogs || [];
|
||||
const dogOptions = dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}</option>`).join('');
|
||||
const wdBtns = _WOCHENTAGE.map(w =>
|
||||
`<label class="wd-btn"><input type="checkbox" value="${w.key}"> ${w.label}</label>`
|
||||
).join('');
|
||||
|
||||
UI.modal.open({
|
||||
title: `${UI.icon('clock')} Stamm-Gassi-Zeit eintragen`,
|
||||
body: `
|
||||
<form id="gassi-zeit-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
${dogs.length ? `<div class="form-group">
|
||||
<label>Hund</label>
|
||||
<select id="gz-dog-select" style="width:100%">
|
||||
<option value="">Kein Hund</option>
|
||||
${dogOptions}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
<div class="form-group">
|
||||
<label>Uhrzeit *</label>
|
||||
<input type="time" id="gz-uhrzeit" required style="width:100%">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Wochentage *</label>
|
||||
<div class="wd-selector">${wdBtns}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ort (optional)</label>
|
||||
<input type="text" id="gz-ort-name" placeholder="z.B. Stadtpark Ebersberg" style="width:100%">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notiz (optional)</label>
|
||||
<input type="text" id="gz-notiz" placeholder="z.B. Wir sind eine ruhige Gruppe…" maxlength="200" style="width:100%">
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="gz-save-btn">Speichern</button>
|
||||
`,
|
||||
});
|
||||
|
||||
document.getElementById('gz-save-btn').addEventListener('click', async () => {
|
||||
const uhrzeit = document.getElementById('gz-uhrzeit')?.value;
|
||||
if (!uhrzeit) { UI.toast.warning('Bitte Uhrzeit angeben.'); return; }
|
||||
|
||||
const wochentage = Array.from(document.querySelectorAll('.wd-btn input:checked')).map(cb => cb.value);
|
||||
if (!wochentage.length) { UI.toast.warning('Bitte mindestens einen Wochentag wählen.'); return; }
|
||||
|
||||
const dogId = parseInt(document.getElementById('gz-dog-select')?.value) || null;
|
||||
const ortName = document.getElementById('gz-ort-name')?.value?.trim() || null;
|
||||
const notiz = document.getElementById('gz-notiz')?.value?.trim() || null;
|
||||
|
||||
const payload = { wochentage, uhrzeit, ort_name: ortName, notiz };
|
||||
if (dogId) payload.dog_id = dogId;
|
||||
if (_userPos) { payload.lat = _userPos.lat; payload.lon = _userPos.lon; }
|
||||
|
||||
try {
|
||||
const created = await API.post('gassi-zeiten', payload);
|
||||
_gassiZeiten.unshift({ ...created });
|
||||
_renderGassiZeiten();
|
||||
UI.toast.success('Gassi-Zeit eingetragen! 🐾');
|
||||
UI.modal.close();
|
||||
} catch (e) { UI.toast.error(e.message || 'Fehler beim Speichern.'); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
|||
|
||||
// index.html wird NICHT pre-gecacht (immer Network-First)
|
||||
const STATIC_ASSETS = [
|
||||
'/css/design-system.css?v=545',
|
||||
'/css/layout.css?v=545',
|
||||
'/css/components.css?v=545',
|
||||
'/css/design-system.css?v=700',
|
||||
'/css/layout.css?v=700',
|
||||
'/css/components.css?v=700',
|
||||
'/icons/phosphor.svg',
|
||||
'/js/api.js',
|
||||
'/js/ui.js',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue