From c1bb72815395bdbaa3b75324c0e9f52e1d0ec4be Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 08:07:41 +0200 Subject: [PATCH 01/63] =?UTF-8?q?Reports=202026-05-01=20=E2=80=94=20Quarta?= =?UTF-8?q?lsbericht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reports/.gitkeep | 0 reports/2026-05-01-dateien.md | 180 ++++++++++++++++++++++++++ reports/2026-05-01-funktionsumfang.md | 151 +++++++++++++++++++++ reports/2026-05-01-nutzer.md | 91 +++++++++++++ reports/2026-05-01-partner.md | 24 ++++ reports/2026-05-01-server.md | 172 ++++++++++++++++++++++++ reports/2026-05-01-sicherheit.md | 128 ++++++++++++++++++ 7 files changed, 746 insertions(+) create mode 100644 reports/.gitkeep create mode 100644 reports/2026-05-01-dateien.md create mode 100644 reports/2026-05-01-funktionsumfang.md create mode 100644 reports/2026-05-01-nutzer.md create mode 100644 reports/2026-05-01-partner.md create mode 100644 reports/2026-05-01-server.md create mode 100644 reports/2026-05-01-sicherheit.md diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/2026-05-01-dateien.md b/reports/2026-05-01-dateien.md new file mode 100644 index 0000000..6ceb3c8 --- /dev/null +++ b/reports/2026-05-01-dateien.md @@ -0,0 +1,180 @@ +# Dateiliste — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Backend — Python-Dateien + +| Datei | Größe | +| ---------------------------- | -------- | +| ._auth.py | 163.0 B | +| ._database.py | 163.0 B | +| ._ki.py | 163.0 B | +| ._main.py | 163.0 B | +| auth.py | 4.5 KB | +| content_filter.py | 2.3 KB | +| database.py | 76.6 KB | +| generate_thumbs.py | 1.0 KB | +| ki.py | 15.7 KB | +| mailer.py | 5.9 KB | +| main.py | 76.9 KB | +| media_utils.py | 7.7 KB | +| migrate_media.py | 3.3 KB | +| ratelimit.py | 4.5 KB | +| routes/.___init__.py | 163.0 B | +| routes/._auth.py | 163.0 B | +| routes/._diary.py | 163.0 B | +| routes/._dogs.py | 163.0 B | +| routes/._health.py | 163.0 B | +| routes/._ki.py | 163.0 B | +| routes/._poison.py | 163.0 B | +| routes/._push.py | 163.0 B | +| routes/__init__.py | 0.0 B | +| routes/achievements.py | 10.9 KB | +| routes/admin.py | 41.0 KB | +| routes/alerts.py | 1.5 KB | +| routes/auth.py | 13.5 KB | +| routes/breeder.py | 16.2 KB | +| routes/breeder_export.py | 22.0 KB | +| routes/breeder_photos.py | 13.4 KB | +| routes/chat.py | 10.4 KB | +| routes/diary.py | 35.8 KB | +| routes/dogs.py | 22.2 KB | +| routes/events.py | 8.9 KB | +| routes/forum.py | 27.1 KB | +| routes/friends.py | 11.8 KB | +| routes/health.py | 21.1 KB | +| routes/import_data.py | 10.0 KB | +| routes/ki.py | 2.2 KB | +| routes/knigge.py | 3.9 KB | +| routes/litters.py | 25.0 KB | +| routes/lost.py | 6.3 KB | +| routes/moderation.py | 10.0 KB | +| routes/movies.py | 10.2 KB | +| routes/notes.py | 9.5 KB | +| routes/notifications.py | 4.2 KB | +| routes/osm.py | 16.8 KB | +| routes/outreach.py | 8.9 KB | +| routes/partner.py | 7.3 KB | +| routes/places.py | 6.4 KB | +| routes/poison.py | 7.0 KB | +| routes/praise.py | 1.2 KB | +| routes/profile.py | 3.7 KB | +| routes/push.py | 5.9 KB | +| routes/ratings.py | 4.8 KB | +| routes/routen.py | 22.2 KB | +| routes/services.py | 5.1 KB | +| routes/sharing.py | 5.2 KB | +| routes/sitting.py | 10.0 KB | +| routes/sitting_access.py | 2.8 KB | +| routes/social.py | 117.2 KB | +| routes/stats.py | 1.5 KB | +| routes/tieraerzte.py | 6.1 KB | +| routes/training.py | 33.8 KB | +| routes/walks.py | 20.5 KB | +| routes/weather.py | 537.0 B | +| routes/webcal.py | 14.9 KB | +| routes/widget.py | 1.8 KB | +| routes/wiki.py | 26.6 KB | +| routes/zucht_hunde.py | 31.2 KB | +| routes/zucht_ki.py | 18.8 KB | +| scheduler.py | 32.8 KB | +| scraper/__init__.py | 0.0 B | +| scraper/breed_enricher.py | 21.5 KB | +| scraper/breed_evaluator.py | 4.9 KB | +| scraper/breeds.py | 5.9 KB | +| scraper/events_vdh.py | 10.6 KB | +| scraper/fetch_wiki_images.py | 9.0 KB | +| scraper/wikidata_breeds.py | 7.8 KB | +| scraper/wikipedia_photos.py | 6.7 KB | +| scripts/generate_reports.py | 29.4 KB | +| timeutils.py | 3.3 KB | +| username_blocklist.py | 1.2 KB | +| weather.py | 5.9 KB | +| welfare_check.py | 10.0 KB | + +**Gesamt**: 85 Dateien, 1.0 MB + + +## Frontend — JavaScript + +| Datei | Größe | +| ------------------------ | -------- | +| ._api.js | 163.0 B | +| ._app.js | 163.0 B | +| ._ui.js | 163.0 B | +| api.js | 31.2 KB | +| app.js | 38.2 KB | +| leaflet.js | 143.7 KB | +| leaflet.markercluster.js | 33.3 KB | +| pages/admin.js | 119.1 KB | +| pages/breeder.js | 8.3 KB | +| pages/chat.js | 19.0 KB | +| pages/datenschutz.js | 11.2 KB | +| pages/diary.js | 92.7 KB | +| pages/dog-profile.js | 51.5 KB | +| pages/erste-hilfe.js | 31.7 KB | +| pages/events.js | 29.8 KB | +| pages/forum.js | 52.8 KB | +| pages/friends.js | 38.6 KB | +| pages/gruender.js | 7.1 KB | +| pages/health.js | 107.5 KB | +| pages/impressum.js | 3.9 KB | +| pages/knigge.js | 16.9 KB | +| pages/litters.js | 51.6 KB | +| pages/lost.js | 30.3 KB | +| pages/map.js | 70.7 KB | +| pages/moderation.js | 23.0 KB | +| pages/movies.js | 18.6 KB | +| pages/notes.js | 38.1 KB | +| pages/notifications.js | 12.0 KB | +| pages/onboarding.js | 17.2 KB | +| pages/places.js | 19.7 KB | +| pages/poison.js | 26.9 KB | +| pages/routes.js | 132.6 KB | +| pages/settings.js | 84.2 KB | +| pages/sitting.js | 33.9 KB | +| pages/social.js | 74.3 KB | +| pages/trainingsplaene.js | 40.0 KB | +| pages/uebungen.js | 98.8 KB | +| pages/walks.js | 42.4 KB | +| pages/welcome.js | 51.1 KB | +| pages/widget.js | 5.6 KB | +| pages/wiki.js | 55.9 KB | +| pages/wurfboerse.js | 9.7 KB | +| pages/zucht-profil.js | 23.6 KB | +| pages/zuchthunde.js | 67.0 KB | +| qrcode.min.js | 19.5 KB | +| ui.js | 34.8 KB | + +**Gesamt**: 46 Dateien, 1.9 MB + + +## Frontend — CSS + +| Datei | Größe | +| ------------------------- | -------- | +| ._components.css | 163.0 B | +| ._design-system.css | 163.0 B | +| ._layout.css | 163.0 B | +| MarkerCluster.Default.css | 1.3 KB | +| MarkerCluster.css | 872.0 B | +| components.css | 178.5 KB | +| design-system.css | 10.0 KB | +| layout.css | 20.7 KB | +| leaflet.css | 14.2 KB | + +**Gesamt**: 9 Dateien, 226.1 KB + + +## Frontend — HTML + +| Datei | Größe | +| ------------ | ------- | +| ._index.html | 163.0 B | +| index.html | 25.3 KB | +| landing.html | 35.2 KB | + diff --git a/reports/2026-05-01-funktionsumfang.md b/reports/2026-05-01-funktionsumfang.md new file mode 100644 index 0000000..988821f --- /dev/null +++ b/reports/2026-05-01-funktionsumfang.md @@ -0,0 +1,151 @@ +# Funktionsumfang — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Authentifizierung + +- Registrierung mit E-Mail-Verifikation +- Login / Logout (JWT + HttpOnly-Cookie) +- Passwort vergessen / zurücksetzen +- Verifikations-Mail erneut senden +- Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt) +- Partner-Codes (Gründer-Slot, eigene Einladungen) + + +## Hunde-Profile + +- Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …) +- Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau) +- Öffentliches Profil mit QR-Code und Teilen-Link +- Hunde-Ausweis (druckbares HTML-Dokument) +- Mehrere Hunde pro Account + + +## Forum + +- Thread erstellen mit Kategorien (allgemein, rasse, region, …) +- Antworten, Likes, Foto-Anhänge (max. 5 pro Thread) +- Moderatoren: Thread pinnen, sperren, löschen +- Report-System: Beiträge melden +- Push-Benachrichtigungen bei neuer Antwort +- Öffentlich lesbar, Schreiben nur für verifizierte User + + +## Tagebuch + +- Tageseinträge mit Freitext, Fotos, GPS-Koordinaten +- EXIF-GPS-Extraktion aus Foto-Uploads +- Kartenansicht aller Tagebuch-Pins +- Kalenderansicht nach Datum +- Medienansicht (Galerie aller Fotos) +- Day-One-kompatibles Format + + +## Gesundheit & Training + +- Gewichtsverlauf mit Diagramm +- Gesundheits-Erinnerungen (Push, täglich 08:00) +- 104 Übungen (DB-basiert, KI-Trainingspläne) +- Training-Logging mit Fortschrittsverfolgung +- KI-Gesundheitsberichte (wöchentlich, cloud/lokal) + + +## Karte & POIs + +- Leaflet-Karte mit Cluster-Markern +- Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe +- Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …) +- 90-Tage-Cache für Overpass-Abfragen +- ORS-Routenvorschläge zu Hundeparks + + +## Wiki & Rassen + +- Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment) +- Züchter-Verzeichnis mit Verifikation +- Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich') +- KI-gestützte Rassen-Anreicherung +- Wikipedia-basierte Beschreibungen + + +## Züchter-Features + +- Züchter-Antrag mit Dokument-Upload +- Admin-Prüfung und Freischaltung +- Züchter-Profil (Zwingername, Rassen, VDH, Stadt) +- Wurfverwaltung mit Elterntieren, Welpen, Fotos +- Tierschutz-Check vor Wurf-Anlage +- Stammbaum-Ansicht +- Genetik-Tracking (Farbgene, Erbkrankheiten) +- Kaufvertrags-Generator +- Jahresbericht-Export + + +## Social Features + +- Freundschaften (anfragen, annehmen, ablehnen) +- Social-Media-Posts (Luna — KI-Social-Manager) +- Lober: wöchentlicher KI-Lob-Push (Mo 09:00) +- Benachrichtigungen (in-app + Push-Notifications) + + +## Admin & Moderation + +- Admin-Dashboard: User-Verwaltung, Ban/Unban +- Moderation-Queue: gemeldete Beiträge +- Outreach-Mailing: Templates, Versand, Log +- Statistiken: User-Wachstum, Aktivität +- Züchter-Anträge prüfen +- Partner-Codes verwalten +- KI-Konfiguration (cloud/lokal, Limits) + + +## Infrastruktur + +- Service Worker (Offline-Stufen 1–3) +- Push-Notifications (VAPID) +- APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …) +- Brevo E-Mail-API + SMTP-Fallback +- Analytics: Umami v2 (extern) +- SEO: robots.txt, sitemap.xml, llms.txt +- Landing Page + Widget + + +--- + + +## Backend-Routers + +| Router | Präfix | +| ------------- | ------------------ | +| auth | /api/auth | +| dogs | /api/dogs | +| diary | /api/diary | +| health | /api/health | +| forum | /api/forum | +| wiki | /api/wiki | +| map | /api/map | +| poison | /api/poison | +| lost | /api/lost | +| breeder | /api/breeder | +| litters | /api/litters | +| training | /api/training | +| outreach | /api/outreach | +| moderation | /api/moderation | +| notes | /api/notes | +| notifications | /api/notifications | +| push | /api/push | +| friends | /api/friends | +| profile | /api/profile | +| social | /api/social | +| sitting | /api/sitting | +| achievements | /api/achievements | +| stats | /api/stats | +| walks | /api/walks | +| events | /api/events | +| alerts | /api/alerts | +| ratings | /api/ratings | diff --git a/reports/2026-05-01-nutzer.md b/reports/2026-05-01-nutzer.md new file mode 100644 index 0000000..7422033 --- /dev/null +++ b/reports/2026-05-01-nutzer.md @@ -0,0 +1,91 @@ +# Nutzerübersicht — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Nutzer nach Rolle + +| Gruppe | Anzahl | +| -------------------- | ------ | +| Gesamt Nutzer | 5 | +| Admin | 1 | +| Moderatoren | 2 | +| Züchter | 0 | +| Gründer (aktiv) | 0 | +| Partner | 1 | +| Premium | 0 | +| Gesperrt (banned) | 0 | +| E-Mail unverifiziert | 4 | + +## Registrierungen (letzte 6 Monate) + +| Monat | Neue Nutzer | +| ------- | ----------- | +| 2026-04 | 5 | + + +## Hunde + +| Metrik | Anzahl | +| ---------------------------- | ------ | +| Hunde gesamt | 4 | +| Hunde mit Tagebuch-Einträgen | 3 | + + +## Forum + +| Metrik | Anzahl | +| ---------------- | ------ | +| Threads | 10 | +| Antworten | 7 | +| Offene Meldungen | 0 | + +**Threads nach Kategorie:** + +| Kategorie | Threads | +| ----------- | ------- | +| rasse | 3 | +| spaziergang | 3 | +| allgemein | 2 | +| ausflug | 2 | + + +## Tagebuch + +| Metrik | Anzahl | +| ------------------- | ------ | +| Einträge gesamt | 117 | +| Mit Foto | 0 | +| Mit GPS-Koordinaten | 0 | + + +## Medien auf dem Server + +| Verzeichnis | Dateien | Größe | +| ----------- | ------- | -------- | +| avatars | 4 | 7.1 MB | +| breeds | 820 | 212.5 MB | +| diary | 311 | 215.6 MB | +| dogs | 10 | 39.8 MB | +| forum | 44 | 112.1 MB | +| poison | 0 | 0.0 B | +| routes | 1 | 6.6 MB | +| **GESAMT** | 1190 | 593.6 MB | + + +## Gesendete E-Mails + +| Absender | Anzahl | Erste Mail | Letzte Mail | +| -------- | ------ | ---------- | ----------- | +| partner | 9 | 2026-04-30 | 2026-04-30 | + +**Gesamt**: 9 Mails gesendet + + +## Besuche (Analytics) + +> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern über **Umami** erfasst und sind nicht im Container verfügbar. Bitte Umami-Dashboard direkt aufrufen. + diff --git a/reports/2026-05-01-partner.md b/reports/2026-05-01-partner.md new file mode 100644 index 0000000..31129b6 --- /dev/null +++ b/reports/2026-05-01-partner.md @@ -0,0 +1,24 @@ +# Partnerliste — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Partner-Accounts + +| Name | E-Mail | Partner seit | Gründer-Nr. | +| ---- | ---------------- | ------------ | ----------- | +| René | mail@motocamp.de | 2026-04-12 | — | + + +## Partner-Codes + +_Keine Partner-Codes_ + + +## Gründer + +_Noch keine Gründer_ + diff --git a/reports/2026-05-01-server.md b/reports/2026-05-01-server.md new file mode 100644 index 0000000..8dc3572 --- /dev/null +++ b/reports/2026-05-01-server.md @@ -0,0 +1,172 @@ +# Server & Speicherbelegung — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Festplattenbelegung + +``` +Filesystem Size Used Avail Use% Mounted on +/dev/mapper/cachedev_0 25T 14T 11T 58% /data +``` + + +## Media-Verzeichnisse + +``` +217M /data/media/diary +215M /data/media/breeds +113M /data/media/forum +40M /data/media/dogs +7.1M /data/media/avatars +6.6M /data/media/routes +0 /data/media/poison + +Gesamt: 596M /data/media +``` + + +## Datenbank + +**DB-Größe:** 62M /data/banyaro.db + +| Tabelle | Zeilen | +| ---------------------- | ------- | +| osm_pois | 440,865 | +| osm_tiles | 7,613 | +| wiki_rassen | 1,003 | +| diary_dogs | 118 | +| diary | 117 | +| training_exercises | 110 | +| diary_media | 101 | +| pflege_tipps | 45 | +| sqlite_sequence | 42 | +| push_subscriptions | 26 | +| user_badges | 22 | +| route_walks | 19 | +| notifications | 17 | +| exercise_progress | 15 | +| routes | 13 | +| user_map_pois | 13 | +| knigge_votes | 12 | +| forum_threads | 11 | +| health | 11 | +| direct_messages | 10 | +| outreach_log | 9 | +| forum_posts | 8 | +| forum_likes | 7 | +| poison | 6 | +| events | 5 | +| ki_daily_calls | 5 | +| training_sessions | 5 | +| users | 5 | +| dogs | 4 | +| ki_health_reports | 4 | +| social_content | 4 | +| weekly_praise | 4 | +| ors_daily_total | 3 | +| walks | 3 | +| friendships | 2 | +| zucht_hunde | 2 | +| admin_audit | 1 | +| breeder_jahresberichte | 1 | +| breeder_profiles | 1 | +| conversations | 1 | +| dog_shares | 1 | +| email_templates | 1 | +| hund_des_monats_votes | 1 | +| notes | 1 | +| ratings | 1 | +| tieraerzte | 1 | +| training_ki_cache | 1 | +| wiki_breed_interest | 1 | +| wiki_foto_submissions | 1 | +| breeder_documents | 0 | +| breeder_photos | 0 | +| dog_genetic_tests | 0 | +| dog_health_tests | 0 | +| dog_titles | 0 | +| event_rsvp | 0 | +| forum_reports | 0 | +| health_media | 0 | +| litters | 0 | +| lost_dogs | 0 | +| movie_votes | 0 | +| osm_poi_edits | 0 | +| osm_reports | 0 | +| partner_codes | 0 | +| places | 0 | +| premium_orders | 0 | +| puppies | 0 | +| puppy_weights | 0 | +| route_suggest_usage | 0 | +| service_offers | 0 | +| sitters | 0 | +| sitting_requests | 0 | +| sitting_subscriptions | 0 | +| training_plan_progress | 0 | +| walk_invitations | 0 | +| walk_participant_dogs | 0 | +| walk_participants | 0 | +| wiki_berichte | 0 | +| wiki_zuchter | 0 | + + +## App-Code + +**App-Verzeichnis (/app):** 8.9M /app + + +## Kapazitäts-Warnung + +> ✅ 58 % Festplatte belegt — ausreichend Kapazität. + + +## Installierte Python-Pakete + +``` +Package Version +------------------ ------------ +aiohappyeyeballs 2.6.1 +aiohttp 3.13.5 +aiosignal 1.4.0 +annotated-types 0.7.0 +anthropic 0.49.0 +anyio 4.13.0 +APScheduler 3.10.4 +attrs 26.1.0 +bcrypt 4.3.0 +certifi 2026.4.22 +cffi 2.0.0 +charset-normalizer 3.4.7 +click 8.3.3 +cryptography 47.0.0 +defusedxml 0.7.1 +distro 1.9.0 +dnspython 2.8.0 +email-validator 2.3.0 +fastapi 0.115.0 +frozenlist 1.8.0 +h11 0.16.0 +http_ece 1.2.1 +httpcore 1.0.9 +httptools 0.7.1 +httpx 0.28.1 +idna 3.13 +jiter 0.14.0 +multidict 6.7.1 +odfpy 1.4.1 +openai 1.59.2 +pillow 11.2.1 +pillow_heif 0.22.0 +pip 25.0.1 +polyline 2.0.2 +propcache 0.4.1 +py-vapid 1.9.4 +pycparser 3.0 +pydantic 2.10.6 +``` + diff --git a/reports/2026-05-01-sicherheit.md b/reports/2026-05-01-sicherheit.md new file mode 100644 index 0000000..49c50ea --- /dev/null +++ b/reports/2026-05-01-sicherheit.md @@ -0,0 +1,128 @@ +# Sicherheitsbericht — Ban Yaro + +_Erstellt: 01.05.2026 06:07_ + + +--- + + +## Übersicht implementierter Schutzmaßnahmen + + +### 1. Authentifizierung & Passwörter + +- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie +- **Bcrypt**-Passwort-Hashing mit automatischem Salt +- Mindestlänge 8 Zeichen, serverseitig erzwungen +- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf + + +### 2. Registrierung + +- **E-Mail-Verifikation** zwingend vor dem ersten Login +- Verifikationslink läuft nach 7 Tagen ab +- Rate Limit: 5 Registrierungen / Stunde / IP +- Username-Blocklist: >200 reservierte und unangemessene Begriffe +- Keine Doppelanmeldung (E-Mail und Username unique) + + +### 3. Login-Schutz + +- **IP-Rate-Limit**: 10 Versuche / 5 Minuten +- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse +- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory) +- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt +- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration) + + +### 4. Forum-Schutz + +- E-Mail-Verifikation Pflicht zum Posten +- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen +- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User +- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User +- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert +- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio +- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete) +- Report-System: User können Beiträge melden + + +### 5. HTTP-Security-Headers + +| Header | Wert | +|--------|------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … | +| `X-Content-Type-Options` | `nosniff` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | +| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) | + + +### 6. Rate Limiting (alle Endpunkte) + +| Endpunkt | Limit | Fenster | +| ------------------------- | ------ | -------------- | +| /auth/register | 5 Req | 60 Min | +| /auth/login (IP) | 10 Req | 5 Min | +| /auth/login (Email) | 5 Req | 5 Min | +| /auth/forgot-password | 3 Req | 60 Min | +| /auth/resend-verification | 3 Req | 60 Min / Email | +| /auth/reset-password | 5 Req | 60 Min | +| KI-Features | 10 Req | 60 Min | +| Poison-Reports | 3 Req | 60 Min | +| Wiki-Liste | 60 Req | 60 Sek | +| Wiki-Detail | 30 Req | 60 Sek | + + +### 7. Honeypot-Fallen + +Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden: + +``` +/api/admin/users /api/v1/users /api/users /api/.env +/api/config /api/setup /api/install /api/phpinfo +/api/debug /api/actuator /api/swagger /api/graphql +/api/wiki/trap +``` + + +### 8. Datei-Upload-Sicherheit + +- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM +- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR` +- **Größenbeschränkung**: 20 MB globales Limit (Middleware) +- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4 +- Max. 5 Fotos pro Forum-Thread + + +### 9. Admin & Moderation + +- Admin-Endpoints per `require_admin` Dependency geschützt +- Moderatoren-Rolle mit eingeschränkten Rechten +- User-Banning mit Sperrgrund, geprüft bei jedem Request +- Outreach-Mailing nur über Admin-Panel, vollständiges Log + + +## Aktuelle Kennzahlen + +| Metrik | Wert | +| ------------------------ | ---- | +| Gesperrte Accounts | 0 | +| Unverifizierte Accounts | 4 | +| Gesendete Outreach-Mails | 9 | + + +## Bekannte Einschränkungen + +- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart +- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz) +- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig) +- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container + + +## Empfehlungen für nächste Überprüfung + +- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre +- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline) +- [ ] Login-Logs in DB schreiben (für Audit-Trail) +- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren From de1677154f6018cf76f597c07f95667f761f3085 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 08:20:53 +0200 Subject: [PATCH 02/63] Security + E-Mail-HTML + Quartalsbericht + Registrierungspflicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrierung & Login: - E-Mail-Verifikation jetzt Pflicht vor erstem Login - Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen - Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert - Resend-Verification ohne Auth (email-basiert) - Frontend: _renderVerifyPending() nach Register und Login-Fehler - Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py) - Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min) - Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt E-Mail-Templates (alle Mails jetzt HTML): - email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige) - Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button - Admin-Outreach: plain text auto-wrapped in HTML - Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template - Tierschutz-Alert (litters.py) → Template - send_support_mail → HTML - outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter Forum-Schutz: - Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check) - Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde - Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory) - content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio-Check Security-Headers: - HSTS: max-age=31536000; includeSubDomains - Content-Security-Policy: frame-ancestors none, base-uri self, … - X-Frame-Options entfernt (CSP frame-ancestors ist moderner) Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre): - /api/admin/users, /api/v1/users, /api/.env, /api/config, /api/setup, /api/install, /api/phpinfo, /api/debug, /api/actuator, /api/swagger, /api/graphql u.a. Quartalsbericht-System: - backend/scripts/generate_reports.py: 6 Sections (Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server) - make reports: generiert alle Berichte aus dem Container, committed - Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00) → vollständige HTML-Mail an ADMIN_EMAIL - quarterly_report erscheint im täglichen Status-Report Admin-Panel: - "Forum & Meldungen" → "Forum" --- Makefile | 28 +- backend/content_filter.py | 63 +++ backend/mailer.py | 85 ++-- backend/main.py | 56 ++- backend/ratelimit.py | 81 +++- backend/routes/auth.py | 83 ++-- backend/routes/breeder.py | 67 +-- backend/routes/forum.py | 51 ++ backend/routes/litters.py | 21 +- backend/routes/outreach.py | 30 +- backend/scheduler.py | 138 +++++- backend/scripts/generate_reports.py | 725 ++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 2 +- backend/static/js/pages/settings.js | 72 ++- 15 files changed, 1363 insertions(+), 141 deletions(-) create mode 100644 backend/content_filter.py create mode 100644 backend/scripts/generate_reports.py diff --git a/Makefile b/Makefile index 2427674..910c66d 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./.DS_Store' .PHONY: help deploy deploy-clean staging release sync push restart build stop status \ - logs logs-f shell db dev clean-cache check-ssh + logs logs-f shell db dev clean-cache check-ssh reports # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -66,6 +66,7 @@ help: @echo "" @echo " make dev Lokaler Dev-Server auf Mac (Port 8001)" @echo " make clean-cache SW-Cache-Version erhöhen + restart" + @echo " make reports Quartalsberichte generieren + committen" @echo "" # ---------------------------------------------------------- @@ -235,6 +236,31 @@ dev: DB_PATH=./dev.db \ uvicorn main:app --reload --port 8001 +# ---------------------------------------------------------- +# REPORTS — Quartalsberichte generieren und committen +# Berichte laufen im Container (DB-Zugriff), werden lokal gespeichert +# ---------------------------------------------------------- +REPORT_DATE := $(shell date +%Y-%m-%d) +REPORT_SECTIONS := sicherheit funktionsumfang dateien nutzer partner server + +reports: check-ssh + @mkdir -p reports + @echo "→ Berichte generieren ($(REPORT_DATE))..." + @for section in $(REPORT_SECTIONS); do \ + echo " → $$section..."; \ + ssh $(DS_HOST) "$(DOCKER) exec $(CONTAINER) python3 scripts/generate_reports.py $$section" \ + > reports/$(REPORT_DATE)-$$section.md; \ + done + @echo "→ Berichte committen..." + @git add reports/ + @git diff --cached --quiet || git commit -m "Reports $(REPORT_DATE) — Quartalsbericht" + @echo "" + @echo " ✓ Alle Berichte erstellt und committed:" + @for section in $(REPORT_SECTIONS); do \ + echo " reports/$(REPORT_DATE)-$$section.md"; \ + done + + # ---------------------------------------------------------- # CACHE leeren — SW-Version erhöhen, dann restart # Nach größeren CSS/JS-Änderungen wenn SW gecacht hat diff --git a/backend/content_filter.py b/backend/content_filter.py new file mode 100644 index 0000000..e094253 --- /dev/null +++ b/backend/content_filter.py @@ -0,0 +1,63 @@ +"""BAN YARO — Content-Filter für Spam und Missbrauch im Forum.""" + +import re +from datetime import datetime, timedelta, timezone +from fastapi import HTTPException + +# Offensichtliche Spam-Signale +_SPAM_KEYWORDS = [ + "casino", "poker", "slots", "jackpot", "sportwetten", + "viagra", "cialis", "levitra", "pharmacy", "apotheke online", + "kreditkarte sofort", "kredit ohne schufa", "schnell geld verdienen", + "passive income", "work from home", "earn money fast", + "click here", "klick hier", "free followers", "buy followers", + "whatsapp +", "telegram +", "call now", "jetzt anrufen", + "seo service", "backlinks kaufen", "website traffic", + "crypto invest", "bitcoin verdienen", "nft mint", + "lose weight fast", "abnehmen schnell", "diät pille", +] + +# URL-Muster (http/https oder nackte Domains) +_URL_RE = re.compile( + r"(https?://|www\.|\b[a-zA-Z0-9-]+\.(de|com|net|org|io|app|shop|info|biz|ru|cn)\b)", + re.IGNORECASE, +) + +# Mindest-Account-Alter für URL-Posts (Tage) +_MIN_DAYS_FOR_URLS = 7 + + +def check_forum_content(text: str, user_created_at: str | None = None) -> None: + """ + Prüft Forum-Text auf Spam. + Wirft HTTPException(400) bei Fund. + """ + lower = text.lower() + + # Spam-Keywords + for kw in _SPAM_KEYWORDS: + if kw in lower: + raise HTTPException(400, "Dein Beitrag wurde als Spam erkannt und nicht gespeichert.") + + # URLs in neuen Accounts sperren + if _URL_RE.search(text): + if user_created_at: + try: + created = datetime.fromisoformat(user_created_at) + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + age = datetime.now(timezone.utc) - created + if age < timedelta(days=_MIN_DAYS_FOR_URLS): + raise HTTPException( + 400, + "Links können erst nach 7 Tagen Mitgliedschaft gepostet werden." + ) + except (ValueError, TypeError): + pass + + # Zu viele Sonderzeichen / Zeichensalat + if len(text) > 20: + alnum = sum(c.isalnum() or c.isspace() for c in text) + ratio = alnum / len(text) + if ratio < 0.5: + raise HTTPException(400, "Dein Beitrag enthält zu viele Sonderzeichen.") diff --git a/backend/mailer.py b/backend/mailer.py index e5cbdc0..344fe4f 100644 --- a/backend/mailer.py +++ b/backend/mailer.py @@ -106,44 +106,67 @@ async def send_email(to: str, subject: str, html: str, plain: str = ""): logger.warning(f"Kein Mail-Backend konfiguriert — Mail NICHT gesendet: «{subject}» → {to}") +def email_html( + body_html: str, + cta_url: str = None, + cta_label: str = None, + footer_text: str = None, +) -> str: + """Shared branded HTML email template (matches Status-Report design).""" + cta_block = "" + if cta_url and cta_label: + cta_block = f""" +

+ + {cta_label} + +

""" + + footer = footer_text or "Ban Yaro · banyaro.app" + + return f"""\ + + + + + + +
+ +
+
🐾 Ban Yaro
+
+ +
+ {body_html}{cta_block} +
+ +
+ {footer} +
+ +
+ +""" + + async def send_verify_email(to: str, name: str, token: str): url = f"{APP_URL}/api/auth/verify/{token}" subject = "Ban Yaro — E-Mail-Adresse bestätigen" - html = f"""\ - - - - -
-

Ban Yaro 🐾

-

Hallo {name},

-

+ body = f""" +

Hallo {name},

+

bitte bestätige deine E-Mail-Adresse, damit dein Konto vollständig eingerichtet ist.

-

- - E-Mail bestätigen - -

-

- Der Link ist 48 Stunden gültig. -

-

+

Der Link ist 48 Stunden gültig.

+

Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. -

-
- -""" +

""" - plain = ( - f"Ban Yaro — E-Mail-Adresse bestätigen\n\n" - f"Hallo {name},\n\n" - f"bitte bestätige deine E-Mail-Adresse:\n{url}\n\n" - f"Der Link ist 48 Stunden gültig.\n" - ) + html = email_html(body, cta_url=url, cta_label="E-Mail bestätigen") + plain = f"Ban Yaro — E-Mail bestätigen\n\nHallo {name},\n\nbitte bestätige:\n{url}\n\nLink ist 48h gültig.\n" await send_email(to, subject, html, plain) diff --git a/backend/main.py b/backend/main.py index e8720c9..83fa934 100644 --- a/backend/main.py +++ b/backend/main.py @@ -67,11 +67,20 @@ app = FastAPI( class SecurityHeadersMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) - response.headers["X-Frame-Options"] = "SAMEORIGIN" - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" - response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob: https:; " + "connect-src 'self' https:; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self';" + ) return response app.add_middleware(SecurityHeadersMiddleware) @@ -1617,6 +1626,43 @@ async def partner_landing(): return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"}) +# ------------------------------------------------------------------ +# Honeypot-Fallen für Scanner und Bots +# Jeder Aufruf → 24h IP-Sperre +# ------------------------------------------------------------------ +from ratelimit import block_ip as _block_ip + +_HONEYPOT_PATHS = [ + "/api/admin/users", + "/api/v1/users", + "/api/users", + "/api/.env", + "/api/config", + "/api/setup", + "/api/install", + "/api/phpinfo", + "/api/debug", + "/api/actuator", + "/api/actuator/health", + "/api/swagger", + "/api/graphql", +] + +async def _honeypot_handler(request: Request): + import logging as _log + _log.getLogger("banyaro.security").warning( + "Honeypot getroffen: %s %s — IP %s", + request.method, request.url.path, + request.client.host if request.client else "?" + ) + _block_ip(request, hours=24) + from fastapi.responses import JSONResponse + return JSONResponse(status_code=404, content={"detail": "Not Found"}) + +for _hp in _HONEYPOT_PATHS: + app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/ratelimit.py b/backend/ratelimit.py index 661eb26..7cb3a2f 100644 --- a/backend/ratelimit.py +++ b/backend/ratelimit.py @@ -1,9 +1,9 @@ """ -BAN YARO — Rate Limiter + IP-Blocklist +BAN YARO — Rate Limiter + IP-Blocklist + Account-Lockout + Duplikat-Erkennung Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container). -Blocklist für Honeypot-Treffer. """ +import hashlib import threading from collections import defaultdict, deque from datetime import datetime, timedelta @@ -11,18 +11,23 @@ from datetime import datetime, timedelta from fastapi import HTTPException, Request _buckets: dict[str, deque] = defaultdict(deque) -_blocklist: dict[str, datetime] = {} # ip → gesperrt bis +_blocklist: dict[str, datetime] = {} # ip → gesperrt bis +_login_failures: dict[str, list] = defaultdict(list) # email → [datetime, ...] +_post_hashes: dict[int, dict] = defaultdict(dict) # user_id → {hash: datetime} _lock = threading.Lock() +_LOCKOUT_WINDOW = 15 # Minuten +_LOCKOUT_ATTEMPTS = 5 # Fehlversuche bis Sperre +_DUPLICATE_WINDOW = 300 # Sekunden (5 Minuten) + +# ------------------------------------------------------------------ +# IP-basiertes Rate Limiting +# ------------------------------------------------------------------ def check(request: Request, *, max_requests: int, window_seconds: int, key: str = ""): - """ - Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten. - key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login'). - """ + """Wirft HTTP 429 wenn max_requests im Zeitfenster überschritten.""" ip = (request.client.host if request.client else "unknown") - # Blocklist prüfen with _lock: blocked_until = _blocklist.get(ip) if blocked_until and datetime.utcnow() < blocked_until: @@ -65,3 +70,63 @@ def is_blocked(request: Request) -> bool: elif until: del _blocklist[ip] return False + + +# ------------------------------------------------------------------ +# Account-Lockout (per E-Mail) +# ------------------------------------------------------------------ +def record_login_failure(email: str) -> int: + """Failure aufzeichnen. Gibt Anzahl Fehlversuche im Fenster zurück.""" + email = email.lower() + now = datetime.utcnow() + cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW) + with _lock: + recent = [t for t in _login_failures[email] if t > cutoff] + recent.append(now) + _login_failures[email] = recent + return len(recent) + + +def is_account_locked(email: str) -> bool: + """True wenn ≥5 Fehlversuche in den letzten 15 Minuten.""" + email = email.lower() + now = datetime.utcnow() + cutoff = now - timedelta(minutes=_LOCKOUT_WINDOW) + with _lock: + recent = [t for t in _login_failures.get(email, []) if t > cutoff] + return len(recent) >= _LOCKOUT_ATTEMPTS + + +def clear_login_failures(email: str): + """Bei erfolgreichem Login zurücksetzen.""" + with _lock: + _login_failures.pop(email.lower(), None) + + +# ------------------------------------------------------------------ +# Duplikat-Post-Erkennung (per User, in-memory) +# ------------------------------------------------------------------ +def content_hash(text: str) -> str: + normalized = " ".join(text.lower().split()) + return hashlib.sha256(normalized.encode()).hexdigest()[:20] + + +def is_duplicate_post(user_id: int, text: str) -> bool: + """True wenn derselbe User denselben Text in den letzten 5 Minuten gepostet hat.""" + h = content_hash(text) + now = datetime.utcnow() + cutoff = now - timedelta(seconds=_DUPLICATE_WINDOW) + with _lock: + hashes = _post_hashes[user_id] + # Alte Einträge bereinigen + expired = [k for k, ts in hashes.items() if ts < cutoff] + for k in expired: + del hashes[k] + return h in hashes + + +def record_post(user_id: int, text: str): + """Post-Hash speichern nach erfolgreichem Erstellen.""" + h = content_hash(text) + with _lock: + _post_hashes[user_id][h] = datetime.utcnow() diff --git a/backend/routes/auth.py b/backend/routes/auth.py index f810217..13d857d 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -15,7 +15,7 @@ from auth import ( get_current_user ) from username_blocklist import is_username_blocked -from ratelimit import check as rl_check +from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures router = APIRouter() COOKIE_NAME = "by_token" @@ -27,17 +27,22 @@ def _send_verification_email(email: str, name: str, token: str): if not _SMTP_READY: return from routes.outreach import _send_smtp + from mailer import email_html + url = f"{_APP_URL}/api/auth/verify-email/{token}" subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" - body = ( - f"Hallo {name},\n\n" - "willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n" - f"{_APP_URL}/api/auth/verify-email/{token}\n\n" - "Der Link ist 7 Tage gültig.\n\n" - "Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n" - "Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app" - ) + body_html = f""" +

Hallo {name},

+

+ willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird. +

+

Der Link ist 7 Tage gültig.

+

+ Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach. +

""" + html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen") + plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n" try: - _send_smtp(email, subject, body, "support") + _send_smtp(email, subject, plain, "support", html=html) except Exception: pass # Nicht blockieren wenn SMTP fehlschlägt @@ -139,24 +144,32 @@ async def register(data: RegisterRequest, response: Response, request: Request): conn.execute("UPDATE users SET referred_by=? WHERE id=?", (referrer['id'], new_user_id)) - token = create_token(user["id"], user["rolle"]) - _set_cookie(response, token) _send_verification_email(data.email, name, verify_token) - return {"token": token, "name": name, "email_verified": 0} + return {"pending_verification": True} @router.post("/login") async def login(data: LoginRequest, response: Response, request: Request): rl_check(request, max_requests=10, window_seconds=300, key="login") + rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}") + + if is_account_locked(data.email): + raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.") + with db() as conn: user = conn.execute( - "SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?", + "SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() if not user or not verify_password(data.password, user["pw_hash"]): + record_login_failure(data.email) raise HTTPException(401, "E-Mail oder Passwort falsch.") + if not user["email_verified"]: + raise HTTPException(403, "EMAIL_NOT_VERIFIED") + + clear_login_failures(data.email) token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) @@ -249,23 +262,24 @@ async def verify_email(token: str): return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) +class ResendVerificationRequest(BaseModel): + email: EmailStr + @router.post("/resend-verification") -async def resend_verification(request: Request, user=Depends(get_current_user)): - rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify") +async def resend_verification(data: ResendVerificationRequest, request: Request): + rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}") with db() as conn: row = conn.execute( - "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) + "SELECT id, name, email_verified FROM users WHERE email=?", (data.email,) ).fetchone() - if not row: - raise HTTPException(404) - if row["email_verified"]: - return {"ok": True, "already_verified": True} + if not row or row["email_verified"]: + return {"ok": True} token = secrets.token_urlsafe(32) with db() as conn: conn.execute( - "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"]) + "UPDATE users SET verification_token=? WHERE id=?", (token, row["id"]) ) - _send_verification_email(row["email"], row["name"], token) + _send_verification_email(data.email, row["name"], token) return {"ok": True} @@ -293,18 +307,23 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): (token, expires, user["id"]) ) app_url = os.getenv("APP_URL", "https://banyaro.app") + url = f"{app_url}/#reset-password?token={token}" subject = "Ban Yaro — Passwort zurücksetzen" - body = ( - f"Hallo {user['name']},\n\n" - "du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n" - f"Klicke hier um ein neues Passwort zu setzen:\n" - f"{app_url}/#reset-password?token={token}\n\n" - "Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n" - "Viele Grüße,\nDas Ban Yaro Team" - ) from routes.outreach import _send_smtp + from mailer import email_html + body_html = f""" +

Hallo {user['name']},

+

+ du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen. +

+

Der Link ist 2 Stunden gültig.

+

+ Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach. +

""" + html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen") + plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n" try: - _send_smtp(data.email, subject, body, "support") + _send_smtp(data.email, subject, plain, "support", html=html) except Exception: pass return {"ok": True} diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index bb5efc8..355a575 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -11,7 +11,7 @@ from typing import Optional from database import db from auth import get_current_user, require_premium -from mailer import send_email +from mailer import send_email, email_html router = APIRouter() logger = logging.getLogger(__name__) @@ -131,21 +131,21 @@ async def breeder_apply( ) # Admin benachrichtigen - admin_html = f""" -

Neuer Züchter-Antrag

-

Von: {user['name']} ({user['email']})

-

Zwingername: {zwingername}

-

Rasse: {rasse_text}

-

Verein: {verein}

-

VDH: {'Ja' if vdh_mitglied else 'Nein'}

-

Stadt: {stadt}

-

Im Admin-Bereich prüfen

- """ + admin_body = f""" +

Neuer Züchter-Antrag eingegangen:

+ + + + + + + +
Von{user['name']} ({user['email']})
Zwingername{zwingername}
Rasse{rasse_text}
Verein{verein}
VDH{'Ja' if vdh_mitglied else 'Nein'}
Stadt{stadt}
""" try: await send_email( ADMIN_EMAIL, f"[Banyaro] Neuer Züchter-Antrag — {zwingername}", - admin_html, + email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"), f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}", ) except Exception as e: @@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)): ) # Bestätigungs-Mail - html = f""" -

Willkommen als Züchter bei Banyaro!

-

Hallo {user['name']},

-

dein Züchter-Profil wurde erfolgreich verifiziert.

-

Ab sofort hast du Zugang zu allen Züchter-Features.

-

Zur App

- """ + approve_body = f""" +

Hallo {user['name']},

+

+ dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉
+ Ab sofort hast du Zugang zu allen Züchter-Features. +

""" try: await send_email( user["email"], - "Willkommen als Züchter bei Banyaro!", - html, + "Willkommen als Züchter bei Ban Yaro!", + email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"), f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.", ) except Exception as e: @@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req ) # Ablehnungs-Mail - html = f""" -

Dein Züchter-Antrag bei Banyaro

-

Hallo {user['name']},

-

leider konnten wir deinen Antrag aktuell nicht bestätigen.

-

Grund: {body.grund}

-

Du kannst jederzeit einen neuen Antrag stellen.

-

Bei Fragen: {ADMIN_EMAIL}

- """ + import html as _h + reject_body = f""" +

Hallo {user['name']},

+

+ leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen. +

+
+ Grund: {_h.escape(body.grund)} +
+

+ Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter + {ADMIN_EMAIL}. +

""" try: await send_email( user["email"], - "Dein Züchter-Antrag bei Banyaro", - html, + "Dein Züchter-Antrag bei Ban Yaro", + email_html(reject_body), f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}", ) except Exception as e: diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 0cfe1df..fe730d5 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -7,6 +7,8 @@ from typing import Optional from database import db from auth import get_current_user, get_current_user_optional from timeutils import safe_client_time +from ratelimit import is_duplicate_post, record_post +from content_filter import check_forum_content from routes.push import send_push_to_user from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from @@ -164,6 +166,50 @@ async def list_threads( # ------------------------------------------------------------------ # POST /api/forum/threads # ------------------------------------------------------------------ +def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False): + """Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts.""" + # 30-Sekunden-Cooldown zwischen beliebigen Posts + last = conn.execute( + """SELECT MAX(created_at) AS last FROM ( + SELECT created_at FROM forum_threads WHERE user_id=? + UNION ALL + SELECT created_at FROM forum_posts WHERE user_id=? + )""", + (user_id, user_id), + ).fetchone()["last"] + if last: + try: + from datetime import datetime as _dt + diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds() + if diff < 30: + raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.") + except (ValueError, TypeError): + pass + + # Stunden-Limit + if is_thread: + count = conn.execute( + "SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), + ).fetchone()[0] + if count >= 5: + raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.") + else: + count = conn.execute( + "SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')", + (user_id,), + ).fetchone()[0] + if count >= 20: + raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.") + + # Duplikat-Check + if is_duplicate_post(user_id, text): + raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.") + + # Content-Filter + check_forum_content(text, user_created_at) + + @router.post("/threads", status_code=201) async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if not user.get("email_verified"): @@ -177,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): if data.kategorie not in KATEGORIEN: raise HTTPException(400, "Ungültige Kategorie.") with db() as conn: + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True) ct = safe_client_time(data.client_time) cur = conn.execute( """INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at) @@ -194,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): t = dict(row) t['foto_urls'] = _parse_foto_urls(t.get('foto_urls')) t['user_liked'] = False + record_post(user["id"], data.text.strip()) return t @@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current if thread['is_deleted']: raise HTTPException(404, "Thread nicht gefunden.") + _check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False) + ct = safe_client_time(data.client_time) cur = conn.execute( "INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)", @@ -347,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current pd = dict(row) pd['foto_urls'] = [] pd['user_liked'] = False + record_post(user["id"], data.text.strip()) # Push-Notification an Thread-Owner (nicht an sich selbst) if owner_id and owner_id != user['id']: diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 2bcf629..82ba96f 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)): # ------------------------------------------------------------------ @router.post("/litters/{litter_id}/welfare-confirm") async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): - from mailer import send_email + from mailer import send_email, email_html import os, logging as _log _logger = _log.getLogger(__name__) @@ -265,19 +265,20 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): eltern = conn.execute( "SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,) ).fetchone() - html = f""" -

Tierschutz-Hinweis bestätigt

-

Züchter {zuechter} (Zwinger: {zwinger}) hat einen Wurf mit - kritischen Tierschutz-Hinweisen trotzdem angelegt.

-

Vater: {eltern['vater_name'] or '—'}  ·  Mutter: {eltern['mutter_name'] or '—'}

-

Wurf-ID: {litter_id}

-

Im Admin-Bereich prüfen

- """ + welfare_body = f""" +

Kritischer Tierschutz-Hinweis bestätigt

+ + + + + + +
Züchter{zuechter}
Zwinger{zwinger}
Vater{eltern['vater_name'] or '—'}
Mutter{eltern['mutter_name'] or '—'}
Wurf-ID#{litter_id}
""" try: await send_email( admin_email, f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}", - html, + email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"), f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.", ) except Exception as e: diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index 6ec066c..85eb624 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -84,7 +84,7 @@ def _imap_save_sent(msg_bytes: bytes, account: str): _log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e) -def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart: +def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart: acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] msg = MIMEMultipart("alternative") msg["Subject"] = subject @@ -92,14 +92,16 @@ def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultip msg["To"] = to msg["Reply-To"] = acc["from"] msg.attach(MIMEText(body, "plain", "utf-8")) + if html: + msg.attach(MIMEText(html, "html", "utf-8")) return msg -def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): +def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None): acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] if not acc["user"] or not acc["pass"]: raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") - msg = _build_message(to, subject, body, account) + msg = _build_message(to, subject, body, account, html=html) msg_bytes = msg.as_bytes() ctx = ssl.create_default_context() with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: @@ -189,6 +191,16 @@ def delete_template(tpl_id: int, user=Depends(require_admin)): # Senden # ------------------------------------------------------------------ +def _plain_to_html_body(text: str) -> str: + import html as h + paragraphs = text.strip().split("\n\n") + parts = [] + for p in paragraphs: + escaped = h.escape(p).replace("\n", "
") + parts.append(f'

{escaped}

') + return "".join(parts) + + @router.post("/send") def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.to: @@ -196,13 +208,19 @@ def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.subject.strip() or not data.body.strip(): raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.") + from mailer import email_html + html = email_html( + _plain_to_html_body(data.body), + footer_text=f"Ban Yaro · banyaro.app · {data.subject}", + ) + sent, failed = [], [] for addr in data.to: addr = addr.strip() if not addr: continue try: - _send_smtp(addr, data.subject, data.body, data.from_account) + _send_smtp(addr, data.subject, data.body, data.from_account, html=html) sent.append(addr) with db() as conn: conn.execute( @@ -224,7 +242,9 @@ def send_mail(data: SendRequest, user=Depends(require_admin)): def send_support_mail(to: str, subject: str, body: str): """Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik.""" - _send_smtp(to, subject, body, "support") + from mailer import email_html + html = email_html(_plain_to_html_body(body)) + _send_smtp(to, subject, body, "support", html=html) # ------------------------------------------------------------------ diff --git a/backend/scheduler.py b/backend/scheduler.py index c99600e..d87ef3f 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -100,6 +100,14 @@ def start(): replace_existing=True, misfire_grace_time=1800, ) + # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht + _scheduler.add_job( + _job_quarterly_report, + CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0), + id="quarterly_report", + replace_existing=True, + misfire_grace_time=7200, + ) # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) _scheduler.add_job( _job_ki_health_report, @@ -109,7 +117,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. 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, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -698,6 +706,7 @@ async def _job_status_report(): "seed_wikidata": "Rassen-Seed (Wikidata, monatlich)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "ki_health_report": "KI-Gesundheitsberichte", + "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", } job_rows_html = "" job_rows_txt = "" @@ -783,6 +792,133 @@ Züchter (pending): {metrics['zuchter_pending']} logger.error(f"Status-Report: Mail-Fehler: {e}") +async def _job_quarterly_report(): + """Quartalsbericht: alle Report-Sections per Mail an ADMIN_EMAIL.""" + import os, sys + from mailer import send_email, email_html + + admin = os.getenv("ADMIN_EMAIL", "") + if not admin: + logger.info("Quartalsbericht: ADMIN_EMAIL nicht gesetzt, übersprungen.") + _log_job("quarterly_report", "ok", "ADMIN_EMAIL nicht gesetzt") + return + + now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y") + quarter = (datetime.now(tz=_TZ).month - 1) // 3 + 1 + + try: + # Report-Script importieren und alle Sections aufrufen + sys.path.insert(0, "/app/scripts") + import importlib, generate_reports as gr + importlib.reload(gr) # sicherstellen dass aktuelle Version + + sections = [ + ("Sicherheit", gr.report_sicherheit), + ("Funktionsumfang", gr.report_funktionsumfang), + ("Dateien", gr.report_dateien), + ("Nutzerübersicht", gr.report_nutzer), + ("Partnerliste", gr.report_partner), + ("Server & Speicher", gr.report_server), + ] + + def md_to_html_simple(text: str) -> str: + """Minimale Markdown→HTML-Konvertierung für E-Mail.""" + import html as _h + lines_out = [] + in_code = False + in_table = False + for line in text.split("\n"): + if line.startswith("```"): + if in_code: + lines_out.append("") + in_code = False + else: + lines_out.append('
')
+                        in_code = True
+                    continue
+                if in_code:
+                    lines_out.append(_h.escape(line))
+                    continue
+                if line.startswith("#### "):
+                    lines_out.append(f'

{line[5:]}

') + elif line.startswith("### "): + lines_out.append(f'

{line[4:]}

') + elif line.startswith("## "): + lines_out.append(f'

{line[3:]}

') + elif line.startswith("# "): + pass # Haupttitel kommt vom äußeren Template + elif line.startswith("---"): + pass # Trennlinie überspringen + elif line.startswith("| "): + if not in_table: + lines_out.append('') + in_table = True + if set(line.replace("|","").replace("-","").replace(" ","")) == set(): + continue # Trenn-Zeile + cells = [c.strip() for c in line.split("|")[1:-1]] + row_html = "".join(f'' for c in cells) + lines_out.append(f"{row_html}") + continue + elif line.startswith("- ") or line.startswith("* "): + if in_table: + lines_out.append("
{_h.escape(c)}
") + in_table = False + lines_out.append(f'
  • {line[2:]}
  • ') + elif line.startswith("> "): + if in_table: + lines_out.append("") + in_table = False + lines_out.append(f'
    {line[2:]}
    ') + elif line.strip() == "": + if in_table: + lines_out.append("") + in_table = False + lines_out.append("") + else: + if in_table: + lines_out.append("") + in_table = False + styled = line.replace("**", "", 1).replace("**", "", 1) + lines_out.append(f'

    {styled}

    ') + if in_table: + lines_out.append("") + if in_code: + lines_out.append("
    ") + return "\n".join(lines_out) + + # Body aus allen Sections zusammensetzen + body_parts = [] + plain_parts = [f"Ban Yaro Quartalsbericht Q{quarter} {now_str}\n", "=" * 50] + + for title, fn in sections: + try: + md = fn() + body_parts.append( + f'
    ' + f'

    {title}

    ' + f'{md_to_html_simple(md)}' + f'
    ' + ) + plain_parts.append(f"\n=== {title.upper()} ===\n{md}\n") + except Exception as e: + body_parts.append(f'

    Fehler in Section {title}: {e}

    ') + plain_parts.append(f"\n=== {title.upper()} ===\nFehler: {e}\n") + + full_body = "\n".join(body_parts) + full_plain = "\n".join(plain_parts) + subject = f"Ban Yaro Quartalsbericht Q{quarter}/{datetime.now(tz=_TZ).year} — {now_str}" + html = email_html(full_body, footer_text=f"Ban Yaro · banyaro.app · Quartalsbericht Q{quarter}") + + await send_email(admin, subject, html, full_plain) + logger.info(f"Quartalsbericht Q{quarter} gesendet an {admin}.") + _log_job("quarterly_report", "ok", f"Q{quarter} → {admin}") + + except Exception as e: + logger.error(f"Quartalsbericht: Fehler: {e}") + _log_job("quarterly_report", "error", str(e)) + + def _compute_milestone(today: date, bday: date, dog_name: str): """ Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist, diff --git a/backend/scripts/generate_reports.py b/backend/scripts/generate_reports.py new file mode 100644 index 0000000..6484c70 --- /dev/null +++ b/backend/scripts/generate_reports.py @@ -0,0 +1,725 @@ +#!/usr/bin/env python3 +""" +BAN YARO — Quarterly Report Generator +Aufruf: python3 scripts/generate_reports.py
    +Sections: sicherheit | funktionsumfang | dateien | nutzer | partner | server | all +""" + +import os +import sys +import sqlite3 +import subprocess +from datetime import datetime +from pathlib import Path + +DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +APP_DIR = "/app" +NOW = datetime.now() +DATE_STR = NOW.strftime("%d.%m.%Y %H:%M") +ISO_DATE = NOW.strftime("%Y-%m-%d") + + +# ────────────────────────────────────────────────────────────────────────────── +# Hilfsfunktionen +# ────────────────────────────────────────────────────────────────────────────── + +def db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def q(sql, params=()): + try: + with db() as conn: + return conn.execute(sql, params).fetchall() + except Exception as e: + return [] + + +def q1(sql, params=()): + rows = q(sql, params) + return rows[0] if rows else None + + +def val(sql, params=(), default=0): + row = q1(sql, params) + if row is None: + return default + return row[0] + + +def sh(cmd): + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=10) + return r.stdout.strip() + except Exception: + return "(nicht verfügbar)" + + +def hr(): + return "\n---\n" + + +def h(level, text): + return f"\n{'#' * level} {text}\n" + + +def table(headers, rows): + col_widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = max(col_widths[i], len(str(cell))) + sep = "| " + " | ".join("-" * w for w in col_widths) + " |" + hdr = "| " + " | ".join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + " |" + lines = [hdr, sep] + for row in rows: + line = "| " + " | ".join(str(row[i] if i < len(row) else "").ljust(col_widths[i]) for i in range(len(headers))) + " |" + lines.append(line) + return "\n".join(lines) + + +def bytes_human(b): + for unit in ("B", "KB", "MB", "GB"): + if b < 1024: + return f"{b:.1f} {unit}" + b /= 1024 + return f"{b:.1f} TB" + + +# ────────────────────────────────────────────────────────────────────────────── +# 1 SICHERHEITSBERICHT +# ────────────────────────────────────────────────────────────────────────────── + +def report_sicherheit(): + # Aktive Bans aus DB + banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1") + unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0") + outreach_rows = q("SELECT COUNT(*) as n, from_account FROM outreach_log GROUP BY from_account") + + lines = [ + f"# Sicherheitsbericht — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + h(2, "Übersicht implementierter Schutzmaßnahmen"), + h(3, "1. Authentifizierung & Passwörter"), + "- **JWT** (HS256) mit 30-Tage-Ablauf, HttpOnly + Secure + SameSite=lax Cookie", + "- **Bcrypt**-Passwort-Hashing mit automatischem Salt", + "- Mindestlänge 8 Zeichen, serverseitig erzwungen", + "- Passwort-Reset: kryptographisches Token, 2 Stunden Ablauf", + "", + h(3, "2. Registrierung"), + "- **E-Mail-Verifikation** zwingend vor dem ersten Login", + "- Verifikationslink läuft nach 7 Tagen ab", + "- Rate Limit: 5 Registrierungen / Stunde / IP", + "- Username-Blocklist: >200 reservierte und unangemessene Begriffe", + "- Keine Doppelanmeldung (E-Mail und Username unique)", + "", + h(3, "3. Login-Schutz"), + "- **IP-Rate-Limit**: 10 Versuche / 5 Minuten", + "- **Email-Rate-Limit**: 5 Versuche / 5 Minuten pro E-Mail-Adresse", + "- **Account-Lockout**: 5 Fehlversuche → 15 Minuten gesperrt (in-memory)", + "- Fehlerzähler wird bei erfolgreichem Login zurückgesetzt", + "- Gleiche Fehlermeldung bei falschem Passwort UND unbekannter E-Mail (kein User-Enumeration)", + "", + h(3, "4. Forum-Schutz"), + "- E-Mail-Verifikation Pflicht zum Posten", + "- **Post-Cooldown**: 30 Sekunden zwischen beliebigen Beiträgen", + "- **Stunden-Limit Threads**: max. 5 neue Threads / Stunde / User", + "- **Stunden-Limit Antworten**: max. 20 Antworten / Stunde / User", + "- **Duplikat-Erkennung**: gleicher Text in 5 Minuten → blockiert", + "- **Content-Filter**: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio", + "- Moderatoren können Threads sperren, Beiträge löschen (Soft-Delete)", + "- Report-System: User können Beiträge melden", + "", + h(3, "5. HTTP-Security-Headers"), + "| Header | Wert |", + "|--------|------|", + "| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |", + "| `Content-Security-Policy` | default-src 'self'; frame-ancestors 'none'; … |", + "| `X-Content-Type-Options` | `nosniff` |", + "| `Referrer-Policy` | `strict-origin-when-cross-origin` |", + "| `Permissions-Policy` | camera=(), microphone=(), geolocation=(self) |", + "", + h(3, "6. Rate Limiting (alle Endpunkte)"), + table( + ["Endpunkt", "Limit", "Fenster"], + [ + ["/auth/register", "5 Req", "60 Min"], + ["/auth/login (IP)", "10 Req", "5 Min"], + ["/auth/login (Email)", "5 Req", "5 Min"], + ["/auth/forgot-password", "3 Req", "60 Min"], + ["/auth/resend-verification", "3 Req", "60 Min / Email"], + ["/auth/reset-password", "5 Req", "60 Min"], + ["KI-Features", "10 Req", "60 Min"], + ["Poison-Reports", "3 Req", "60 Min"], + ["Wiki-Liste", "60 Req", "60 Sek"], + ["Wiki-Detail", "30 Req", "60 Sek"], + ] + ), + "", + h(3, "7. Honeypot-Fallen"), + "Folgende Pfade blockieren Scanner-IPs sofort für 24 Stunden:", + "", + "```", + "/api/admin/users /api/v1/users /api/users /api/.env", + "/api/config /api/setup /api/install /api/phpinfo", + "/api/debug /api/actuator /api/swagger /api/graphql", + "/api/wiki/trap", + "```", + "", + h(3, "8. Datei-Upload-Sicherheit"), + "- **Magic-Byte-Prüfung**: JPEG, PNG, GIF, WebP, MP4, WebM", + "- **Path-Traversal-Schutz**: alle Pfade bleiben innerhalb `MEDIA_DIR`", + "- **Größenbeschränkung**: 20 MB globales Limit (Middleware)", + "- Automatische Konvertierung: HEIC→JPEG, MOV/AVI→MP4", + "- Max. 5 Fotos pro Forum-Thread", + "", + h(3, "9. Admin & Moderation"), + "- Admin-Endpoints per `require_admin` Dependency geschützt", + "- Moderatoren-Rolle mit eingeschränkten Rechten", + "- User-Banning mit Sperrgrund, geprüft bei jedem Request", + "- Outreach-Mailing nur über Admin-Panel, vollständiges Log", + "", + h(2, "Aktuelle Kennzahlen"), + table( + ["Metrik", "Wert"], + [ + ["Gesperrte Accounts", str(banned)], + ["Unverifizierte Accounts", str(unverifiziert)], + ["Gesendete Outreach-Mails", str(sum(r[0] for r in outreach_rows))], + ] + ), + "", + h(2, "Bekannte Einschränkungen"), + "- Rate-Limit-Daten und IP-Blocklist sind **in-memory** → Reset bei Container-Neustart", + "- Kein CAPTCHA (bewusst: Nutzerfreundlichkeit vs. Bot-Schutz)", + "- Keine Refresh-Token-Rotation (JWT ist 30 Tage gültig)", + "- Analytics (Besucher) extern über Umami — kein Zugriff aus dem Container", + "", + h(2, "Empfehlungen für nächste Überprüfung"), + "- [ ] Prüfen ob IP-Blocklist-Persistenz via DB sinnvoll wäre", + "- [ ] CSP weiter verschärfen (nonce-basiert statt unsafe-inline)", + "- [ ] Login-Logs in DB schreiben (für Audit-Trail)", + "- [ ] Zwei-Faktor-Authentifizierung für Admin-Accounts evaluieren", + ] + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 2 FUNKTIONSUMFANG +# ────────────────────────────────────────────────────────────────────────────── + +def report_funktionsumfang(): + BEREICHE = [ + ("Authentifizierung", [ + "Registrierung mit E-Mail-Verifikation", + "Login / Logout (JWT + HttpOnly-Cookie)", + "Passwort vergessen / zurücksetzen", + "Verifikations-Mail erneut senden", + "Referral-System (3 Stufen: 10/20/50 Refs → 20/30/50 % Rabatt)", + "Partner-Codes (Gründer-Slot, eigene Einladungen)", + ]), + ("Hunde-Profile", [ + "Anlegen / Bearbeiten von Hunde-Profilen (Rasse, Geburtsdatum, Gewicht, …)", + "Avatar-Upload (JPEG/WebP-Konvertierung, Vorschau)", + "Öffentliches Profil mit QR-Code und Teilen-Link", + "Hunde-Ausweis (druckbares HTML-Dokument)", + "Mehrere Hunde pro Account", + ]), + ("Forum", [ + "Thread erstellen mit Kategorien (allgemein, rasse, region, …)", + "Antworten, Likes, Foto-Anhänge (max. 5 pro Thread)", + "Moderatoren: Thread pinnen, sperren, löschen", + "Report-System: Beiträge melden", + "Push-Benachrichtigungen bei neuer Antwort", + "Öffentlich lesbar, Schreiben nur für verifizierte User", + ]), + ("Tagebuch", [ + "Tageseinträge mit Freitext, Fotos, GPS-Koordinaten", + "EXIF-GPS-Extraktion aus Foto-Uploads", + "Kartenansicht aller Tagebuch-Pins", + "Kalenderansicht nach Datum", + "Medienansicht (Galerie aller Fotos)", + "Day-One-kompatibles Format", + ]), + ("Gesundheit & Training", [ + "Gewichtsverlauf mit Diagramm", + "Gesundheits-Erinnerungen (Push, täglich 08:00)", + "104 Übungen (DB-basiert, KI-Trainingspläne)", + "Training-Logging mit Fortschrittsverfolgung", + "KI-Gesundheitsberichte (wöchentlich, cloud/lokal)", + ]), + ("Karte & POIs", [ + "Leaflet-Karte mit Cluster-Markern", + "Nearby-Alerts: Giftköder, Vermisste Hunde in der Nähe", + "Overpass-API-Integration (Tierärzte, Hundewiesen, Parks, …)", + "90-Tage-Cache für Overpass-Abfragen", + "ORS-Routenvorschläge zu Hundeparks", + ]), + ("Wiki & Rassen", [ + "Rassen-Datenbank (TheDogAPI + Wikidata-Enrichment)", + "Züchter-Verzeichnis mit Verifikation", + "Breed-Interest-Tracking ('So einen hab ich' / 'Interessiert mich')", + "KI-gestützte Rassen-Anreicherung", + "Wikipedia-basierte Beschreibungen", + ]), + ("Züchter-Features", [ + "Züchter-Antrag mit Dokument-Upload", + "Admin-Prüfung und Freischaltung", + "Züchter-Profil (Zwingername, Rassen, VDH, Stadt)", + "Wurfverwaltung mit Elterntieren, Welpen, Fotos", + "Tierschutz-Check vor Wurf-Anlage", + "Stammbaum-Ansicht", + "Genetik-Tracking (Farbgene, Erbkrankheiten)", + "Kaufvertrags-Generator", + "Jahresbericht-Export", + ]), + ("Social Features", [ + "Freundschaften (anfragen, annehmen, ablehnen)", + "Social-Media-Posts (Luna — KI-Social-Manager)", + "Lober: wöchentlicher KI-Lob-Push (Mo 09:00)", + "Benachrichtigungen (in-app + Push-Notifications)", + ]), + ("Admin & Moderation", [ + "Admin-Dashboard: User-Verwaltung, Ban/Unban", + "Moderation-Queue: gemeldete Beiträge", + "Outreach-Mailing: Templates, Versand, Log", + "Statistiken: User-Wachstum, Aktivität", + "Züchter-Anträge prüfen", + "Partner-Codes verwalten", + "KI-Konfiguration (cloud/lokal, Limits)", + ]), + ("Infrastruktur", [ + "Service Worker (Offline-Stufen 1–3)", + "Push-Notifications (VAPID)", + "APScheduler: 9 Hintergrund-Jobs (Gesundheit, Wetter, Events, …)", + "Brevo E-Mail-API + SMTP-Fallback", + "Analytics: Umami v2 (extern)", + "SEO: robots.txt, sitemap.xml, llms.txt", + "Landing Page + Widget", + ]), + ] + + lines = [ + "# Funktionsumfang — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + for bereich, features in BEREICHE: + lines.append(h(2, bereich)) + for f in features: + lines.append(f"- {f}") + lines.append("") + + # Anzahl Routes aus DB-Query-Kontext (statisch) + lines += [ + hr(), + h(2, "Backend-Routers"), + table( + ["Router", "Präfix"], + [ + ["auth", "/api/auth"], + ["dogs", "/api/dogs"], + ["diary", "/api/diary"], + ["health", "/api/health"], + ["forum", "/api/forum"], + ["wiki", "/api/wiki"], + ["map", "/api/map"], + ["poison", "/api/poison"], + ["lost", "/api/lost"], + ["breeder", "/api/breeder"], + ["litters", "/api/litters"], + ["training", "/api/training"], + ["outreach", "/api/outreach"], + ["moderation", "/api/moderation"], + ["notes", "/api/notes"], + ["notifications", "/api/notifications"], + ["push", "/api/push"], + ["friends", "/api/friends"], + ["profile", "/api/profile"], + ["social", "/api/social"], + ["sitting", "/api/sitting"], + ["achievements", "/api/achievements"], + ["stats", "/api/stats"], + ["walks", "/api/walks"], + ["events", "/api/events"], + ["alerts", "/api/alerts"], + ["ratings", "/api/ratings"], + ] + ), + ] + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 3 DATEILISTE +# ────────────────────────────────────────────────────────────────────────────── + +def report_dateien(): + lines = [ + "# Dateiliste — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + def scan_dir(title, path, ext): + lines.append(h(2, title)) + files = sorted(Path(path).rglob(f"*.{ext}")) if Path(path).exists() else [] + rows = [] + total = 0 + for f in files: + try: + size = f.stat().st_size + total += size + rows.append([str(f.relative_to(path)), bytes_human(size)]) + except Exception: + pass + if rows: + lines.append(table(["Datei", "Größe"], rows)) + lines.append(f"\n**Gesamt**: {len(rows)} Dateien, {bytes_human(total)}\n") + + scan_dir("Backend — Python-Dateien", APP_DIR, "py") + scan_dir("Frontend — JavaScript", f"{APP_DIR}/static/js", "js") + scan_dir("Frontend — CSS", f"{APP_DIR}/static/css", "css") + + # HTML-Templates + html_files = list(Path(f"{APP_DIR}/static").glob("*.html")) if Path(f"{APP_DIR}/static").exists() else [] + if html_files: + lines.append(h(2, "Frontend — HTML")) + rows = [[f.name, bytes_human(f.stat().st_size)] for f in sorted(html_files)] + lines.append(table(["Datei", "Größe"], rows)) + lines.append("") + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 4 NUTZERÜBERSICHT +# ────────────────────────────────────────────────────────────────────────────── + +def report_nutzer(): + lines = [ + "# Nutzerübersicht — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + # Nutzer nach Rolle + lines.append(h(2, "Nutzer nach Rolle")) + total_users = val("SELECT COUNT(*) FROM users") + admins = val("SELECT COUNT(*) FROM users WHERE rolle='admin'") + mods = val("SELECT COUNT(*) FROM users WHERE rolle='moderator' OR is_moderator=1") + breeders = val("SELECT COUNT(*) FROM users WHERE rolle='breeder'") + founders = val("SELECT COUNT(*) FROM users WHERE is_founder=1") + partners = val("SELECT COUNT(*) FROM users WHERE is_partner=1") + banned = val("SELECT COUNT(*) FROM users WHERE is_banned=1") + unverifiziert = val("SELECT COUNT(*) FROM users WHERE email_verified=0") + premium = val("SELECT COUNT(*) FROM users WHERE is_premium=1") + + lines.append(table( + ["Gruppe", "Anzahl"], + [ + ["Gesamt Nutzer", str(total_users)], + ["Admin", str(admins)], + ["Moderatoren", str(mods)], + ["Züchter", str(breeders)], + ["Gründer (aktiv)", str(founders)], + ["Partner", str(partners)], + ["Premium", str(premium)], + ["Gesperrt (banned)", str(banned)], + ["E-Mail unverifiziert", str(unverifiziert)], + ] + )) + + # Registrierungen pro Monat (letzte 6 Monate) + lines.append(h(2, "Registrierungen (letzte 6 Monate)")) + reg_rows = q(""" + SELECT strftime('%Y-%m', created_at) as monat, COUNT(*) as n + FROM users + WHERE created_at >= date('now', '-6 months') + GROUP BY monat ORDER BY monat + """) + if reg_rows: + lines.append(table(["Monat", "Neue Nutzer"], [(r[0], r[1]) for r in reg_rows])) + else: + lines.append("_Keine Daten_") + lines.append("") + + # Hunde + lines.append(h(2, "Hunde")) + dogs = val("SELECT COUNT(*) FROM dogs") + dogs_with_diary = val("SELECT COUNT(DISTINCT dog_id) FROM diary") + lines.append(table( + ["Metrik", "Anzahl"], + [ + ["Hunde gesamt", str(dogs)], + ["Hunde mit Tagebuch-Einträgen", str(dogs_with_diary)], + ] + )) + lines.append("") + + # Forum + lines.append(h(2, "Forum")) + threads = val("SELECT COUNT(*) FROM forum_threads WHERE is_deleted=0") + posts = val("SELECT COUNT(*) FROM forum_posts WHERE is_deleted=0") + reports_open = val("SELECT COUNT(*) FROM forum_reports WHERE resolved=0", default=0) + lines.append(table( + ["Metrik", "Anzahl"], + [ + ["Threads", str(threads)], + ["Antworten", str(posts)], + ["Offene Meldungen", str(reports_open)], + ] + )) + + # Kategorie-Verteilung + kat_rows = q(""" + SELECT kategorie, COUNT(*) as n + FROM forum_threads WHERE is_deleted=0 + GROUP BY kategorie ORDER BY n DESC + """) + if kat_rows: + lines.append("\n**Threads nach Kategorie:**\n") + lines.append(table(["Kategorie", "Threads"], [(r[0], r[1]) for r in kat_rows])) + lines.append("") + + # Tagebuch + lines.append(h(2, "Tagebuch")) + diary_total = val("SELECT COUNT(*) FROM diary") + diary_mit_foto = val("SELECT COUNT(*) FROM diary WHERE foto_url IS NOT NULL AND foto_url != ''") + diary_mit_gps = val("SELECT COUNT(*) FROM diary WHERE lat IS NOT NULL") + lines.append(table( + ["Metrik", "Anzahl"], + [ + ["Einträge gesamt", str(diary_total)], + ["Mit Foto", str(diary_mit_foto)], + ["Mit GPS-Koordinaten", str(diary_mit_gps)], + ] + )) + lines.append("") + + # Medien (Dateisystem) + lines.append(h(2, "Medien auf dem Server")) + media_root = Path(MEDIA_DIR) + if media_root.exists(): + rows = [] + total_size = 0 + total_count = 0 + for subdir in sorted(media_root.iterdir()): + if subdir.is_dir(): + files = list(subdir.rglob("*")) + files = [f for f in files if f.is_file()] + size = sum(f.stat().st_size for f in files if f.is_file()) + total_size += size + total_count += len(files) + rows.append([subdir.name, str(len(files)), bytes_human(size)]) + rows.append(["**GESAMT**", str(total_count), bytes_human(total_size)]) + lines.append(table(["Verzeichnis", "Dateien", "Größe"], rows)) + else: + lines.append(f"_Media-Verzeichnis nicht gefunden: {MEDIA_DIR}_") + lines.append("") + + # Outreach-Mails + lines.append(h(2, "Gesendete E-Mails")) + mail_rows = q(""" + SELECT from_account, COUNT(*) as n, + MIN(sent_at) as erste, MAX(sent_at) as letzte + FROM outreach_log + GROUP BY from_account ORDER BY n DESC + """) + if mail_rows: + lines.append(table( + ["Absender", "Anzahl", "Erste Mail", "Letzte Mail"], + [(r[0], r[1], r[2][:10] if r[2] else "—", r[3][:10] if r[3] else "—") for r in mail_rows] + )) + total_mails = sum(r[1] for r in mail_rows) + lines.append(f"\n**Gesamt**: {total_mails} Mails gesendet\n") + else: + lines.append("_Noch keine Mails versendet_\n") + + # Analytics-Hinweis + lines += [ + h(2, "Besuche (Analytics)"), + "> **Hinweis:** Besucher-Statistiken (Besuche/Tag und Monat) werden extern " + "über **Umami** erfasst und sind nicht im Container verfügbar. " + "Bitte Umami-Dashboard direkt aufrufen.", + "", + ] + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 5 PARTNERLISTE +# ────────────────────────────────────────────────────────────────────────────── + +def report_partner(): + lines = [ + "# Partnerliste — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + # Partner-User + lines.append(h(2, "Partner-Accounts")) + partner_users = q(""" + SELECT name, email, created_at, founder_number + FROM users WHERE is_partner=1 + ORDER BY created_at + """) + if partner_users: + lines.append(table( + ["Name", "E-Mail", "Partner seit", "Gründer-Nr."], + [(r[0], r[1], r[2][:10] if r[2] else "—", str(r[3]) if r[3] else "—") for r in partner_users] + )) + else: + lines.append("_Keine Partner-Accounts_") + lines.append("") + + # Partner-Codes + lines.append(h(2, "Partner-Codes")) + codes = q(""" + SELECT code, grants_founder, max_uses, uses, created_at + FROM partner_codes ORDER BY created_at + """) + if codes: + lines.append(table( + ["Code", "Gründer-Slot", "Max. Nutzungen", "Verwendet", "Erstellt"], + [( + r[0], + "Ja" if r[1] else "Nein", + str(r[2]) if r[2] else "∞", + str(r[3]), + r[4][:10] if r[4] else "—" + ) for r in codes] + )) + else: + lines.append("_Keine Partner-Codes_") + lines.append("") + + # Gründer + lines.append(h(2, "Gründer")) + gruender = q(""" + SELECT founder_number, name, email, created_at + FROM users WHERE is_founder=1 + ORDER BY founder_number + """) + if gruender: + lines.append(table( + ["Nr.", "Name", "E-Mail", "Registriert"], + [(r[0], r[1], r[2], r[3][:10] if r[3] else "—") for r in gruender] + )) + lines.append(f"\n**{len(gruender)} von 100 Gründer-Plätzen belegt.**\n") + else: + lines.append("_Noch keine Gründer_") + lines.append("") + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# 6 SERVER & SPEICHER +# ────────────────────────────────────────────────────────────────────────────── + +def report_server(): + lines = [ + "# Server & Speicherbelegung — Ban Yaro", + f"\n_Erstellt: {DATE_STR}_\n", + hr(), + ] + + # Disk Usage + lines.append(h(2, "Festplattenbelegung")) + df_out = sh("df -h /data 2>/dev/null || df -h /") + lines.append(f"```\n{df_out}\n```\n") + + # Media-Verzeichnisse + lines.append(h(2, "Media-Verzeichnisse")) + du_media = sh(f"du -sh {MEDIA_DIR}/* 2>/dev/null | sort -rh") + du_total = sh(f"du -sh {MEDIA_DIR} 2>/dev/null") + if du_media: + lines.append(f"```\n{du_media}\n\nGesamt: {du_total}\n```\n") + else: + lines.append("_Keine Media-Daten_\n") + + # DB-Größe + lines.append(h(2, "Datenbank")) + db_size = sh(f"du -sh {DB_PATH} 2>/dev/null") + db_rows = {} + try: + with db() as conn: + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + for t in tables: + name = t[0] + count = conn.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0] + db_rows[name] = count + except Exception: + pass + lines.append(f"**DB-Größe:** {db_size}\n") + if db_rows: + rows_sorted = sorted(db_rows.items(), key=lambda x: x[1], reverse=True) + lines.append(table(["Tabelle", "Zeilen"], [(k, f"{v:,}") for k, v in rows_sorted])) + lines.append("") + + # App-Code Größe + lines.append(h(2, "App-Code")) + du_app = sh(f"du -sh {APP_DIR} 2>/dev/null") + lines.append(f"**App-Verzeichnis ({APP_DIR}):** {du_app}\n") + + # Speicher-Kapazität (Warnung wenn >80 %) + lines.append(h(2, "Kapazitäts-Warnung")) + df_pct = sh("df /data 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%' || df / | awk 'NR==2{print $5}' | tr -d '%'") + try: + pct = int(df_pct.strip()) + if pct >= 90: + lines.append(f"> ⚠️ **KRITISCH: {pct} % Festplatte belegt!** Sofortige Maßnahmen nötig.") + elif pct >= 80: + lines.append(f"> ⚠️ **Warnung: {pct} % Festplatte belegt.** Bald aufrüsten.") + elif pct >= 70: + lines.append(f"> ℹ️ {pct} % Festplatte belegt — im Blick behalten.") + else: + lines.append(f"> ✅ {pct} % Festplatte belegt — ausreichend Kapazität.") + except (ValueError, TypeError): + lines.append(f"> Belegung: {df_pct}") + lines.append("") + + # Python-Pakete + lines.append(h(2, "Installierte Python-Pakete")) + pip_list = sh("pip list --format=columns 2>/dev/null | head -40") + lines.append(f"```\n{pip_list}\n```\n") + + return "\n".join(lines) + + +# ────────────────────────────────────────────────────────────────────────────── +# Main +# ────────────────────────────────────────────────────────────────────────────── + +REPORTS = { + "sicherheit": report_sicherheit, + "funktionsumfang": report_funktionsumfang, + "dateien": report_dateien, + "nutzer": report_nutzer, + "partner": report_partner, + "server": report_server, +} + +if __name__ == "__main__": + section = sys.argv[1] if len(sys.argv) > 1 else "all" + + if section == "all": + for name, fn in REPORTS.items(): + print(f"=== REPORT:{name} ===") + print(fn()) + print() + elif section in REPORTS: + print(REPORTS[section]()) + else: + print(f"Unbekannte Section: {section}", file=sys.stderr) + print(f"Verfügbar: {', '.join(REPORTS.keys())}", file=sys.stderr) + sys.exit(1) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f1d577b..cdc5231 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -564,7 +564,7 @@ const App = (() => { banner.style.display = 'flex'; document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { - await API.post('/auth/resend-verification', {}); + await API.post('/auth/resend-verification', { email: state.user.email }); UI.toast.success('Bestätigungs-Mail erneut gesendet.'); }, { once: true }); diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index cd34154..1775ccd 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -14,7 +14,7 @@ window.Page_admin = (() => { { id: 'nutzer', label: 'Nutzer', icon: 'users' }, { id: 'moderation', label: 'Moderation', icon: 'shield-check' }, { id: 'zuchter', label: 'Züchter', icon: 'certificate' }, - { id: 'forum', label: 'Forum & Meldungen', icon: 'chat-circle-dots' }, + { id: 'forum', label: 'Forum', icon: 'chat-circle-dots' }, { id: 'social', label: 'Social Media', icon: 'camera' }, { id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'system', label: 'System', icon: 'gear' }, diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 7c3679d..f8488b6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -1238,6 +1238,49 @@ window.Page_settings = (() => { // ---------------------------------------------------------- // NICHT EINGELOGGT — Login / Registrierung // ---------------------------------------------------------- + function _renderVerifyPending(email) { + _container.innerHTML = ` +
    +
    + Ban Yaro +

    E-Mail bestätigen

    +
    +
    +

    + Wir haben einen Bestätigungslink an
    + ${email}
    + gesendet. +

    +

    + Bitte prüfe dein Postfach und klicke auf den Link, um dein Konto zu aktivieren. + Danach kannst du dich hier anmelden. +

    +
    + + +
    + `; + document.getElementById('verify-resend-btn2')?.addEventListener('click', async function() { + this.disabled = true; + this.textContent = 'Gesendet …'; + try { + await API.post('/auth/resend-verification', { email }); + UI.toast.success('Bestätigungs-Mail erneut gesendet.'); + } catch { + UI.toast.error('Fehler beim Senden — bitte versuche es später erneut.'); + } + }); + document.getElementById('verify-back-btn')?.addEventListener('click', () => _renderAuth('login')); + } + function _renderAuth(mode) { // Passwort-Reset über Link aus E-Mail const resetToken = sessionStorage.getItem('by_reset_token'); @@ -1467,7 +1510,16 @@ window.Page_settings = (() => { const fd = UI.formData(e.target); await UI.asyncButton(btn, async () => { - const result = await API.auth.login(fd.email, fd.password); + let result; + try { + result = await API.auth.login(fd.email, fd.password); + } catch (err) { + if (err.message === 'EMAIL_NOT_VERIFIED') { + _renderVerifyPending(fd.email); + return; + } + throw err; + } localStorage.setItem('by_token', result.token); // User-Daten laden @@ -1583,22 +1635,12 @@ window.Page_settings = (() => { const refCode = sessionStorage.getItem('by_ref_code') || ''; const finalCode = partnerCode || refCode || undefined; const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode); - localStorage.setItem('by_token', result.token); if (refCode) sessionStorage.removeItem('by_ref_code'); - _appState.user = await API.auth.me(); - document.getElementById('sidebar-username').textContent = _appState.user.name; - _appState.dogs = []; - _appState.activeDog = null; - - document.getElementById('header-login-btn')?.remove(); - const greeting = _appState.user.is_founder_pending - ? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!` - : _appState.user.is_founder - ? `Willkommen, Gründer ${_appState.user.name}! 🎉` - : `Willkommen bei Ban Yaro, ${_appState.user.name}!`; - UI.toast.success(greeting); - App.showOnboarding(); + if (result.pending_verification) { + _renderVerifyPending(fd.email); + return; + } }); }); } From 59856e61a1874cc2b96c5a5589f2c2812deddea1 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 08:50:01 +0200 Subject: [PATCH 03/63] =?UTF-8?q?Filme:=20DB-Migration,=2068=20Eintr=C3=A4?= =?UTF-8?q?ge,=20Sort=20+=20Typ-Filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - movies-Tabelle in SQLite (statt hardcoded Liste) - seed_movies(): 68 Filme/Serien/Dokus beim ersten Start - Felder: titel, originaltitel, jahr, genre, typ, hund_rasse, stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming - GET /api/movies/filme?sort=&typ= — serverseitig sortiert Sort: default | titel | jahr_desc | jahr_asc | imdb | bewertung Typ: alle | film | serie | doku - Admin-CRUD: POST/PATCH/DELETE /api/movies/filme - Frontend: Sort-Dropdown, Typ-Filter-Buttons (Filme/Serien/Dokus), Zähler, IMDb-Rating + Streaming auf der Karte - Promis ebenfalls erweitert (10 statt 6 Einträge) --- backend/database.py | 23 +- backend/main.py | 2 + backend/routes/movies.py | 363 +++++++++++++++++++++--------- backend/static/js/pages/movies.js | 75 +++++- 4 files changed, 350 insertions(+), 113 deletions(-) diff --git a/backend/database.py b/backend/database.py index 5ea9f4a..8238bca 100644 --- a/backend/database.py +++ b/backend/database.py @@ -701,7 +701,28 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC); """) - # Hunde-Filme: Bewertungen + Hund des Monats + # Hunde-Filme: Katalog + Bewertungen + Hund des Monats + conn.executescript(""" + CREATE TABLE IF NOT EXISTS movies ( + id TEXT PRIMARY KEY, + titel TEXT NOT NULL, + originaltitel TEXT, + jahr INTEGER, + genre TEXT, + typ TEXT NOT NULL DEFAULT 'film', + hund_rasse TEXT, + stirbt_der_hund INTEGER NOT NULL DEFAULT 0, + beschreibung TEXT, + bild_emoji TEXT DEFAULT '🐾', + imdb_rating REAL, + streaming TEXT, + sort_order INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_movies_typ ON movies(typ); + CREATE INDEX IF NOT EXISTS idx_movies_jahr ON movies(jahr DESC); + """) + conn.executescript(""" CREATE TABLE IF NOT EXISTS movie_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/backend/main.py b/backend/main.py index 83fa934..e7db176 100644 --- a/backend/main.py +++ b/backend/main.py @@ -46,6 +46,8 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): logger.info("Ban Yaro startet...") init_db() + from routes.movies import seed_movies + seed_movies() logger.info(f"KI-Modus: {ki.KI_MODE}") sched.start() yield diff --git a/backend/routes/movies.py b/backend/routes/movies.py index 5ef83da..399c583 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -1,140 +1,317 @@ """BAN YARO — Hunde-Filme Routes""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional from datetime import datetime from database import db -from auth import get_current_user, get_current_user_optional +from auth import get_current_user, get_current_user_optional, require_admin router = APIRouter() # ------------------------------------------------------------------ -# Hardcoded Film-Daten +# Seed-Daten — werden beim ersten Start in die DB geschrieben # ------------------------------------------------------------------ -FILME = [ - {"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2}, - {"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0}, - {"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5}, - {"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8}, - {"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3}, - {"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8}, - {"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1}, - {"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0}, - {"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5}, - {"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6}, - {"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4}, - {"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3}, +_SEED_FILME = [ + # ── Originalbestand ────────────────────────────────────────────── + {"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "imdb_rating": 7.0}, + {"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "imdb_rating": 6.4}, + {"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "imdb_rating": 7.1}, + {"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "typ": "film", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "imdb_rating": 8.1}, + {"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "imdb_rating": 7.2}, + {"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "imdb_rating": 5.9}, + {"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "imdb_rating": 7.5}, + {"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.3}, + {"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "imdb_rating": 5.7}, + {"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus.", "bild_emoji": "💣", "imdb_rating": 7.4}, + {"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "imdb_rating": 7.9}, + {"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "imdb_rating": 7.3}, + # ── Animation / Kinder ────────────────────────────────────────── + {"id": "lady-and-the-tramp", "titel": "Susi und Strolch", "originaltitel": "Lady and the Tramp", "jahr": 1955, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker mit der berühmtesten Spaghetti-Szene der Filmgeschichte.", "bild_emoji": "🍝", "imdb_rating": 7.3, "streaming": "Disney+"}, + {"id": "fox-and-the-hound", "titel": "Cap und Capper", "originaltitel": "The Fox and the Hound", "jahr": 1981, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Bloodhound", "stirbt_der_hund": False, "beschreibung": "Emotionaler Disney-Film über Freundschaft zwischen Fuchs und Jagdhund — und wie die Welt sie trennt.", "bild_emoji": "🦊", "imdb_rating": 7.2, "streaming": "Disney+"}, + {"id": "balto", "titel": "Balto", "jahr": 1995, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Husky/Wolf-Mischling", "stirbt_der_hund": False, "beschreibung": "1925 brachte Schlittenhund Balto lebensrettende Medizin nach Nome, Alaska. Basiert auf einer wahren Heldengeschichte.", "bild_emoji": "🐺", "imdb_rating": 7.1, "streaming": "Amazon Prime"}, + {"id": "bolt", "titel": "Bolt — Ein Hund für alle Fälle", "originaltitel": "Bolt", "jahr": 2008, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Ein TV-Superhund glaubt, seine Kräfte seien echt, und reist abenteuerlich quer durch Amerika.", "bild_emoji": "⚡", "imdb_rating": 6.8, "streaming": "Disney+"}, + {"id": "frankenweenie", "titel": "Frankenweenie", "jahr": 2012, "genre": "Animation/Horrorkomödie","typ": "film","hund_rasse": "Bullterrier", "stirbt_der_hund": True, "beschreibung": "Tim Burtons Stop-Motion-Meisterwerk: Ein Junge erweckt seinen toten Hund mit Wissenschaft wieder zum Leben.", "bild_emoji": "🧟", "imdb_rating": 6.9, "streaming": "Disney+"}, + {"id": "secret-life-of-pets", "titel": "Pets — Geheimes Leben der Haustiere","originaltitel": "The Secret Life of Pets","jahr": 2016,"genre": "Animation/Komödie","typ": "film","hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Was machen unsere Haustiere, wenn wir nicht zu Hause sind? Rasante Antwort mit Witz und Charme.", "bild_emoji": "🏠", "imdb_rating": 6.5, "streaming": "Amazon Prime"}, + {"id": "plague-dogs", "titel": "The Plague Dogs", "jahr": 1982, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Labrador / Mischling", "stirbt_der_hund": True, "beschreibung": "Düsterer Animationsfilm für Erwachsene: Zwei Hunde fliehen aus einem Tierversuchs-Labor. Brutal ehrlich, nach Richard Adams.", "bild_emoji": "🚫", "imdb_rating": 7.7}, + {"id": "paw-patrol-movie", "titel": "PAW Patrol: Der Kinofilm", "originaltitel": "PAW Patrol: The Movie", "jahr": 2021, "genre": "Animation/Kinder", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Die beliebten TV-Rettungshunde auf der großen Leinwand. Für die jüngsten Fans ein Pflichtprogramm.", "bild_emoji": "🚒", "imdb_rating": 6.1, "streaming": "Amazon Prime"}, + # ── Klassiker vor 1980 ────────────────────────────────────────── + {"id": "the-thin-man", "titel": "Der dünne Mann", "originaltitel": "The Thin Man", "jahr": 1934, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Drahthaariger Foxterrier", "stirbt_der_hund": False, "beschreibung": "Hollywood-Klassiker mit Nick und Nora Charles — und Asta, dem witzigsten Hund der Filmgeschichte. Mehrere Fortsetzungen.", "bild_emoji": "🍸", "imdb_rating": 7.9}, + {"id": "lassie-come-home", "titel": "Lassie kehrt heim", "originaltitel": "Lassie Come Home", "jahr": 1943, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Originalfilm: Ein armes Farmkind muss seinen geliebten Collie verkaufen — Lassie findet trotzdem heim.", "bild_emoji": "🏡", "imdb_rating": 7.1}, + {"id": "incredible-journey", "titel": "Die unglaubliche Reise", "originaltitel": "The Incredible Journey", "jahr": 1963, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Labrador", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze meistern 400 km kanadische Wildnis — Disney-Abenteuer nach dem Roman von Sheila Burnford.", "bild_emoji": "🗺️", "imdb_rating": 7.0}, + {"id": "greyfriars-bobby", "titel": "Greyfriars Bobby", "jahr": 1961, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Skye Terrier", "stirbt_der_hund": False, "beschreibung": "Basiert auf der wahren Geschichte des Terriers, der 14 Jahre das Grab seines Herrchens in Edinburgh bewachte.", "bild_emoji": "⛪", "imdb_rating": 7.2, "streaming": "Disney+"}, + {"id": "sounder", "titel": "Sounder", "jahr": 1972, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": False, "beschreibung": "Oscar-nominiertes Drama über eine schwarze Farmfamilie in der Great Depression. Ihr Hund Sounder ist das Herz der Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.5}, + {"id": "where-red-fern-grows","titel": "Wo der rote Farn wächst", "originaltitel": "Where the Red Fern Grows", "jahr": 1974, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": True, "beschreibung": "Ein Junge spart jahrelang für zwei Jagdhunde. Kultfilm der amerikanischen Kindheit — das Ende lässt kaum jemanden trocken.", "bild_emoji": "🌿", "imdb_rating": 6.9}, + {"id": "milo-and-otis", "titel": "Milo und Otis", "originaltitel": "The Adventures of Milo and Otis","jahr": 1986,"genre": "Abenteuer/Familie","typ": "film", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Japanischer Realfilm mit Katze und Hund auf großer Abenteuerreise. Für Kinder ein Klassiker, für Erwachsene nostalgisches Heimweh.", "bild_emoji": "🐾", "imdb_rating": 6.9}, + {"id": "umberto-d", "titel": "Umberto D.", "jahr": 1952, "genre": "Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Meisterwerk des italienischen Neorealismus: Ein alter Rentner und sein Hund kämpfen würdevoll gegen Armut in Rom.", "bild_emoji": "🇮🇹", "imdb_rating": 8.1, "streaming": "Mubi"}, + # ── Wahre Geschichten ─────────────────────────────────────────── + {"id": "homeward-bound", "titel": "Auf dem Weg nach Hause", "originaltitel": "Homeward Bound", "jahr": 1993, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze kämpfen sich mit Stimmen durch die amerikanische Wildnis nach Hause. Remake des Klassikers.", "bild_emoji": "🏔️", "imdb_rating": 7.0, "streaming": "Disney+"}, + {"id": "togo", "titel": "Togo", "jahr": 2019, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Sibirischer Husky", "stirbt_der_hund": False, "beschreibung": "Die unbekannte Geschichte hinter dem Balto-Mythos: Der echte Held des Serum-Runs 1925 war Togo. Außergewöhnlicher Disney+-Film.", "bild_emoji": "🛷", "imdb_rating": 7.9, "streaming": "Disney+"}, + {"id": "red-dog", "titel": "Red Dog", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Kelpie", "stirbt_der_hund": True, "beschreibung": "Australischer Kultfilm über einen echten Wanderhund, der eine Minengemeinschaft im Outback zusammenbrachte. Rauh und herzlich.", "bild_emoji": "🦘", "imdb_rating": 7.3, "streaming": "Amazon Prime"}, + {"id": "megan-leavey", "titel": "Megan Leavey", "jahr": 2017, "genre": "Biopic/Drama", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Die wahre Geschichte einer US-Marine und ihres Sprengstoff-Suchhundes Rex im Irak-Einsatz. Kate Mara in einer ihrer stärksten Rollen.", "bild_emoji": "🎖️", "imdb_rating": 7.1, "streaming": "Amazon Prime"}, + {"id": "arthur-the-king", "titel": "Arthur der König", "originaltitel": "Arthur the King", "jahr": 2024, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Mark Wahlberg und ein streunender Hund meistern gemeinsam ein Extremrennen durch die Dominikanische Republik. Inspiriert von wahren Ereignissen.","bild_emoji": "🏆", "imdb_rating": 7.0, "streaming": "Amazon Prime"}, + {"id": "rescued-by-ruby", "titel": "Gerettet von Ruby", "originaltitel": "Rescued by Ruby", "jahr": 2022, "genre": "Biopic/Familie", "typ": "film", "hund_rasse": "Australian Shepherd / Border Collie","stirbt_der_hund": False,"beschreibung": "Ein Polizist und ein Tierheim-Hund retten sich gegenseitig — wahre Geschichte aus Rhode Island.", "bild_emoji": "🌟", "imdb_rating": 7.2, "streaming": "Netflix"}, + {"id": "my-dog-skip", "titel": "Mein Hund Skip", "originaltitel": "My Dog Skip", "jahr": 2000, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": True, "beschreibung": "Die Coming-of-Age-Geschichte eines einsamen Jungen im Mississippi der 1940er, der durch seinen Hund Skip Freundschaft findet.", "bild_emoji": "📚", "imdb_rating": 7.0}, + # ── Arbeitshunde / Polizeihunde ───────────────────────────────── + {"id": "turner-and-hooch", "titel": "Turner & Hooch", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": True, "beschreibung": "Tom Hanks als ordentlicher Detective trifft auf Hooch, den sabbernden Chaoshund. Buddy-Cop-Klassiker mit überraschend emotionalem Ende.", "bild_emoji": "🕵️", "imdb_rating": 6.2, "streaming": "Disney+"}, + {"id": "k9", "titel": "K-9", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Jim Belushi als Drogenfahnder bekommt zwangsweise den eigenwilligen Schäferhund Jerry als Partner. Klassiker des Buddy-Cop-Genres.", "bild_emoji": "🚔", "imdb_rating": 6.2}, + {"id": "max", "titel": "Max", "jahr": 2015, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Ein Kriegshund aus Afghanistan wird nach dem Tod seines Handlers von dessen Familie adoptiert. Über Trauma und Vertrauen.", "bild_emoji": "🎗️", "imdb_rating": 6.6, "streaming": "Amazon Prime"}, + {"id": "dog-2022", "titel": "Dog", "jahr": 2022, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Channing Tatum fährt mit einem traumatisierten Kriegshund quer durch Amerika — roh, komisch und berührend.", "bild_emoji": "🚗", "imdb_rating": 6.5, "streaming": "Amazon Prime"}, + {"id": "quill", "titel": "Quill — Ein Führhund", "originaltitel": "Quill: The Life of a Guide Dog","jahr": 2004,"genre": "Drama/Familie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Japanischer Film über das Leben des Führhundes Quill vom Welpen bis zum Tod. Zeigt die unersetzliche Arbeit von Blindenführhunden.", "bild_emoji": "👁️", "imdb_rating": 7.1}, + # ── Komödien ──────────────────────────────────────────────────── + {"id": "beethoven-2", "titel": "Beethoven's 2nd", "jahr": 1993, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Beethoven verliebt sich und bekommt Nachwuchs — vier chaotische Welpen bringen die Familie erneut an den Rand des Nervenzusammenbruchs.", "bild_emoji": "🐶", "imdb_rating": 5.4}, + {"id": "dog-days-2018", "titel": "Dog Days", "jahr": 2018, "genre": "Komödie/Romanze", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Mehrere Angelenos und ihre Hunde, deren Leben sich charmant verflechten. Leichte Feel-Good-Komödie mit Vanessa Hudgens.", "bild_emoji": "☀️", "imdb_rating": 6.3}, + {"id": "as-good-as-it-gets", "titel": "Besser geht's nicht", "originaltitel": "As Good as It Gets", "jahr": 1997, "genre": "Komödie/Drama", "typ": "film", "hund_rasse": "Griffon Bruxellois", "stirbt_der_hund": False, "beschreibung": "Jack Nicholson als Misanthrop, der durch einen kleinen Hund namens Verdell sein Herz entdeckt. Oscar-Gewinner, zeitlos witzig.", "bild_emoji": "💊", "imdb_rating": 7.7, "streaming": "Amazon Prime"}, + {"id": "eat-pray-bark", "titel": "Eat Pray Bark", "jahr": 2026, "genre": "Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Deutsche Komödie über fünf exzentrische Hundebesitzer, die gemeinsam einen Hundetrainer in den Tiroler Bergen aufsuchen. Top 10 in 49 Netflix-Ländern.", "bild_emoji": "🏔️", "imdb_rating": None, "streaming": "Netflix"}, + {"id": "the-artist", "titel": "The Artist", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": False, "beschreibung": "Oscar-Gewinner als Stummfilm-Hommage. Uggie der Jack Russell stahl allen die Show und gewann den Palm Dog Award in Cannes.", "bild_emoji": "🎬", "imdb_rating": 7.8, "streaming": "Amazon Prime"}, + # ── Thriller / Action / Horror ────────────────────────────────── + {"id": "cujo", "titel": "Cujo", "jahr": 1983, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": True, "beschreibung": "Stephen Kings Roman verfilmt: Ein tollwütiger Bernhardiner terrorisiert eine Mutter und ihr Kind in einem Auto. Klassiker des 80er-Horror.", "bild_emoji": "🩸", "imdb_rating": 6.1, "streaming": "Amazon Prime"}, + {"id": "white-god", "titel": "White God — Hund ohne Gnade","originaltitel": "White God", "jahr": 2014, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Ungarischer Arthouse-Thriller mit 250 echten Straßenhunden. Ein Mädchen sucht seinen Hund — während die Hunde Rache nehmen. Cannes-Preis.", "bild_emoji": "🔴", "imdb_rating": 6.8}, + {"id": "dogman-2018", "titel": "Dogman", "jahr": 2018, "genre": "Krimi/Drama", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matteo Garrones preisgekrönter Film: Ein stiller Hundegroomer verstrickt sich mit einem brutalen Kriminellen. Cannes-Gewinner 2018.", "bild_emoji": "✂️", "imdb_rating": 7.2, "streaming": "Amazon Prime"}, + {"id": "call-of-the-wild", "titel": "Ruf der Wildnis", "originaltitel": "The Call of the Wild", "jahr": 2020, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Saint Bernard Mix (CGI)", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Harrison Ford: Hund Buck wandert vom Salon-Leben in die Wildnis des Klondike. Episch.", "bild_emoji": "🌲", "imdb_rating": 6.7, "streaming": "Disney+"}, + # ── Deutsche / österreichische Produktionen ───────────────────── + {"id": "lassie-neues-abenteuer","titel": "Lassie — Ein neues Abenteuer","jahr": 2023, "genre": "Familie/Abenteuer","typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Deutsche Neuinterpretation: Lassie hilft Kindern dabei, mysteriöse Hundesdiebstähle aufzudecken. Für Kinder und Familien.", "bild_emoji": "🐕", "imdb_rating": 5.6}, + # ── Neuere Serien ─────────────────────────────────────────────── + {"id": "turner-hooch-serie", "titel": "Turner & Hooch (Serie)", "originaltitel": "Turner & Hooch", "jahr": 2021, "genre": "Krimi/Komödie", "typ": "serie", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": False, "beschreibung": "Disney+-Sequel zur Filmklassik: Der Sohn des Originals detektiviert mit Hoochs Nachfolger. Nach einer Staffel abgesetzt.", "bild_emoji": "📺", "imdb_rating": 6.6, "streaming": "Disney+"}, + {"id": "healing-powers-of-dude","titel": "Dude, mein Hund", "originaltitel": "The Healing Powers of Dude", "jahr": 2020, "genre": "Komödie/Familie", "typ": "serie", "hund_rasse": "Labradoodle", "stirbt_der_hund": False, "beschreibung": "Netflix-Jugendserie: Ein Junge mit sozialer Angststörung und sein emotionaler Support-Hund meistern gemeinsam die Middle School.", "bild_emoji": "💙", "imdb_rating": 6.6, "streaming": "Netflix"}, + {"id": "hudson-rex", "titel": "Hudson & Rex", "jahr": 2019, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Kanadische Neuauflage von Kommissar Rex: Detective Hudson und sein Schäferhund Rex lösen in Neufundland Verbrechen. Läuft seit 2019.", "bild_emoji": "🍁", "imdb_rating": 7.4}, + # ── Klassische Serien ─────────────────────────────────────────── + {"id": "lassie-serie", "titel": "Lassie (TV-Serie)", "originaltitel": "Lassie", "jahr": 1954, "genre": "Familie/Abenteuer", "typ": "serie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Die legendäre CBS-Serie, die 20 Jahre lief und Generationen prägte. Lassies wöchentliche Rettungsaktionen wurden zum Inbegriff des Treue-Hundes.", "bild_emoji": "📡", "imdb_rating": 7.5}, + {"id": "rin-tin-tin-serie", "titel": "Rin Tin Tin", "originaltitel": "The Adventures of Rin Tin Tin","jahr": 1954, "genre": "Western/Familie", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Legendäre 1950er-Western-Serie. Ein Waisenjunge und sein Schäferhund helfen der US-Kavallerie im Wilden Westen.", "bild_emoji": "🤠", "imdb_rating": 7.0}, + # ── Dokumentationen ───────────────────────────────────────────── + {"id": "dogs-netflix", "titel": "Dogs", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Herzzerreißende Netflix-Dokuserie über die Bindung zwischen Hunden und Menschen weltweit. Sechs Episoden, Tränen garantiert.", "bild_emoji": "❤️", "imdb_rating": 8.0, "streaming": "Netflix"}, + {"id": "pick-of-the-litter", "titel": "Pick of the Litter", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Labrador", "stirbt_der_hund": False, "beschreibung": "Ein Labrador-Wurf wird zur Führhundausbildung bestimmt. Nicht alle schaffen es — spannend wie ein Spielfilm.", "bild_emoji": "🎗️", "imdb_rating": 7.6, "streaming": "Amazon Prime"}, + {"id": "inside-mind-of-dog", "titel": "Im Kopf des Hundes", "originaltitel": "Inside the Mind of a Dog", "jahr": 2024, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Wissenschaftsdoku: Was wissen Hunde wirklich über uns? Rob Lowe erzählt, Forscher erklären die Kognition unserer Vierbeiner.", "bild_emoji": "🧠", "imdb_rating": 7.2, "streaming": "Netflix"}, + {"id": "stray-doku", "titel": "Stray", "jahr": 2020, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Poetische Doku aus Hundeperspektive: Die Kamera folgt drei Straßenhunden durch Istanbul. Philosophisch, still und ungemein berührend.", "bild_emoji": "🕌", "imdb_rating": 6.9, "streaming": "Amazon Prime"}, + {"id": "dog-by-dog", "titel": "Dog by Dog", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über den milliardenschweren Welpen-Industrie-Komplex in den USA. Folgt dem Geldfluss hinter Puppy Mills.", "bild_emoji": "💰", "imdb_rating": 8.8, "streaming": "Netflix"}, + {"id": "gunthers-millions", "titel": "Günthers Millionen", "originaltitel": "Gunther's Millions", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Die absurde Netflix-Doku: Erbte ein Schäferhund wirklich eine halbe Milliarde Euro? Die Wahrheit ist noch seltsamer.", "bild_emoji": "💎", "imdb_rating": 5.6, "streaming": "Netflix"}, + # ── Weitere ───────────────────────────────────────────────────── + {"id": "a-dogs-purpose", "titel": "Bailey — Ein Freund fürs Leben","originaltitel": "A Dog's Purpose", "jahr": 2017, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Verschiedene (Labrador, Corgi u.a.)","stirbt_der_hund": True, "beschreibung": "Ein Hund wird mehrfach wiedergeboren und sucht in jeder Inkarnation nach seinem Sinn. Taschentücher-Pflicht.", "bild_emoji": "🔄", "imdb_rating": 7.3, "streaming": "Amazon Prime"}, + {"id": "a-dogs-journey", "titel": "Bailey 2", "originaltitel": "A Dog's Journey", "jahr": 2019, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Beagle / Bernhardiner u.a.", "stirbt_der_hund": True, "beschreibung": "Fortsetzung: Bailey beschützt in mehreren Leben die Enkelin seines Herrchens. Emotional und rührend.", "bild_emoji": "🔄", "imdb_rating": 7.5, "streaming": "Amazon Prime"}, + {"id": "art-of-racing", "titel": "Enzo und die wundersame Welt der Menschen","originaltitel": "The Art of Racing in the Rain","jahr": 2019,"genre": "Drama","typ": "film","hund_rasse": "Golden Retriever", "stirbt_der_hund": True, "beschreibung": "Ein Golden Retriever erzählt seine Lebensgeschichte. Philosophisch, witzig, herzzerreißend — Kevin Costner leiht ihm die Stimme.", "bild_emoji": "🏎️", "imdb_rating": 7.6, "streaming": "Disney+"}, + {"id": "dog-gone", "titel": "Dog Gone", "originaltitel": "Dog Gone", "jahr": 2023, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Vater und Sohn suchen auf dem Appalachian Trail ihren verlorenen kranken Hund — und finden dabei zueinander. Wahre Geschichte.", "bild_emoji": "🥾", "imdb_rating": 6.1, "streaming": "Netflix"}, + {"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7}, + {"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4}, + {"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "imdb_rating": 6.7}, ] -PROMIS = [ - {"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"}, - {"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"}, - {"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"}, - {"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"}, - {"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"}, - {"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"}, +_SEED_PROMIS = [ + {"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"}, + {"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund","bekannt_fuer": "Filmhund der 1920er. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"}, + {"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Sowjetische Weltraumpionierin.", "emoji": "🚀"}, + {"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"}, + {"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (1800–1812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"}, + {"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": "⛪"}, + {"name": "Balto", "rasse": "Siberian Husky", "bekannt_fuer": "Führte 1925 den letzten Abschnitt des Serum-Runs nach Nome, Alaska. Statue im Central Park New York.", "emoji": "🛷"}, + {"name": "Togo", "rasse": "Siberian Husky", "bekannt_fuer": "Der echte Held des Serum-Runs 1925 — legte die schwierigste Strecke zurück, blieb aber lange unbekannt.", "emoji": "🏅"}, + {"name": "Asta", "rasse": "Drahthaariger Foxterrier","bekannt_fuer": "Filmhund in der 'Dünner Mann'-Reihe (1934–1947). Hollywood-Ikone der klassischen Ära.", "emoji": "🎩"}, + {"name": "Lassie", "rasse": "Rough Collie", "bekannt_fuer": "Meistverfilmter Hund der Geschichte. Erster Vierbeiner mit einem Stern auf dem Hollywood Walk of Fame.", "emoji": "⭐"}, ] +def seed_movies(): + """Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE).""" + import logging + logger = logging.getLogger(__name__) + with db() as conn: + count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0] + if count == 0: + for i, f in enumerate(_SEED_FILME): + conn.execute(""" + INSERT OR IGNORE INTO movies + (id, titel, originaltitel, jahr, genre, typ, hund_rasse, + stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, + streaming, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + f["id"], f["titel"], f.get("originaltitel"), + f.get("jahr"), f.get("genre"), f.get("typ", "film"), + f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0, + f.get("beschreibung"), f.get("bild_emoji", "🐾"), + f.get("imdb_rating"), f.get("streaming"), i, + )) + logger.info(f"movies: {len(_SEED_FILME)} Filme geseedet.") + + # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class FilmVoteRequest(BaseModel): bewertung: int # 1–5 - class HundDesMonatsVoteRequest(BaseModel): dog_id: int +class MovieCreate(BaseModel): + id: str + titel: str + originaltitel: Optional[str] = None + jahr: Optional[int] = None + genre: Optional[str] = None + typ: str = "film" + hund_rasse: Optional[str] = None + stirbt_der_hund: bool = False + beschreibung: Optional[str] = None + bild_emoji: str = "🐾" + imdb_rating: Optional[float] = None + streaming: Optional[str] = None + +class MovieUpdate(BaseModel): + titel: Optional[str] = None + originaltitel: Optional[str] = None + jahr: Optional[int] = None + genre: Optional[str] = None + typ: Optional[str] = None + hund_rasse: Optional[str] = None + stirbt_der_hund: Optional[bool] = None + beschreibung: Optional[str] = None + bild_emoji: Optional[str] = None + imdb_rating: Optional[float] = None + streaming: Optional[str] = None + # ------------------------------------------------------------------ -# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung +# GET /api/movies/filme # ------------------------------------------------------------------ +_SORT_COLS = { + "titel": "m.titel ASC", + "jahr_desc": "m.jahr DESC", + "jahr_asc": "m.jahr ASC", + "imdb": "m.imdb_rating DESC", + "bewertung": "community_avg DESC", + "default": "m.sort_order ASC, m.jahr DESC", +} + @router.get("/filme") -async def get_filme(user=Depends(get_current_user_optional)): - user_ratings = {} - community_avgs = {} +async def get_filme( + sort: str = Query("default"), + typ: str = Query("alle"), # alle | film | serie | doku + user = Depends(get_current_user_optional), +): + order = _SORT_COLS.get(sort, _SORT_COLS["default"]) + + where = "" + params: list = [] + if typ != "alle": + where = "WHERE m.typ = ?" + params.append(typ) with db() as conn: - if user: - rows = conn.execute( - "SELECT film_id, bewertung FROM movie_votes WHERE user_id=?", - (user["id"],), - ).fetchall() - user_ratings = {r["film_id"]: r["bewertung"] for r in rows} - - avg_rows = conn.execute( - "SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id" - ).fetchall() - community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows} + rows = conn.execute(f""" + SELECT m.*, + COALESCE(AVG(v.bewertung), 0) AS community_avg, + COUNT(v.id) AS bewertung_cnt, + uv.bewertung AS user_rating + FROM movies m + LEFT JOIN movie_votes v ON v.film_id = m.id + LEFT JOIN movie_votes uv ON uv.film_id = m.id + AND uv.user_id = ? + {where} + GROUP BY m.id + ORDER BY {order} + """, [user["id"] if user else None] + params).fetchall() result = [] - for film in FILME: - f = dict(film) - f["user_rating"] = user_ratings.get(film["id"]) - if film["id"] in community_avgs: - f["bewertung_avg"] = community_avgs[film["id"]]["avg"] - f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"] - else: - f["bewertung_cnt"] = 0 - result.append(f) - + for r in rows: + d = dict(r) + d["stirbt_der_hund"] = bool(d["stirbt_der_hund"]) + d["bewertung_avg"] = round(d["community_avg"] or 0, 1) + result.append(d) return result # ------------------------------------------------------------------ -# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert) +# POST /api/movies/filme/{film_id}/vote # ------------------------------------------------------------------ @router.post("/filme/{film_id}/vote") async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)): - if not any(f["id"] == film_id for f in FILME): - raise HTTPException(404, "Film nicht gefunden.") if data.bewertung < 1 or data.bewertung > 5: raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.") - with db() as conn: - conn.execute( - """INSERT INTO movie_votes (user_id, film_id, bewertung) - VALUES (?, ?, ?) - ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""", - (user["id"], film_id, data.bewertung), - ) + if not conn.execute("SELECT 1 FROM movies WHERE id=?", (film_id,)).fetchone(): + raise HTTPException(404, "Film nicht gefunden.") + conn.execute(""" + INSERT INTO movie_votes (user_id, film_id, bewertung) + VALUES (?, ?, ?) + ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung + """, (user["id"], film_id, data.bewertung)) row = conn.execute( "SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?", (film_id,), ).fetchone() - return { - "film_id": film_id, + "film_id": film_id, "bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung, "bewertung_cnt": row["cnt"], - "user_rating": data.bewertung, + "user_rating": data.bewertung, } # ------------------------------------------------------------------ -# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats +# Admin: CRUD für Filme +# ------------------------------------------------------------------ +@router.post("/filme", status_code=201) +async def create_film(data: MovieCreate, admin=Depends(require_admin)): + with db() as conn: + max_order = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM movies").fetchone()[0] + try: + conn.execute(""" + INSERT INTO movies (id, titel, originaltitel, jahr, genre, typ, hund_rasse, + stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, (data.id, data.titel, data.originaltitel, data.jahr, data.genre, data.typ, + data.hund_rasse, 1 if data.stirbt_der_hund else 0, data.beschreibung, + data.bild_emoji, data.imdb_rating, data.streaming, max_order + 1)) + except Exception: + raise HTTPException(400, "Film-ID bereits vorhanden.") + return {"ok": True} + +@router.patch("/filme/{film_id}") +async def update_film(film_id: str, data: MovieUpdate, admin=Depends(require_admin)): + updates = {k: v for k, v in data.model_dump(exclude_none=True).items()} + if "stirbt_der_hund" in updates: + updates["stirbt_der_hund"] = 1 if updates["stirbt_der_hund"] else 0 + if not updates: + return {"ok": True} + set_clause = ", ".join(f"{k}=?" for k in updates) + with db() as conn: + conn.execute(f"UPDATE movies SET {set_clause} WHERE id=?", (*updates.values(), film_id)) + return {"ok": True} + +@router.delete("/filme/{film_id}") +async def delete_film(film_id: str, admin=Depends(require_admin)): + with db() as conn: + conn.execute("DELETE FROM movies WHERE id=?", (film_id,)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/movies/promis — Berühmte Hunde (aus Seed-Daten) +# ------------------------------------------------------------------ +@router.get("/promis") +async def get_promis(): + return _SEED_PROMIS + + +# ------------------------------------------------------------------ +# Hund des Monats # ------------------------------------------------------------------ @router.get("/hund-des-monats") async def get_hund_des_monats(user=Depends(get_current_user_optional)): monat = datetime.now().strftime("%Y-%m") - with db() as conn: - rows = conn.execute( - """SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name, - COUNT(v.id) as stimmen - FROM hund_des_monats_votes v - JOIN dogs d ON d.id = v.dog_id - JOIN users u ON u.id = d.user_id - WHERE v.monat = ? - GROUP BY v.dog_id - ORDER BY stimmen DESC - LIMIT 10""", - (monat,), - ).fetchall() - + rows = conn.execute(""" + SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name, + COUNT(v.id) as stimmen + FROM hund_des_monats_votes v + JOIN dogs d ON d.id = v.dog_id + JOIN users u ON u.id = d.user_id + WHERE v.monat = ? + GROUP BY v.dog_id + ORDER BY stimmen DESC + LIMIT 10 + """, (monat,)).fetchall() user_vote = None if user: row = conn.execute( @@ -143,43 +320,25 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)): ).fetchone() if row: user_vote = row["dog_id"] - - return { - "monat": monat, - "top": [dict(r) for r in rows], - "user_vote": user_vote, - } + return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote} -# ------------------------------------------------------------------ -# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required) -# ------------------------------------------------------------------ @router.post("/hund-des-monats/vote") async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)): monat = datetime.now().strftime("%Y-%m") - with db() as conn: - # Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist - dog = conn.execute( - "SELECT id, user_id, is_public FROM dogs WHERE id=?", - (data.dog_id,), - ).fetchone() + dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") if dog["user_id"] != user["id"] and not dog["is_public"]: raise HTTPException(403, "Dieser Hund ist nicht öffentlich.") - - conn.execute( - """INSERT INTO hund_des_monats_votes (user_id, dog_id, monat) - VALUES (?, ?, ?) - ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""", - (user["id"], data.dog_id, monat), - ) - - # Aktuelle Stimmenanzahl für den gewählten Hund + conn.execute(""" + INSERT INTO hund_des_monats_votes (user_id, dog_id, monat) + VALUES (?, ?, ?) + ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id + """, (user["id"], data.dog_id, monat)) row = conn.execute( "SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?", (data.dog_id, monat), ).fetchone() - return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]} diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js index 441a0df..5f872cb 100644 --- a/backend/static/js/pages/movies.js +++ b/backend/static/js/pages/movies.js @@ -13,6 +13,8 @@ window.Page_movies = (() => { let _filme = []; let _activeTab = 'filme'; let _filter = 'alle'; + let _typ = 'alle'; // alle | film | serie | doku + let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung // ---------------------------------------------------------- // INIT @@ -70,20 +72,44 @@ window.Page_movies = (() => { // ---------------------------------------------------------- // TAB 1: FILME // ---------------------------------------------------------- + async function _loadFilme() { + _filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`); + } + async function _renderFilme(content) { try { - _filme = await API.get('/movies/filme'); + await _loadFilme(); } catch { content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' }); return; } content.innerHTML = ` -
    - - - - +
    +
    + + + + +
    +
    + + + + +
    +
    + + + +
    `; @@ -97,6 +123,26 @@ window.Page_movies = (() => { }); }); + content.querySelectorAll('.movies-type-btn').forEach(btn => { + btn.addEventListener('click', async () => { + _typ = btn.dataset.typ; + content.querySelectorAll('.movies-type-btn').forEach(b => b.classList.remove('movies-filter-btn--active')); + btn.classList.add('movies-filter-btn--active'); + const grid = content.querySelector('#movie-grid'); + grid.innerHTML = UI.skeleton(3); + await _loadFilme(); + _renderMovieGrid(grid); + }); + }); + + content.querySelector('#movies-sort')?.addEventListener('change', async e => { + _sort = e.target.value; + const grid = content.querySelector('#movie-grid'); + grid.innerHTML = UI.skeleton(3); + await _loadFilme(); + _renderMovieGrid(grid); + }); + _renderMovieGrid(content.querySelector('#movie-grid')); } @@ -106,7 +152,10 @@ window.Page_movies = (() => { if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund); if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund); - if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0); + if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0); + + const countEl = document.getElementById('movies-count'); + if (countEl) countEl.textContent = `${list.length} Einträge`; if (list.length === 0) { grid.innerHTML = `
    Keine Filme für diesen Filter.
    `; @@ -130,18 +179,24 @@ window.Page_movies = (() => { function _movieCard(film) { const stirbt = film.stirbt_der_hund; const tag = stirbt - ? `
    ACHTUNG: Der Hund stirbt
    ` - : `
    Der Hund überlebt
    `; + ? `
    Hund stirbt
    ` + : `
    Hund überlebt
    `; const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false); + const typLabel = film.typ === 'serie' ? '📺 Serie' : film.typ === 'doku' ? '🎥 Doku' : ''; + const imdb = film.imdb_rating ? `IMDb ${film.imdb_rating}` : ''; + const streaming = film.streaming ? `${_esc(film.streaming)}` : ''; return `
    ${film.bild_emoji}
    ${_esc(film.titel)} (${film.jahr})
    -
    ${_esc(film.genre)}
    +
    + ${_esc(film.genre)}${typLabel ? `${typLabel}` : ''} +
    ${_esc(film.hund_rasse)}
    ${tag} +
    ${imdb}${streaming}
    ${stars}
    From f378edab5dec8edeea5f606e674da003b2975946 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 09:30:05 +0200 Subject: [PATCH 04/63] =?UTF-8?q?Jobs:=20Bewerbungssystem=20f=C3=BCr=20Soc?= =?UTF-8?q?ial-Media-Manager/in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - job_applications + job_application_docs Tabellen in DB - luna_trial_until Spalte in users (Migration) - routes/jobs.py: POST /apply (FormData + Datei-Upload, max 3×10MB), GET /my-application, GET /luna-trial-status - Admin: GET/PATCH /admin/applications, GET /admin/applications/{id}/docs/{doc_id} - Bei Bewerbung: 14-Tage Luna-Probezugang automatisch aktiviert - Bei Annahme: is_social_media=1 + Gründer-Status gesetzt - Status-Mails (pending/reviewing/accepted/rejected) via email_html-Template - auth.py: require_social_media prüft auch luna_trial_until Frontend: - pages/jobs.js: Stellenausschreibung + Bewerbungsformular (Name, E-Mail, Hund, Social-Handle, Motivation, Datei-Upload) - Luna-Probezugang Teaser mit Countdown wenn aktiv - Bestehende Bewerbung: Status-Screen statt Formular - app.js: 'jobs' Seite registriert - admin.js: neuer Tab 'Bewerbungen' (filtert nach Status, Statuswechsel per Dropdown, Detailansicht mit Anhang-Download, Admin-Notiz-Feld) - admin.js: Tab 'Jobs' → 'Scheduler' umbenannt --- backend/auth.py | 9 +- backend/database.py | 29 +++ backend/main.py | 2 + backend/routes/jobs.py | 318 +++++++++++++++++++++++++++++++ backend/static/js/app.js | 1 + backend/static/js/pages/admin.js | 123 +++++++++++- backend/static/js/pages/jobs.js | 260 +++++++++++++++++++++++++ 7 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 backend/routes/jobs.py create mode 100644 backend/static/js/pages/jobs.js diff --git a/backend/auth.py b/backend/auth.py index b2736f5..55c63fc 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?", (user_id,) ).fetchone() @@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)): def require_social_media(user=Depends(get_current_user)): - """Dependency: Social-Media-Manager oder Admin.""" - if not (user.get("is_social_media") or user["rolle"] == "admin"): + """Dependency: Social-Media-Manager, Luna-Probezugang oder Admin.""" + from datetime import datetime as _dt + trial = user.get("luna_trial_until") + trial_active = bool(trial and _dt.utcnow().isoformat() < trial) + if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active): raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") return user diff --git a/backend/database.py b/backend/database.py index 8238bca..8ef5362 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1582,6 +1582,35 @@ def _migrate(conn_factory): if 'from_account' not in existing_ol: conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") + # Job-Bewerbungen + Luna-Probezugang + conn.executescript(""" + CREATE TABLE IF NOT EXISTS job_applications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + dog_name TEXT, + dog_rasse TEXT, + social_handle TEXT, + motivation TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + admin_note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + reviewed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_job_apps_status ON job_applications(status, created_at DESC); + CREATE TABLE IF NOT EXISTS job_application_docs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + application_id INTEGER NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + file_path TEXT NOT NULL, + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()] + if 'luna_trial_until' not in existing_u: + conn.execute("ALTER TABLE users ADD COLUMN luna_trial_until TEXT") + # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] if 'js_exercise_id' not in existing_te: diff --git a/backend/main.py b/backend/main.py index e7db176..d85556a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -188,6 +188,7 @@ from routes.breeder_export import router as breeder_export_router from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router from routes.outreach import router as outreach_router +from routes.jobs import router as jobs_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -221,6 +222,7 @@ app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(partner_router, prefix="/api", tags=["Partner"]) app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"]) +app.include_router(jobs_router, prefix="/api/jobs", tags=["Jobs"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(import_router, prefix="/api/import", tags=["Import"]) diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py new file mode 100644 index 0000000..8714ae2 --- /dev/null +++ b/backend/routes/jobs.py @@ -0,0 +1,318 @@ +"""BAN YARO — Social-Media-Job Bewerbungs-System""" + +import os +import uuid +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from typing import Optional +from database import db +from auth import get_current_user, get_current_user_optional, require_admin +from mailer import send_email, email_html + +router = APIRouter() + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +JOBS_DIR = os.path.join(MEDIA_DIR, "jobs") +TRIAL_DAYS = 14 +MAX_FILES = 3 +MAX_FILE_MB = 10 + +os.makedirs(JOBS_DIR, exist_ok=True) + +_ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"} + + +# ------------------------------------------------------------------ +# POST /api/jobs/apply +# ------------------------------------------------------------------ +async def apply( + name: str = Form(...), + email: str = Form(...), + dog_name: str = Form(""), + dog_rasse: str = Form(""), + social_handle: str = Form(...), + motivation: str = Form(...), + files: list[UploadFile] = File(default=[]), + user = Depends(get_current_user_optional), +): + if len(motivation.strip()) < 80: + raise HTTPException(400, "Bitte schreibe etwas mehr über dich (mindestens 80 Zeichen).") + if len(files) > MAX_FILES: + raise HTTPException(400, f"Maximal {MAX_FILES} Dateien erlaubt.") + + user_id = user["id"] if user else None + + # Doppelbewerbung verhindern + if user_id: + with db() as conn: + existing = conn.execute( + "SELECT id FROM job_applications WHERE user_id=? AND status NOT IN ('rejected')", + (user_id,) + ).fetchone() + if existing: + raise HTTPException(400, "Du hast bereits eine aktive Bewerbung eingereicht.") + + with db() as conn: + cur = conn.execute(""" + INSERT INTO job_applications + (user_id, name, email, dog_name, dog_rasse, social_handle, motivation) + VALUES (?,?,?,?,?,?,?) + """, (user_id, name.strip(), email.strip(), dog_name.strip(), + dog_rasse.strip(), social_handle.strip(), motivation.strip())) + app_id = cur.lastrowid + + # Dokumente speichern + app_dir = os.path.join(JOBS_DIR, str(app_id)) + os.makedirs(app_dir, exist_ok=True) + + for f in files: + if not f.filename: + continue + ext = os.path.splitext(f.filename)[1].lower() + if ext not in _ALLOWED_EXT: + continue + size = 0 + safe_name = f"{uuid.uuid4().hex}{ext}" + dest = os.path.join(app_dir, safe_name) + with open(dest, "wb") as out: + while chunk := await f.read(65536): + size += len(chunk) + if size > MAX_FILE_MB * 1024 * 1024: + out.close() + os.remove(dest) + raise HTTPException(400, f"Datei zu groß (max. {MAX_FILE_MB} MB).") + out.write(chunk) + conn.execute(""" + INSERT INTO job_application_docs (application_id, filename, file_path) + VALUES (?,?,?) + """, (app_id, f.filename, dest)) + + # Luna-Probezugang: 14 Tage ab sofort + if user_id: + trial_until = (datetime.utcnow() + timedelta(days=TRIAL_DAYS)).isoformat() + conn.execute( + "UPDATE users SET luna_trial_until=? WHERE id=?", + (trial_until, user_id) + ) + + # Bestätigungs-Mail an Bewerber + try: + body = f""" +

    Hallo {name},

    +

    + deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen. + Wir melden uns bald bei dir! +

    + {"

    🎉 Luna-Probezugang aktiviert!
    Du hast für 14 Tage kostenlos Zugang zu Luna, unserem KI-Social-Media-Assistenten. Logge dich ein und probiere ihn aus.

    " if user_id else ""} +

    Das Ban Yaro Team

    """ + await send_email( + email, + "Deine Bewerbung bei Ban Yaro 🐾", + email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), + f"Hallo {name}, deine Bewerbung ist eingegangen!", + ) + except Exception: + pass + + # Admin benachrichtigen + try: + admin_email = os.getenv("ADMIN_EMAIL", "") + if admin_email: + admin_body = f""" +

    Neue Job-Bewerbung eingegangen:

    + + + + + + +
    Name{name}
    E-Mail{email}
    Hund{dog_name} ({dog_rasse})
    Social{social_handle}
    Anhänge{len([f for f in files if f.filename])} Datei(en)
    +

    {motivation[:300]}{"…" if len(motivation)>300 else ""}

    """ + await send_email( + admin_email, + f"[Banyaro Jobs] Neue Bewerbung — {name}", + email_html(admin_body, cta_url="https://banyaro.app/#admin", cta_label="Im Admin-Bereich prüfen"), + f"Neue Bewerbung von {name} ({email})", + ) + except Exception: + pass + + return { + "ok": True, + "application_id": app_id, + "luna_trial": user_id is not None, + "trial_days": TRIAL_DAYS, + } + + +# FastAPI braucht expliziten Router-Decorator +router.add_api_route("/apply", apply, methods=["POST"], status_code=201) + + +# ------------------------------------------------------------------ +# GET /api/jobs/my-application +# ------------------------------------------------------------------ +@router.get("/my-application") +async def my_application(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + """SELECT id, status, admin_note, created_at + FROM job_applications WHERE user_id=? + ORDER BY created_at DESC LIMIT 1""", + (user["id"],) + ).fetchone() + if not row: + return {"application": None} + return {"application": dict(row)} + + +# ------------------------------------------------------------------ +# GET /api/jobs/luna-trial-status +# ------------------------------------------------------------------ +@router.get("/luna-trial-status") +async def luna_trial_status(user=Depends(get_current_user)): + from datetime import datetime as _dt + trial = user.get("luna_trial_until") + if not trial: + return {"active": False} + remaining = (_dt.fromisoformat(trial) - _dt.utcnow()).days + return {"active": remaining > 0, "until": trial, "remaining_days": max(0, remaining)} + + +# ------------------------------------------------------------------ +# Admin: Bewerbungen verwalten +# ------------------------------------------------------------------ +@router.get("/admin/applications") +async def list_applications( + status: str = "pending", + admin = Depends(require_admin), +): + where = "" if status == "alle" else "WHERE a.status=?" + params = [] if status == "alle" else [status] + with db() as conn: + rows = conn.execute(f""" + SELECT a.*, u.name AS username, + COUNT(d.id) AS doc_count + FROM job_applications a + LEFT JOIN users u ON u.id = a.user_id + LEFT JOIN job_application_docs d ON d.application_id = a.id + {where} + GROUP BY a.id + ORDER BY a.created_at DESC + """, params).fetchall() + return [dict(r) for r in rows] + + +@router.get("/admin/applications/{app_id}") +async def get_application(app_id: int, admin=Depends(require_admin)): + with db() as conn: + row = conn.execute( + """SELECT a.*, u.name AS username, u.email AS user_email + FROM job_applications a + LEFT JOIN users u ON u.id = a.user_id + WHERE a.id=?""", + (app_id,) + ).fetchone() + if not row: + raise HTTPException(404) + docs = conn.execute( + "SELECT id, filename, uploaded_at FROM job_application_docs WHERE application_id=?", + (app_id,) + ).fetchall() + return {**dict(row), "docs": [dict(d) for d in docs]} + + +@router.patch("/admin/applications/{app_id}") +async def update_application( + app_id: int, + status: Optional[str] = None, + admin_note: Optional[str] = None, + admin = Depends(require_admin), +): + valid = {"pending", "reviewing", "accepted", "rejected"} + if status and status not in valid: + raise HTTPException(400, f"Ungültiger Status. Erlaubt: {valid}") + + with db() as conn: + row = conn.execute( + "SELECT user_id, email, name, status FROM job_applications WHERE id=?", + (app_id,) + ).fetchone() + if not row: + raise HTTPException(404) + + updates: dict = {"reviewed_at": datetime.utcnow().isoformat()} + if status: + updates["status"] = status + if admin_note is not None: + updates["admin_note"] = admin_note + + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE job_applications SET {set_clause} WHERE id=?", + (*updates.values(), app_id) + ) + + # Bei Annahme: is_social_media aktivieren + Gründer-Status + if status == "accepted" and row["user_id"]: + conn.execute( + "UPDATE users SET is_social_media=1 WHERE id=?", + (row["user_id"],) + ) + founder_count = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if founder_count < 100: + conn.execute( + "UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0", + (row["user_id"],) + ) + + # Status-Mail an Bewerber + try: + if status in ("accepted", "rejected", "reviewing"): + _send_status_mail(row["email"], row["name"], status, admin_note or "") + except Exception: + pass + + return {"ok": True} + + +@router.get("/admin/applications/{app_id}/docs/{doc_id}") +async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)): + with db() as conn: + doc = conn.execute( + "SELECT file_path, filename FROM job_application_docs WHERE id=? AND application_id=?", + (doc_id, app_id) + ).fetchone() + if not doc or not os.path.exists(doc["file_path"]): + raise HTTPException(404) + return FileResponse(doc["file_path"], filename=doc["filename"]) + + +def _send_status_mail(email: str, name: str, status: str, note: str): + import asyncio + texts = { + "reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾", + f"

    Hallo {name},

    wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

    "), + "accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉", + f"

    Hallo {name},

    wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
    Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

    "), + "rejected": ("Deine Bewerbung bei Ban Yaro", + f"

    Hallo {name},

    vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

    "), + } + subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

    Hallo {name},

    ")) + note_html = f'
    {note}
    ' if note else "" + body = body_start + note_html + + async def _send(): + await send_email(email, subj, email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), subj) + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.ensure_future(_send()) + else: + loop.run_until_complete(_send()) + except Exception: + pass diff --git a/backend/static/js/app.js b/backend/static/js/app.js index cdc5231..b75494f 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -70,6 +70,7 @@ const App = (() => { zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, 'zucht-profil': { title: 'Hunde-Profil', module: null }, gruender: { title: '100 Gründer', module: null }, + jobs: { title: 'Wir suchen dich', module: null }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 1775ccd..37d31ae 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -18,7 +18,8 @@ window.Page_admin = (() => { { id: 'social', label: 'Social Media', icon: 'camera' }, { id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'system', label: 'System', icon: 'gear' }, - { id: 'jobs', label: 'Jobs', icon: 'clock' }, + { id: 'jobs', label: 'Scheduler', icon: 'clock' }, + { id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, @@ -93,6 +94,7 @@ window.Page_admin = (() => { case 'partner': await _renderPartner(el); break; case 'outreach': await _renderOutreach(el); break; case 'audit': await _renderAudit(el); break; + case 'bewerbungen': await _renderBewerbungen(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -2375,6 +2377,125 @@ window.Page_admin = (() => { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + // ------------------------------------------------------------------ + // BEWERBUNGEN — Social-Media-Job + // ------------------------------------------------------------------ + async function _renderBewerbungen(el) { + let _statusFilter = 'pending'; + + async function _load() { + el.innerHTML = ` +
    + ${['pending','reviewing','accepted','rejected','alle'].map(s => ` + `).join('')} +
    +
    ${UI.skeleton(3)}
    `; + + el.querySelectorAll('.adm-bew-filter').forEach(btn => { + btn.addEventListener('click', () => { + _statusFilter = btn.dataset.s; + _load(); + }); + }); + + try { + const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`); + const list = el.querySelector('#adm-bew-list'); + if (!rows.length) { + list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.'); + return; + } + list.innerHTML = rows.map(r => ` +
    +
    +
    +
    ${_esc(r.name)} + ${r.username ? `(@${_esc(r.username)})` : ''} +
    +
    + ${_esc(r.email)} · @${_esc(r.social_handle||'—')} + ${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''} +
    +
    + ${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge +
    +
    + ${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''} +
    +
    +
    + + +
    +
    +
    `).join(''); + + list.querySelectorAll('.adm-bew-status').forEach(sel => { + sel.addEventListener('change', async () => { + const id = sel.dataset.id; + await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value }); + UI.toast.success('Status aktualisiert.'); + setTimeout(_load, 500); + }); + }); + + list.querySelectorAll('.adm-bew-view').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset.id; + const app = await API.get(`/jobs/admin/applications/${id}`); + const docsHtml = app.docs?.length + ? app.docs.map(d => ` + 📎 ${_esc(d.filename)}`).join('') + : 'Keine Anhänge'; + + UI.modal.open({ + title: `Bewerbung — ${_esc(app.name)}`, + body: ` +
    +
    E-Mail: ${_esc(app.email)}
    +
    Social: @${_esc(app.social_handle||'—')}
    + ${app.dog_name ? `
    Hund: ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})
    ` : ''} +
    Motivation:
    +
    ${_esc(app.motivation)}
    +
    +
    Anhänge:
    ${docsHtml}
    +
    + Admin-Notiz: + +
    +
    `, + footer: ` + + `, + }); + document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => { + const note = document.getElementById('adm-bew-note')?.value || ''; + await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note }); + UI.toast.success('Notiz gespeichert.'); + UI.modal.close(); + }); + }); + }); + } catch (e) { + el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message); + } + } + + await _load(); + } + // ------------------------------------------------------------------ return { init, refresh, onDogChange }; diff --git a/backend/static/js/pages/jobs.js b/backend/static/js/pages/jobs.js new file mode 100644 index 0000000..7ad5e68 --- /dev/null +++ b/backend/static/js/pages/jobs.js @@ -0,0 +1,260 @@ +/* ============================================================ + BAN YARO — Social-Media-Job Bewerbung + ============================================================ */ + +window.Page_jobs = (() => { + + let _container = null; + let _appState = null; + + async function init(container, appState) { + _container = container; + _appState = appState; + await _render(); + } + + async function _render() { + // Bestehende Bewerbung prüfen (nur wenn eingeloggt) + let existingApp = null; + let trialStatus = null; + if (_appState.user) { + try { + const r = await API.get('/jobs/my-application'); + existingApp = r.application; + trialStatus = await API.get('/jobs/luna-trial-status'); + } catch { /* ignorieren */ } + } + + _container.innerHTML = ` +
    + + +
    +
    🐾
    +

    + Social-Media-Manager/in gesucht +

    +

    + Werde das Gesicht von Ban Yaro auf Instagram & TikTok +

    +
    + + +
    +
    +

    Die Stelle

    +
    + ${_infoRow('📍', 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')} + ${_infoRow('📅', 'Umfang', '1–2 Posts pro Woche auf Instagram & TikTok')} + ${_infoRow('💶', 'Vergütung', '50 € / Monat — wächst mit der Community')} + ${_infoRow('🤖', 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')} + ${_infoRow('⭐', 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')} +
    +
    +
    + + +
    +
    + 🤖 Luna 14 Tage kostenlos testen +
    +

    + Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei — + unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus, + bevor du dich entscheidest. +

    + ${trialStatus?.active ? `
    + ✅ Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage
    ` : ''} +
    + + +
    +
    +

    Wen wir suchen

    +
      +
    • Du hast einen Hund — und liebst ihn sehr 🐕
    • +
    • Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)
    • +
    • Du schreibst gerne und authentisch auf Deutsch
    • +
    • Du hast Lust, eine junge App bekannt zu machen — aus Überzeugung
    • +
    • Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.
    • +
    +
    +
    + + + ${existingApp ? _renderStatus(existingApp) : _renderForm()} + +
    + `; + + if (!existingApp) { + _bindForm(); + } + } + + function _infoRow(icon, label, text) { + return ` +
    + ${icon} +
    +
    ${label}
    +
    ${text}
    +
    +
    `; + } + + function _renderStatus(app) { + const statusMap = { + pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' }, + reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' }, + accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' }, + rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' }, + }; + const s = statusMap[app.status] || statusMap.pending; + return ` +
    +
    ${s.icon}
    +
    ${s.text}
    +
    + Bewerbung eingereicht: ${app.created_at?.slice(0,10)} +
    + ${app.admin_note ? `
    ${UI.esc(app.admin_note)}
    ` : ''} +
    `; + } + + function _renderForm() { + const u = _appState.user; + return ` +
    +
    +

    + Jetzt bewerben +

    +
    + +
    + + +
    + +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    + @ + +
    +

    + Dein öffentliches Profil auf Instagram oder TikTok +

    +
    + +
    + + +

    + Mindestens 80 Zeichen +

    +
    + +
    + + +

    + Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund — max. 3 Dateien, je 10 MB. + PDF, Bild oder Video. +

    +
    + + ${!u ? `
    + 💡 Tipp: Wenn du dich vorher + anmeldest oder registrierst, + bekommst du sofort den 14-tägigen Luna-Probezugang. +
    ` : ''} + + + +
    +
    +
    `; + } + + function _bindForm() { + document.getElementById('jobs-login-link')?.addEventListener('click', e => { + e.preventDefault(); + if (window.App) App.navigate('settings'); + }); + + document.getElementById('jobs-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const fd = new FormData(e.target); + + // Dateien aus file-input übernehmen + const fileInput = document.getElementById('jobs-files'); + if (fileInput?.files?.length) { + fd.delete('files'); + for (const f of fileInput.files) fd.append('files', f); + } + + await UI.asyncButton(btn, async () => { + const resp = await fetch('/api/jobs/apply', { + method: 'POST', + body: fd, + headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` }, + credentials: 'include', + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || 'Fehler beim Absenden.'); + } + const result = await resp.json(); + + if (result.luna_trial) { + UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.'); + // User-State aktualisieren damit Luna sofort zugänglich ist + if (_appState.user && window.API) { + try { _appState.user = await API.auth.me(); } catch { /* ignore */ } + } + } else { + UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.'); + } + + await _render(); + }); + }); + } + + return { init }; +})(); From 8561cffc80be4e13a18af3af8adf58d3a8a3a89a Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 09:34:12 +0200 Subject: [PATCH 05/63] Fix: page-jobs Section in index.html + SW by-v580 --- backend/static/index.html | 4 ++++ backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 21afd73..310c6da 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -452,6 +452,10 @@
    +
    +
    +
    + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index b75494f..a179d0e 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '554'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '580'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index d3afae4..f72df43 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v577'; +const CACHE_VERSION = 'by-v580'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From d5e408528b0e2bc7b6f9d857433780d416b56910 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 09:37:51 +0200 Subject: [PATCH 06/63] =?UTF-8?q?Fix:=20UI.esc=20=E2=86=92=20UI.escape=20i?= =?UTF-8?q?n=20jobs.js,=20SW=20by-v581?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/js/app.js | 2 +- backend/static/js/pages/jobs.js | 8 +++++--- backend/static/sw.js | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a179d0e..25a949c 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '580'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '581'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/jobs.js b/backend/static/js/pages/jobs.js index 7ad5e68..c8dd539 100644 --- a/backend/static/js/pages/jobs.js +++ b/backend/static/js/pages/jobs.js @@ -7,6 +7,8 @@ window.Page_jobs = (() => { let _container = null; let _appState = null; + const _esc = s => UI.escape(s ?? ''); + async function init(container, appState) { _container = container; _appState = appState; @@ -122,7 +124,7 @@ window.Page_jobs = (() => { ${app.admin_note ? `
    ${UI.esc(app.admin_note)}
    ` : ''} + color:var(--c-text-secondary);text-align:left">${_esc(app.admin_note)}` : ''} `; } @@ -139,13 +141,13 @@ window.Page_jobs = (() => {
    + value="${u ? _esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
    + value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
    diff --git a/backend/static/sw.js b/backend/static/sw.js index f72df43..34c48e3 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v580'; +const CACHE_VERSION = 'by-v581'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From b3bd34c76aa693794dd5ae047f543dbe13cb896a Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 1 May 2026 09:44:51 +0200 Subject: [PATCH 07/63] =?UTF-8?q?UI:=20Phosphor-Icons=20f=C3=BCr=20Jobs,?= =?UTF-8?q?=20Filme,=20Admin-Bewerbungen=20+=20Jobs-Link=20in=20Entdecken,?= =?UTF-8?q?=20SW=20by-v582?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 3 +++ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 6 ++++- backend/static/js/pages/jobs.js | 39 ++++++++++++++++++------------- backend/static/js/pages/movies.js | 13 +++++++---- backend/static/sw.js | 2 +- 6 files changed, 42 insertions(+), 23 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 310c6da..7b8b567 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -169,6 +169,9 @@ + @@ -58,8 +60,9 @@ window.Page_jobs = (() => {
    -
    - 🤖 Luna 14 Tage kostenlos testen +
    + + Luna 14 Tage kostenlos testen

    Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei — @@ -68,7 +71,8 @@ window.Page_jobs = (() => {

    ${trialStatus?.active ? `
    - ✅ Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage
    ` : ''} + + Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage
    ` : ''}
    @@ -99,7 +103,7 @@ window.Page_jobs = (() => { function _infoRow(icon, label, text) { return `
    - ${icon} +
    ${icon}
    ${label}
    ${text}
    @@ -109,15 +113,17 @@ window.Page_jobs = (() => { function _renderStatus(app) { const statusMap = { - pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' }, - reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' }, - accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' }, - rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' }, + pending: { icon: 'clock', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' }, + reviewing: { icon: 'magnifying-glass', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' }, + accepted: { icon: 'check-circle', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' }, + rejected: { icon: 'x', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' }, }; const s = statusMap[app.status] || statusMap.pending; return `
    -
    ${s.icon}
    +
    + +
    ${s.text}
    Bewerbung eingereicht: ${app.created_at?.slice(0,10)} @@ -203,8 +209,9 @@ window.Page_jobs = (() => { bekommst du sofort den 14-tägigen Luna-Probezugang.
    ` : ''} - diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js index 5f872cb..65275d1 100644 --- a/backend/static/js/pages/movies.js +++ b/backend/static/js/pages/movies.js @@ -95,11 +95,11 @@ window.Page_movies = (() => {
    - - + +
    - + @@ -175,6 +178,139 @@ window.Page_forum = (() => { document.getElementById('forum-rules-btn').addEventListener('click', _showRules); } + // ---------------------------------------------------------- + // Hund des Monats — Kachel + Modal + // ---------------------------------------------------------- + async function _loadHdmCard() { + const card = document.getElementById('forum-hdm-card'); + if (!card) return; + try { + const data = await API.get('/movies/hund-des-monats'); + const [year, month] = data.monat.split('-'); + const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' }) + .format(new Date(+year, +month - 1, 1)); + const top = data.top?.[0]; + const winnerLine = top + ? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}` + : 'Noch keine Stimmen'; + const metaLine = top + ? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}` + : 'Sei der Erste!'; + + card.innerHTML = ` +
    +
    🏆
    +
    +
    Hund des Monats · ${_esc(monthName)}
    +
    ${winnerLine}
    +
    ${metaLine}
    +
    +
    ${UI.icon('arrow-right')}
    +
    `; + + document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data)); + } catch { + // Kachel bleibt leer bei Fehler + } + } + + async function _openHdmModal(data) { + // Immer frische Daten laden + try { data = await API.get('/movies/hund-des-monats'); } catch { /* nutze gecachte */ } + + const [year, month] = data.monat.split('-'); + const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) + .format(new Date(+year, +month - 1, 1)); + + let voteSection = ''; + if (_appState.user && _appState.dogs?.length > 0) { + const cards = _appState.dogs.map(dog => { + const isVoted = data.user_vote === dog.id; + const av = dog.foto_url + ? `${_esc(dog.name)}` + : `${_esc(dog.name.charAt(0).toUpperCase())}`; + return ` +
    +
    ${av}
    +
    ${_esc(dog.name)}
    + ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} + +
    `; + }).join(''); + voteSection = ` +
    +

    Für welchen deiner Hunde möchtest du abstimmen?

    +
    ${cards}
    +
    `; + } else if (!_appState.user) { + voteSection = ` +
    +

    + Anmelden + um für deinen Hund abzustimmen. +

    +
    `; + } + + const topList = data.top?.length + ? data.top.slice(0, 5).map((dog, i) => { + const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i]; + const av = dog.foto_url + ? `${_esc(dog.name)}` + : `${_esc(dog.name.charAt(0).toUpperCase())}`; + const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; + return ` +
    + ${medal} +
    ${av}
    +
    +
    ${_esc(dog.name)}
    + ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} + ${vorname ? `
    von ${vorname}
    ` : ''} +
    +
    ${dog.stimmen} ${UI.icon('star')}
    +
    `; + }).join('') + : `

    Noch keine Stimmen diesen Monat. Sei der Erste!

    `; + + const body = ` +
    +
    🏆
    +

    Hund des Monats

    +
    ${_esc(monthName)}
    +
    + ${voteSection} +
    +

    Top 5 diesen Monat

    +
    ${topList}
    +
    `; + + UI.modal.open({ title: '🏆 Hund des Monats', body, + footer: `` }); + + document.getElementById('hdm-login-link')?.addEventListener('click', e => { + e.preventDefault(); UI.modal.close(); App.navigate('settings'); + }); + + document.querySelectorAll('.hdm-vote-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const dogId = parseInt(btn.dataset.dogId); + await UI.asyncButton(btn, async () => { + try { + await API.post('/movies/hund-des-monats/vote', { dog_id: dogId }); + UI.toast.success('Stimme abgegeben!'); + UI.modal.close(); + _loadHdmCard(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Abstimmen.'); + } + }); + }); + }); + } + // ---------------------------------------------------------- // Threads laden // ---------------------------------------------------------- diff --git a/backend/static/js/pages/movies.js b/backend/static/js/pages/movies.js index 552928d..a36cf73 100644 --- a/backend/static/js/pages/movies.js +++ b/backend/static/js/pages/movies.js @@ -15,6 +15,7 @@ window.Page_movies = (() => { let _filter = 'alle'; let _typ = 'alle'; // alle | film | serie | doku let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung + let _search = ''; // ---------------------------------------------------------- // INIT @@ -41,7 +42,6 @@ window.Page_movies = (() => {
    -
    `; @@ -66,7 +66,6 @@ window.Page_movies = (() => { if (_activeTab === 'filme') await _renderFilme(content); if (_activeTab === 'promis') _renderPromis(content); - if (_activeTab === 'hdm') await _renderHundDesMonats(content); } // ---------------------------------------------------------- @@ -86,6 +85,11 @@ window.Page_movies = (() => { content.innerHTML = `
    +
    + + +
    @@ -135,6 +139,11 @@ window.Page_movies = (() => { }); }); + content.querySelector('#movies-search')?.addEventListener('input', e => { + _search = e.target.value.trim().toLowerCase(); + _renderMovieGrid(content.querySelector('#movie-grid')); + }); + content.querySelector('#movies-sort')?.addEventListener('change', async e => { _sort = e.target.value; const grid = content.querySelector('#movie-grid'); @@ -153,6 +162,14 @@ window.Page_movies = (() => { if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund); if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund); if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0); + if (_search) { + list = list.filter(f => + (f.titel || '').toLowerCase().includes(_search) || + (f.hund_rasse || '').toLowerCase().includes(_search) || + (f.genre || '').toLowerCase().includes(_search) || + (f.beschreibung || '').toLowerCase().includes(_search) + ); + } const countEl = document.getElementById('movies-count'); if (countEl) countEl.textContent = `${list.length} Einträge`; diff --git a/backend/static/sw.js b/backend/static/sw.js index 4ff9fe1..a727200 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v591'; +const CACHE_VERSION = 'by-v594'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 07a888bbd88b2271d936ee5faa15af07ed740ed7 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 08:25:51 +0200 Subject: [PATCH 27/63] =?UTF-8?q?Feature:=20Filme-DB=20auf=20126=20Eintr?= =?UTF-8?q?=C3=A4ge=20aufgeblasen=20(+58=20neue),=20Seed=20immer=20aktiv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 58 neue Filme/Serien/Dokus: Bluey, Best in Show, Ginga Gin, All Dogs Go to Heaven, Oliver & Co., Cats & Dogs, White Dog, The Dog House, Supervet, The Champions, Alpha u.v.m. - seed_movies() läuft jetzt immer (INSERT OR IGNORE), nicht nur wenn DB leer --- backend/routes/movies.py | 99 ++++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/backend/routes/movies.py b/backend/routes/movies.py index 399c583..d8684a7 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -94,6 +94,71 @@ _SEED_FILME = [ {"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7}, {"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4}, {"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "imdb_rating": 6.7}, + # ── Neue Einträge: Animation ────────────────────────────────────── + {"id": "all-dogs-go-to-heaven", "titel": "Alle Hunde kommen in den Himmel", "originaltitel": "All Dogs Go to Heaven", "jahr": 1989, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": True, "beschreibung": "Don Bluth-Klassiker: Gauner-Hund Charlie entkommt dem Himmel und sucht Rache — bis er sich in ein Waisenmädchen verliebt.", "bild_emoji": "😇", "imdb_rating": 6.8}, + {"id": "all-dogs-go-heaven-2", "titel": "Alle Hunde kommen in den Himmel 2", "originaltitel": "All Dogs Go to Heaven 2", "jahr": 1996, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Charlie kehrt aus dem Himmel zurück nach San Francisco — mit Charlie Sheen als Synchronstimme.", "bild_emoji": "😇", "imdb_rating": 5.4}, + {"id": "oliver-and-company", "titel": "Oliver & Co.", "originaltitel": "Oliver & Company", "jahr": 1988, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Verschiedene (Dodger = Mischling)", "stirbt_der_hund": False, "beschreibung": "Disney modernisiert Oliver Twist: Ein obdachloser Kater schließt sich einer Hunde-Gang unter Anführer Dodger im New York der 1980er an.", "bild_emoji": "🎸", "imdb_rating": 6.7, "streaming": "Disney+"}, + {"id": "peanuts-movie", "titel": "Die Peanuts — Der Film", "originaltitel": "The Peanuts Movie", "jahr": 2015, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Snoopy und Charlie Brown auf der großen Leinwand. Perfekt für alle, die mit dem Kultcomic aufgewachsen sind.", "bild_emoji": "✈️", "imdb_rating": 7.0, "streaming": "Disney+"}, + {"id": "clifford-big-red-dog", "titel": "Clifford — Der große rote Hund", "originaltitel": "Clifford the Big Red Dog", "jahr": 2021, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Mischling (riesig)", "stirbt_der_hund": False, "beschreibung": "Der riesige rote Hund aus dem Kinderbuchklassiker kommt ins Kino — Chaos und Herz für die ganze Familie.", "bild_emoji": "🔴", "imdb_rating": 5.4, "streaming": "Amazon Prime"}, + {"id": "101-dalmatians-1996", "titel": "101 Dalmatiner (1996)", "originaltitel": "101 Dalmatians", "jahr": 1996, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Glenn Close als unvergessliche Cruella de Vil im Live-Action-Remake des Disney-Klassikers — böse, schrill und absolut unterhaltsam.", "bild_emoji": "🐾", "imdb_rating": 5.7, "streaming": "Disney+"}, + {"id": "102-dalmatians", "titel": "102 Dalmatiner", "originaltitel": "102 Dalmatians", "jahr": 2000, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Cruella ist scheinbar geläutert — aber die Dalmatiner-Welpen sind wieder in Gefahr. Fortsetzung mit Glenn Close.", "bild_emoji": "🐾", "imdb_rating": 4.9, "streaming": "Disney+"}, + {"id": "lady-and-tramp-2019", "titel": "Susi und Strolch (2019)", "originaltitel": "Lady and the Tramp", "jahr": 2019, "genre": "Familie/Romanze", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney+ Live-Action-Remake mit echten Hunden: Die ikonische Spaghetti-Szene neu inszeniert, herzlich und mit modernem Charme.", "bild_emoji": "🍝", "imdb_rating": 6.4, "streaming": "Disney+"}, + {"id": "my-dog-tulip", "titel": "Mein Hund Tulip", "originaltitel": "My Dog Tulip", "jahr": 2009, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": True, "beschreibung": "Animierter Arthouse-Film nach J.R. Ackerley: Ein eigenbrötlerischer Engländer und die vorbehaltlose Liebe zu seiner Schäferhündin Tulip.", "bild_emoji": "🌷", "imdb_rating": 7.0}, + {"id": "bluey", "titel": "Bluey", "jahr": 2018, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Blue Heeler", "stirbt_der_hund": False, "beschreibung": "Australische Kinder-Animationsserie über eine Blue-Heeler-Familie, die weltweit Eltern und Kinder gleichermaßen begeistert. Meistgeliebte Kinderserie des 21. Jahrhunderts.", "bild_emoji": "💙", "imdb_rating": 9.0, "streaming": "Disney+"}, + {"id": "strays-2023", "titel": "Strays — Lass uns Hunde sein", "originaltitel": "Strays", "jahr": 2023, "genre": "Animation/Komödie", "typ": "film", "hund_rasse": "Mischling / Australian Shepherd", "stirbt_der_hund": False, "beschreibung": "Komplett obszöne Erwachsenen-Trickfilm-Komödie: Verlassener Hund will mit neuen Hundefreunden Rache am Herrchen nehmen. Nicht für Kinder!","bild_emoji": "🤬", "imdb_rating": 5.8, "streaming": "Amazon Prime"}, + # ── Neue Einträge: Familie/Drama ────────────────────────────────── + {"id": "hotel-for-dogs", "titel": "Hotel für Hunde", "originaltitel": "Hotel for Dogs", "jahr": 2009, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Zwei Geschwister verwandeln ein leerstehendes Hotel in ein geheimes Paradies für Straßenhunde. Herzerwärmende Familienunterhaltung.", "bild_emoji": "🏨", "imdb_rating": 5.8}, + {"id": "snow-dogs", "titel": "Snowdogs", "originaltitel": "Snow Dogs", "jahr": 2002, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Siberian Husky", "stirbt_der_hund": False, "beschreibung": "Cuba Gooding Jr. erbt eine Schlittenhunde-Meute in Alaska und muss erst lernen, mit ihnen umzugehen. Leichte Disney-Komödie.", "bild_emoji": "🛷", "imdb_rating": 4.2, "streaming": "Disney+"}, + {"id": "a-dogs-way-home", "titel": "Auf dem Heimweg — Lassie und Ich", "originaltitel": "A Dog's Way Home", "jahr": 2019, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Pit-Bull-Mischling", "stirbt_der_hund": False, "beschreibung": "Hündin Bella verliert sich 400 Meilen von zu Hause entfernt und kämpft sich durch alle Widrigkeiten zurück zu ihrem Herrchen.", "bild_emoji": "🏡", "imdb_rating": 6.7, "streaming": "Netflix"}, + {"id": "fluke", "titel": "Fluke — Das fremde Ich", "originaltitel": "Fluke", "jahr": 1995, "genre": "Drama/Fantasy", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Mann stirbt und wird als Hund wiedergeboren — und kehrt zu seiner Familie zurück. Ungewöhnliches Dramafantasy mit tiefem emotionalem Kern.", "bild_emoji": "🔄", "imdb_rating": 6.2}, + {"id": "zeus-and-roxanne", "titel": "Zeus und Roxanne", "originaltitel": "Zeus and Roxanne", "jahr": 1997, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Hund und ein Delfin werden beste Freunde — und bringen dabei auch ihre Besitzer zusammen. Charmante Familienunterhaltung der 90er.", "bild_emoji": "🐬", "imdb_rating": 5.2}, + {"id": "benji-2018", "titel": "Benji (2018)", "originaltitel": "Benji", "jahr": 2018, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Netflix-Remake des Klassikers: Der streunende Hund Benji rettet erneut Kinder aus gefährlichen Händen — jetzt für eine neue Generation.", "bild_emoji": "🐾", "imdb_rating": 6.3, "streaming": "Netflix"}, + {"id": "ugly-dachshund", "titel": "Der hässliche Dackel", "originaltitel": "The Ugly Dachshund", "jahr": 1966, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutscher Dogge / Dackel", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker: Eine Harlekindogge wird von Dackeln aufgezogen und denkt, sie sei selbst ein Dackel. Harmlose Familienkomödie.", "bild_emoji": "😅", "imdb_rating": 6.4}, + {"id": "shiloh", "titel": "Shiloh — Mein treuer Freund", "originaltitel": "Shiloh", "jahr": 1996, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Junge in West Virginia rettet einen misshandelten Beagle vor seinem brutalen Besitzer — eine Geschichte über Mut und Gewissen.", "bild_emoji": "🌿", "imdb_rating": 6.7}, + {"id": "iron-will", "titel": "Iron Will", "jahr": 1994, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": False, "beschreibung": "Junger Mann rettet die Farm seiner Familie mit einem gewagten Schlittenhunde-Rennen von Kanada nach Minnesota. Inspirierend und episch.", "bild_emoji": "🏆", "imdb_rating": 6.7, "streaming": "Disney+"}, + {"id": "belle-et-sebastien", "titel": "Belle und Sébastien", "originaltitel": "Belle et Sébastien", "jahr": 2013, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Französischer Familienfilm: Ein Waisenjunge und die riesige Berghündin Belle sind beste Freunde in den Alpen des Zweiten Weltkriegs.", "bild_emoji": "🏔️", "imdb_rating": 7.0}, + {"id": "dog-of-flanders", "titel": "Ein Hund von Flandern", "originaltitel": "A Dog of Flanders", "jahr": 1999, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Bouvier des Flandres", "stirbt_der_hund": True, "beschreibung": "Bewegende Verfilmung des Klassikers: Ein armer Junge in Belgien und sein Hund träumen von Kunst und Würde — mit tragischem Ende.", "bild_emoji": "🎨", "imdb_rating": 7.0}, + {"id": "underdog-2007", "titel": "Underdog — Ein Held auf vier Pfoten", "originaltitel": "Underdog", "jahr": 2007, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Laborhund erhält Superkräfte und wird zum maskierten Superhelden der Stadt. Leichte Disney-Familienkomödie nach der Zeichentrickserie.", "bild_emoji": "🦸", "imdb_rating": 5.1}, + {"id": "bingo-1991", "titel": "Bingo", "jahr": 1991, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Zirkushund reist quer durch Amerika, um seinen jungen Herrchen zu finden. Kindheitserinnerung der frühen 90er.", "bild_emoji": "🎪", "imdb_rating": 5.8}, + {"id": "goodbye-my-lady", "titel": "Leb wohl, Lady", "originaltitel": "Goodbye, My Lady", "jahr": 1956, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Basenji", "stirbt_der_hund": False, "beschreibung": "Ein Junge im Mississippi-Sumpfland findet einen seltsamen lachenden Hund — und muss ihn am Ende zurückgeben. Zeitloser Klassiker.", "bild_emoji": "🌊", "imdb_rating": 7.1}, + {"id": "mitt-liv-som-hund", "titel": "Mein Leben als Hund", "originaltitel": "Mitt liv som hund", "jahr": 1985, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Lasse Hallströms schwedisches Meisterwerk: Ein Junge wird aufs Land geschickt und vergleicht sein Leben mit dem Schicksal von Laika.", "bild_emoji": "🇸🇪", "imdb_rating": 7.8}, + {"id": "homeward-bound-2", "titel": "Auf dem Weg nach Hause 2 — Im Großstadtdschungel", "originaltitel": "Homeward Bound II: Lost in San Francisco", "jahr": 1996, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Die Tiere aus Teil 1 verirren sich in San Francisco und müssen erneut den Weg nach Hause finden — diesmal durch die Stadt.", "bild_emoji": "🌉", "imdb_rating": 6.0, "streaming": "Disney+"}, + {"id": "alpha-2018", "titel": "Alpha", "jahr": 2018, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Wolf", "stirbt_der_hund": False, "beschreibung": "Vor 20.000 Jahren: Ein junger Jäger freundet sich mit einem verletzten Wolf an und legt damit den Grundstein für die Mensch-Hund-Beziehung.", "bild_emoji": "🐺", "imdb_rating": 6.7, "streaming": "Amazon Prime"}, + {"id": "nankyoku-monogatari", "titel": "Antarktis", "originaltitel": "Nankyoku Monogatari", "jahr": 1983, "genre": "Drama/Abenteuer", "typ": "film", "hund_rasse": "Sakhalin Husky", "stirbt_der_hund": True, "beschreibung": "Japanisches Meisterwerk: 15 Schlittenhunde werden 1958 in der Antarktis zurückgelassen — die wahre Geschichte zweier Überlebender.", "bild_emoji": "🇯🇵", "imdb_rating": 7.7}, + # ── Neue Einträge: Komödie ──────────────────────────────────────── + {"id": "cats-and-dogs", "titel": "Cats & Dogs", "jahr": 2001, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle / Verschiedene", "stirbt_der_hund": False, "beschreibung": "Geheimdienstagenten auf vier Pfoten: Hunde gegen Katzen im Kampf um die Weltherrschaft. Spionagefilm-Parodie für die ganze Familie.", "bild_emoji": "🐱", "imdb_rating": 5.2}, + {"id": "cats-and-dogs-2", "titel": "Cats & Dogs 2 — Die Rache der Kitty Kahlohr", "originaltitel": "Cats & Dogs: The Revenge of Kitty Galore", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Hunde und Katzen müssen kooperieren, um eine wahnsinnige Superschurkin-Katze zu stoppen.", "bild_emoji": "😾", "imdb_rating": 4.1}, + {"id": "shaggy-dog-1959", "titel": "Der zottige Hund", "originaltitel": "The Shaggy Dog", "jahr": 1959, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Alter Ungarischer Hirtenhund (Sheepdogähnlich)", "stirbt_der_hund": False, "beschreibung": "Disney-Familienklassiker: Ein Teenager verwandelt sich durch einen magischen Ring immer wieder in einen Hund. Mit Fred MacMurray.", "bild_emoji": "✨", "imdb_rating": 6.3}, + {"id": "shaggy-dog-2006", "titel": "The Shaggy Dog (2006)", "originaltitel": "The Shaggy Dog", "jahr": 2006, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bobtail (Alter Englischer Schäferhund)", "stirbt_der_hund": False, "beschreibung": "Tim Allen als Staatsanwalt, der sich in einen Hund verwandelt — modernes Remake des Disney-Klassikers mit Slapstick-Humor.", "bild_emoji": "✨", "imdb_rating": 5.2, "streaming": "Disney+"}, + {"id": "marmaduke-2010", "titel": "Marmaduke", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutsche Dogge", "stirbt_der_hund": False, "beschreibung": "Die riesige Comic-Dogge Marmaduke zieht nach Kalifornien und mischt die dortige Hunde-Gesellschaft auf. Lockerleichte Familienkomödie.", "bild_emoji": "😬", "imdb_rating": 4.6}, + {"id": "show-dogs", "titel": "Show Dogs", "jahr": 2018, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Rottweiler", "stirbt_der_hund": False, "beschreibung": "Ein Polizei-Rottweiler muss undercover bei einer Hundeschau ermitteln. Kindliche Spionagekomödie mit Will Arnett.", "bild_emoji": "🏅", "imdb_rating": 4.3}, + {"id": "must-love-dogs", "titel": "Must Love Dogs", "jahr": 2005, "genre": "Romanze/Komödie", "typ": "film", "hund_rasse": "Neufundländer", "stirbt_der_hund": False, "beschreibung": "Romantische Komödie: Eine frisch Geschiedene sucht online die Liebe — und ein Hund spielt dabei die entscheidende Rolle. Mit Diane Lane.", "bild_emoji": "💕", "imdb_rating": 6.0}, + {"id": "best-in-show", "titel": "Best in Show", "jahr": 2000, "genre": "Komödie/Mockumentary", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Christopher Guest's genialer Mockumentary über völlig überdrehte Hundeshow-Besucher beim Mayflower Dog Show — vernichtende Satire auf Hundebesitzer.", "bild_emoji": "🎭", "imdb_rating": 7.8}, + {"id": "wiener-dog", "titel": "Wiener-Dog", "jahr": 2016, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Dackel", "stirbt_der_hund": True, "beschreibung": "Todd Solondz' episodischer Indie-Film: Ein kleiner Dackel wandert durch mehrere skurrile Menschenleben. Dunkel, philosophisch, preisgekrönt.", "bild_emoji": "🌭", "imdb_rating": 6.6}, + # ── Neue Einträge: Action/Thriller ─────────────────────────────── + {"id": "mans-best-friend-1993", "titel": "Man's Best Friend", "originaltitel": "Man's Best Friend", "jahr": 1993, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Mastiff", "stirbt_der_hund": True, "beschreibung": "Ein genetisch manipulierter Kettenhund bricht aus einem Labor aus und entpuppt sich als tödliche Bedrohung. B-Movie-Horrorklassiker.", "bild_emoji": "🧬", "imdb_rating": 5.1}, + {"id": "white-dog-1982", "titel": "White Dog", "originaltitel": "White Dog", "jahr": 1982, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Samuel Fullers politisch brisanter Film: Ein weißer Schäferhund wurde auf schwarze Menschen abgerichtet — und soll nun umtrainiert werden.", "bild_emoji": "⚪", "imdb_rating": 7.2}, + {"id": "hound-of-baskervilles", "titel": "Der Hund von Baskerville", "originaltitel": "The Hound of the Baskervilles", "jahr": 1939, "genre": "Krimi/Thriller", "typ": "film", "hund_rasse": "Fantastisches Wesen", "stirbt_der_hund": False, "beschreibung": "Basil Rathbone als Sherlock Holmes in der besten Verfilmung des Doyle-Klassikers — die unheilvolle Legende des Dartmoor-Hundes.", "bild_emoji": "🔦", "imdb_rating": 7.4}, + # ── Neue Einträge: Japan/International ─────────────────────────── + {"id": "mari-to-koinu", "titel": "Mari und ihr Hundewelpe", "originaltitel": "Mari to koinu no monogatari", "jahr": 2007, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Shiba Inu", "stirbt_der_hund": False, "beschreibung": "Wahre Geschichte: Eine Shiba-Inu-Mutter rettet nach einem Erdbeben in den japanischen Alpen ihr Herrchen und ihre Welpen.", "bild_emoji": "🏔️", "imdb_rating": 7.2}, + {"id": "ginga-nagareboshi-gin", "titel": "Ginga: Nagareboshi Gin", "originaltitel": "Ginga: Nagareboshi Gin", "jahr": 1986, "genre": "Anime/Abenteuer", "typ": "serie", "hund_rasse": "Akita Inu", "stirbt_der_hund": False, "beschreibung": "Legendärer japanischer Anime: Silber, ein junger Akita, kämpft gegen einen gigantischen Bären — packende Shonen-Klassikerserie der 80er.", "bild_emoji": "⭐", "imdb_rating": 8.0}, + # ── Neue Einträge: Serien ────────────────────────────────────────── + {"id": "dog-whisperer", "titel": "Der Hundeflüsterer", "originaltitel": "Dog Whisperer with Cesar Millan", "jahr": 2004, "genre": "Dokumentation/Reality", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Cesar Millan rehabilitiert Problemhunde und schult deren Besitzer. Legendäre Reality-Serie, die das Hundetraining nachhaltig beeinflusst hat.", "bild_emoji": "🤫", "imdb_rating": 7.8}, + {"id": "its-me-or-the-dog", "titel": "Ich oder der Hund", "originaltitel": "It's Me or the Dog", "jahr": 2005, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Victoria Stilwell bringt unerzogenen Hunden Manieren bei — mit Positiver Verstärkung statt Dominanztheorie. Britische Erfolgsrealityshow.", "bild_emoji": "💪", "imdb_rating": 7.2}, + {"id": "lucky-dog", "titel": "Lucky Dog", "originaltitel": "Lucky Dog", "jahr": 2013, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Trainer Brandon McMillan rettet Todestrakt-Hunde aus Tierheimen und trainiert sie innerhalb einer Woche als perfekte Familienhunde.", "bild_emoji": "🌟", "imdb_rating": 7.5}, + {"id": "dog-impossible", "titel": "Dog: Impossible", "originaltitel": "Dog: Impossible", "jahr": 2019, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matt Beisner arbeitet mit aggressiven und traumatisierten Hunden, die andere aufgegeben haben. National Geographic's bewegende Trainerserie.", "bild_emoji": "🙏", "imdb_rating": 8.1}, + {"id": "belle-sebastian-anime", "titel": "Belle und Sebastian (Anime)", "originaltitel": "Belle et Sébastien", "jahr": 1984, "genre": "Anime/Familie", "typ": "serie", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Japanische Zeichentrickserie der NHK: Sébastien und sein riesiger weißer Hund Belle in den Alpen — eine Lieblingsserie mehrerer Generationen.", "bild_emoji": "⛰️", "imdb_rating": 7.5}, + {"id": "the-dog-house", "titel": "The Dog House", "originaltitel": "The Dog House", "jahr": 2019, "genre": "Dokumentation", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Wood Green Animal Shelter: Zuschauer beobachten, wie Hunde und Menschen füreinander bestimmt werden. Britische Kultdoku.", "bild_emoji": "🏡", "imdb_rating": 8.2}, + {"id": "puppy-dog-pals", "titel": "Puppy Dog Pals", "originaltitel": "Puppy Dog Pals", "jahr": 2017, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Disney Junior-Serie: Zwei Möpse erleben täglich Abenteuer in der Nachbarschaft, während ihr Herrchen weg ist. Ideal für Kleinkinder.", "bild_emoji": "🐾", "imdb_rating": 7.4, "streaming": "Disney+"}, + {"id": "dogs-of-berlin", "titel": "Dogs of Berlin", "jahr": 2018, "genre": "Krimi/Drama", "typ": "serie", "hund_rasse": "Kampfhund", "stirbt_der_hund": False, "beschreibung": "Netflix Deutschland-Originalserie: Zwei Berliner Ermittler aus verschiedenen Welten jagen einen Mörder — düster, social, brutal ehrlich.", "bild_emoji": "🐕", "imdb_rating": 7.4, "streaming": "Netflix"}, + # ── Neue Einträge: Dokumentationen ──────────────────────────────── + {"id": "the-champions-2015", "titel": "The Champions", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Bewegende Doku über die Pitbulls von Michael Vick's Dogfighting-Ring — und ihre erstaunliche Rehabilitation durch engagierte Retter.", "bild_emoji": "💪", "imdb_rating": 7.8}, + {"id": "one-nation-under-dog", "titel": "One Nation Under Dog", "originaltitel": "One Nation Under Dog", "jahr": 2012, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "HBO-Dokumentation über die komplexe Beziehung der Amerikaner zu Hunden — von Tierheimen bis Luxus-Hundesalons.", "bild_emoji": "🇺🇸", "imdb_rating": 7.4}, + {"id": "dogs-on-the-inside", "titel": "Dogs on the Inside", "originaltitel": "Dogs on the Inside", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Gefangene in einem Hochsicherheitsgefängnis trainieren Tierheim-Hunde — eine Geschichte über Mitgefühl, Verantwortung und zweite Chancen.", "bild_emoji": "🔒", "imdb_rating": 7.6}, + {"id": "wonderdog-2023", "titel": "Wonderdog", "originaltitel": "Wonderdog", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Doku über die außergewöhnlichen Fähigkeiten von Hunden: Was können sie wirklich riechen, hören und fühlen? Wissenschaft trifft Herz.", "bild_emoji": "🔬", "imdb_rating": 7.1, "streaming": "Netflix"}, + {"id": "the-supervet", "titel": "The Supervet", "originaltitel": "The Supervet", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Noel Fitzpatrick, den visionären Veterinärchirurgen, der unheilbar verletzte Tiere mit Hightech-Prothesen rettet.", "bild_emoji": "🦾", "imdb_rating": 8.7}, + {"id": "off-the-chain", "titel": "Off the Chain", "originaltitel": "Off the Chain", "jahr": 2004, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über Pit Bulls in Amerika — zwischen Missbrauch, Dogfighting-Kultur und den Menschen, die für sie kämpfen.", "bild_emoji": "⛓️", "imdb_rating": 7.2}, + {"id": "war-dog-soldier", "titel": "War Dog: A Soldier's Best Friend", "originaltitel": "War Dog: A Soldier's Best Friend", "jahr": 2017, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "National Geographic-Doku über Militärhunde und die unzerstörbare Bindung zu ihren Hundeführern — von der Ausbildung bis zum Einsatz.", "bild_emoji": "🎖️", "imdb_rating": 7.8}, ] _SEED_PROMIS = [ @@ -111,27 +176,25 @@ _SEED_PROMIS = [ def seed_movies(): - """Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE).""" + """Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE).""" import logging logger = logging.getLogger(__name__) with db() as conn: - count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0] - if count == 0: - for i, f in enumerate(_SEED_FILME): - conn.execute(""" - INSERT OR IGNORE INTO movies - (id, titel, originaltitel, jahr, genre, typ, hund_rasse, - stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, - streaming, sort_order) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) - """, ( - f["id"], f["titel"], f.get("originaltitel"), - f.get("jahr"), f.get("genre"), f.get("typ", "film"), - f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0, - f.get("beschreibung"), f.get("bild_emoji", "🐾"), - f.get("imdb_rating"), f.get("streaming"), i, - )) - logger.info(f"movies: {len(_SEED_FILME)} Filme geseedet.") + for i, f in enumerate(_SEED_FILME): + conn.execute(""" + INSERT OR IGNORE INTO movies + (id, titel, originaltitel, jahr, genre, typ, hund_rasse, + stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, + streaming, sort_order) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + f["id"], f["titel"], f.get("originaltitel"), + f.get("jahr"), f.get("genre"), f.get("typ", "film"), + f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0, + f.get("beschreibung"), f.get("bild_emoji", "🐾"), + f.get("imdb_rating"), f.get("streaming"), i, + )) + logger.info(f"movies: seed_movies() ausgeführt, {len(_SEED_FILME)} Einträge in der Liste.") # ------------------------------------------------------------------ From 7474e1003195b2806dc8751671046153ce1f948d Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 08:28:47 +0200 Subject: [PATCH 28/63] =?UTF-8?q?Fix:=20Filme-Standardsortierung=20?= =?UTF-8?q?=E2=86=92=20IMDb=20DESC,=20Erscheinungsjahr=20DESC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/movies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/movies.py b/backend/routes/movies.py index d8684a7..e4f306e 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -243,7 +243,7 @@ _SORT_COLS = { "jahr_asc": "m.jahr ASC", "imdb": "m.imdb_rating DESC", "bewertung": "community_avg DESC", - "default": "m.sort_order ASC, m.jahr DESC", + "default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC", } @router.get("/filme") From d1572c52bcc7c5056c816fc08823f8795ffc505e Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 08:35:49 +0200 Subject: [PATCH 29/63] =?UTF-8?q?Feature:=20Forum=20Tab-Pills=20=E2=80=94?= =?UTF-8?q?=20Marquee-Scroll=20bei=20Hover=20f=C3=BCr=20abgeschnittenen=20?= =?UTF-8?q?Text,=20SW=20by-v595?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 13 +++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/forum.js | 19 +++++++++++++++++-- backend/static/sw.js | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 65e9739..5264274 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -4179,6 +4179,19 @@ html.modal-open { text-overflow: ellipsis; max-width: 10rem; /* prevents single pill from being wider than ~160px on mobile */ } +.by-tab-text { + display: inline-block; + white-space: nowrap; + transition: transform 0.3s ease; +} +.by-tab-text.scrolling { + animation: forum-tab-scroll 1.8s ease-in-out 0.3s infinite alternate; + transition: none; +} +@keyframes forum-tab-scroll { + from { transform: translateX(0); } + to { transform: translateX(var(--tab-scroll-px, 0)); } +} /* Category badge (colored pill) */ .forum-category-badge { diff --git a/backend/static/js/app.js b/backend/static/js/app.js index ba16c57..89ea286 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '594'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '595'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 9df00c5..7a7634c 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -99,10 +99,10 @@ window.Page_forum = (() => {
    ${KATEGORIEN.map(k => ` + data-kat="${k.key}">${_esc(k.label)} `).join('')} + data-section="map">${UI.icon('users')} Mitgliederkarte
    @@ -130,6 +130,21 @@ window.Page_forum = (() => { const _tabCount = _tabsEl.querySelectorAll('.by-tab').length; _tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2)); + // Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist + _tabsEl.addEventListener('mouseenter', e => { + const btn = e.target.closest('.by-tab'); + const span = btn?.querySelector('.by-tab-text'); + if (!span) return; + const overflow = span.scrollWidth - btn.clientWidth; + if (overflow <= 2) return; + span.style.setProperty('--tab-scroll-px', `-${overflow}px`); + span.classList.add('scrolling'); + }, true); + _tabsEl.addEventListener('mouseleave', e => { + const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text'); + if (span) span.classList.remove('scrolling'); + }, true); + // Tab-Klicks _tabsEl.addEventListener('click', e => { const btn = e.target.closest('[data-kat], [data-section]'); diff --git a/backend/static/sw.js b/backend/static/sw.js index a727200..bfe5f0d 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v594'; +const CACHE_VERSION = 'by-v595'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 83958cbb0bb9d21027c90d929e7e1853fcdd205c Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 08:39:22 +0200 Subject: [PATCH 30/63] Fix: Forum Tab-Marquee scrollt jetzt bis zum Textende (Padding eingerechnet), SW by-v596 --- backend/static/js/app.js | 2 +- backend/static/js/pages/forum.js | 4 +++- backend/static/sw.js | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 89ea286..18e94c3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '595'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '596'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 7a7634c..1b5abd7 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -135,7 +135,9 @@ window.Page_forum = (() => { const btn = e.target.closest('.by-tab'); const span = btn?.querySelector('.by-tab-text'); if (!span) return; - const overflow = span.scrollWidth - btn.clientWidth; + const style = getComputedStyle(btn); + const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const overflow = span.scrollWidth - (btn.clientWidth - padH); if (overflow <= 2) return; span.style.setProperty('--tab-scroll-px', `-${overflow}px`); span.classList.add('scrolling'); diff --git a/backend/static/sw.js b/backend/static/sw.js index bfe5f0d..1a56aac 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v595'; +const CACHE_VERSION = 'by-v596'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 031c6028ac0040912a563ac2cbd4ea765b2bbf3c Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 08:44:59 +0200 Subject: [PATCH 31/63] =?UTF-8?q?Feature:=20HdM=20Community-Vote=20?= =?UTF-8?q?=E2=80=94=20alle=20=C3=B6ffentlichen=20Hunde=20w=C3=A4hlbar,=20?= =?UTF-8?q?eigene=20ausgenommen,=20SW=20by-v597?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/movies.py | 32 ++++++- backend/static/css/components.css | 8 ++ backend/static/js/app.js | 2 +- backend/static/js/pages/forum.js | 134 +++++++++++++++++++----------- backend/static/sw.js | 2 +- 5 files changed, 127 insertions(+), 51 deletions(-) diff --git a/backend/routes/movies.py b/backend/routes/movies.py index e4f306e..da6c682 100644 --- a/backend/routes/movies.py +++ b/backend/routes/movies.py @@ -386,6 +386,34 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)): return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote} +@router.get("/hund-des-monats/kandidaten") +async def get_hdm_kandidaten(user=Depends(get_current_user)): + """Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand.""" + monat = datetime.now().strftime("%Y-%m") + with db() as conn: + rows = conn.execute(""" + SELECT d.id, d.name, d.rasse, d.foto_url, + u.name AS besitzer_name, + COALESCE(v.stimmen, 0) AS stimmen + FROM dogs d + JOIN users u ON u.id = d.user_id + LEFT JOIN ( + SELECT dog_id, COUNT(*) AS stimmen + FROM hund_des_monats_votes + WHERE monat = ? + GROUP BY dog_id + ) v ON v.dog_id = d.id + WHERE d.is_public = 1 + AND d.user_id != ? + ORDER BY + CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END, + stimmen DESC, + d.name ASC + LIMIT 60 + """, (monat, user["id"])).fetchall() + return [dict(r) for r in rows] + + @router.post("/hund-des-monats/vote") async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)): monat = datetime.now().strftime("%Y-%m") @@ -393,7 +421,9 @@ async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_ dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") - if dog["user_id"] != user["id"] and not dog["is_public"]: + if dog["user_id"] == user["id"]: + raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.") + if not dog["is_public"]: raise HTTPException(403, "Dieser Hund ist nicht öffentlich.") conn.execute(""" INSERT INTO hund_des_monats_votes (user_id, dog_id, monat) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 5264274..3582760 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -5241,11 +5241,19 @@ html.modal-open { margin-bottom: var(--space-3); } +/* Kandidaten-Suche */ +.hdm-kandidaten-search { + margin-bottom: var(--space-3); +} + /* Vote-Grid */ .hdm-vote-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-3); + max-height: 340px; + overflow-y: auto; + padding-right: var(--space-1); } .hdm-vote-card { diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 18e94c3..55a80eb 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '596'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '597'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 1b5abd7..8c28a1b 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -232,45 +232,12 @@ window.Page_forum = (() => { } async function _openHdmModal(data) { - // Immer frische Daten laden - try { data = await API.get('/movies/hund-des-monats'); } catch { /* nutze gecachte */ } + try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ } const [year, month] = data.monat.split('-'); const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) .format(new Date(+year, +month - 1, 1)); - let voteSection = ''; - if (_appState.user && _appState.dogs?.length > 0) { - const cards = _appState.dogs.map(dog => { - const isVoted = data.user_vote === dog.id; - const av = dog.foto_url - ? `${_esc(dog.name)}` - : `${_esc(dog.name.charAt(0).toUpperCase())}`; - return ` -
    -
    ${av}
    -
    ${_esc(dog.name)}
    - ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} - -
    `; - }).join(''); - voteSection = ` -
    -

    Für welchen deiner Hunde möchtest du abstimmen?

    -
    ${cards}
    -
    `; - } else if (!_appState.user) { - voteSection = ` -
    -

    - Anmelden - um für deinen Hund abzustimmen. -

    -
    `; - } - const topList = data.top?.length ? data.top.slice(0, 5).map((dog, i) => { const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i]; @@ -290,7 +257,26 @@ window.Page_forum = (() => {
    ${dog.stimmen} ${UI.icon('star')}
    `; }).join('') - : `

    Noch keine Stimmen diesen Monat. Sei der Erste!

    `; + : `

    Noch keine Stimmen. Sei der Erste!

    `; + + const voteHint = !_appState.user + ? `
    +

    + Anmelden + um abstimmen zu können. +

    +
    ` + : `
    +

    Für welchen Hund möchtest du abstimmen?

    + +
    + ${UI.skeleton(3)} +
    +
    `; const body = `
    @@ -298,7 +284,7 @@ window.Page_forum = (() => {

    Hund des Monats

    ${_esc(monthName)}
    - ${voteSection} + ${voteHint}

    Top 5 diesen Monat

    ${topList}
    @@ -311,20 +297,72 @@ window.Page_forum = (() => { e.preventDefault(); UI.modal.close(); App.navigate('settings'); }); - document.querySelectorAll('.hdm-vote-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const dogId = parseInt(btn.dataset.dogId); - await UI.asyncButton(btn, async () => { - try { - await API.post('/movies/hund-des-monats/vote', { dog_id: dogId }); - UI.toast.success('Stimme abgegeben!'); - UI.modal.close(); - _loadHdmCard(); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Abstimmen.'); - } + if (!_appState.user) return; + + // Kandidaten laden und rendern + let _kandidaten = []; + const _renderKandidaten = (list) => { + const grid = document.getElementById('hdm-kandidaten-grid'); + if (!grid) return; + if (!list.length) { + grid.innerHTML = `

    Keine Hunde gefunden.

    `; + return; + } + grid.innerHTML = list.map(dog => { + const isVoted = data.user_vote === dog.id; + const av = dog.foto_url + ? `${_esc(dog.name)}` + : `${_esc(dog.name.charAt(0).toUpperCase())}`; + const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; + return ` +
    +
    ${av}
    +
    ${_esc(dog.name)}
    + ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} + ${vorname ? `
    von ${vorname}
    ` : ''} + ${dog.stimmen > 0 ? `
    ${dog.stimmen} ${UI.icon('star')}
    ` : ''} + +
    `; + }).join(''); + + grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => { + btn.addEventListener('click', async () => { + const dogId = parseInt(btn.dataset.dogId); + await UI.asyncButton(btn, async () => { + try { + await API.post('/movies/hund-des-monats/vote', { dog_id: dogId }); + data.user_vote = dogId; + UI.toast.success('Stimme abgegeben!'); + UI.modal.close(); + _loadHdmCard(); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Abstimmen.'); + } + }); }); }); + }; + + try { + _kandidaten = await API.get('/movies/hund-des-monats/kandidaten'); + } catch { + document.getElementById('hdm-kandidaten-grid').innerHTML = + `

    Kandidaten konnten nicht geladen werden.

    `; + return; + } + _renderKandidaten(_kandidaten); + + document.getElementById('hdm-search')?.addEventListener('input', e => { + const q = e.target.value.trim().toLowerCase(); + _renderKandidaten(q + ? _kandidaten.filter(d => + (d.name || '').toLowerCase().includes(q) || + (d.rasse || '').toLowerCase().includes(q)) + : _kandidaten + ); }); } diff --git a/backend/static/sw.js b/backend/static/sw.js index 1a56aac..bc46029 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v596'; +const CACHE_VERSION = 'by-v597'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 742ad189e8f07b7862294e489be9592b9bd6a450 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 09:29:48 +0200 Subject: [PATCH 32/63] =?UTF-8?q?Feature:=20Sprint31=20=E2=80=94=209=20Fea?= =?UTF-8?q?tures=20merged=20(Streak,=20Ausgaben,=20KI-Tierarzt,=20R=C3=BCc?= =?UTF-8?q?krufe,=20Adoption,=20Vet+Befunde,=20Hundepass,=20Playdate,=20Ra?= =?UTF-8?q?ssenerkennung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js - Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle - KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log - Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF - Adoption: adoption.py, adoption.js, DB adoption_cache - Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents - Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2 - Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests - Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log --- backend/database.py | 200 +++++++ backend/main.py | 160 ++++++ backend/requirements.txt | 1 + backend/routes/adoption.py | 292 ++++++++++ backend/routes/expenses.py | 228 ++++++++ backend/routes/health_docs.py | 138 +++++ backend/routes/ki.py | 224 +++++++- backend/routes/passport.py | 377 +++++++++++++ backend/routes/playdate.py | 364 +++++++++++++ backend/routes/recalls.py | 138 +++++ backend/routes/streak.py | 114 ++++ backend/routes/tieraerzte.py | 57 +- backend/scheduler.py | 96 +++- backend/static/css/components.css | 121 +++++ backend/static/index.html | 28 + backend/static/js/api.js | 13 +- backend/static/js/app.js | 5 + backend/static/js/pages/adoption.js | 483 +++++++++++++++++ backend/static/js/pages/dog-profile.js | 609 ++++++++++++++++++++- backend/static/js/pages/expenses.js | 493 +++++++++++++++++ backend/static/js/pages/health.js | 498 ++++++++++++++++- backend/static/js/pages/playdate.js | 708 +++++++++++++++++++++++++ backend/static/js/pages/recalls.js | 190 +++++++ backend/static/js/pages/uebungen.js | 10 + backend/static/js/pages/welcome.js | 78 +++ backend/static/js/pages/wiki.js | 136 +++++ 26 files changed, 5734 insertions(+), 27 deletions(-) create mode 100644 backend/routes/adoption.py create mode 100644 backend/routes/expenses.py create mode 100644 backend/routes/health_docs.py create mode 100644 backend/routes/passport.py create mode 100644 backend/routes/playdate.py create mode 100644 backend/routes/recalls.py create mode 100644 backend/routes/streak.py create mode 100644 backend/static/js/pages/adoption.js create mode 100644 backend/static/js/pages/expenses.js create mode 100644 backend/static/js/pages/playdate.js create mode 100644 backend/static/js/pages/recalls.js diff --git a/backend/database.py b/backend/database.py index e373f02..5d992eb 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1657,3 +1657,203 @@ def _migrate(conn_factory): ); CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id); """) + + # Trainings-Streak-Tabelle + conn.execute(""" + CREATE TABLE IF NOT EXISTS training_streaks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + current_streak INTEGER NOT NULL DEFAULT 0, + longest_streak INTEGER NOT NULL DEFAULT 0, + last_training_date TEXT, + UNIQUE(user_id, dog_id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)") + + # Ausgaben-Tracker + conn.executescript(""" + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + datum TEXT NOT NULL, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC); + """) + + # KI-Tierarztfragen Rate-Limit-Log + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_tierarzt_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + # KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User) + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_rasse_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user + ON ki_rasse_log(user_id, created_at DESC) + """) + + # feed_recalls — Rückruf-Alarm für Tierfutter (RASFF) + conn.execute(""" + CREATE TABLE IF NOT EXISTS feed_recalls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + titel TEXT NOT NULL, + produkt TEXT, + gefahr TEXT, + herkunft TEXT, + datum TEXT NOT NULL, + quelle TEXT NOT NULL DEFAULT 'rasff', + url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)") + + # Adoption-Cache + conn.execute(""" + CREATE TABLE IF NOT EXISTS adoption_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + rasse TEXT, + alter_jahre REAL, + geschlecht TEXT, + foto_url TEXT, + tierheim TEXT, + tierheim_plz TEXT, + tierheim_lat REAL, + tierheim_lon REAL, + adoptions_url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ) + """) + + # ---- Favoriten-Tierarzt + Gesundheitsdokumente ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS favorite_vets ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, vet_id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS health_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + typ TEXT NOT NULL, + titel TEXT NOT NULL, + beschreibung TEXT, + file_path TEXT NOT NULL, + file_type TEXT NOT NULL, + datum TEXT, + vet_id INTEGER REFERENCES tieraerzte(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)") + + # Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS vaccinations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + krankheit TEXT NOT NULL, + datum TEXT NOT NULL, + naechste TEXT, + tierarzt TEXT, + charge_nr TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS medications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + name TEXT NOT NULL, + dosierung TEXT, + von TEXT, + bis TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS dog_passport_meta ( + dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE, + blutgruppe TEXT, + allergien TEXT, + besonderheiten TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS passport_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + valid_until TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token) + """) + logger.info("Migration: Hundepass-Tabellen bereit.") + except Exception as e: + logger.warning(f"Migration Hundepass: {e}") + + # ---- Playdate ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + lat REAL NOT NULL, + lon REAL NOT NULL, + ort_name TEXT, + radius_km INTEGER NOT NULL DEFAULT 10, + beschreibung TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo + ON playdate_listings(lat, lon) WHERE aktiv=1 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + nachricht TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(from_dog_id, to_dog_id) + ) + """) diff --git a/backend/main.py b/backend/main.py index 6eb99a2..8b259f7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -189,6 +189,13 @@ from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router from routes.outreach import router as outreach_router from routes.jobs import router as jobs_router +from routes.streak import router as streak_router +from routes.expenses import router as expenses_router +from routes.recalls import router as recalls_router +from routes.adoption import router as adoption_router +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 app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -240,6 +247,13 @@ app.include_router(training_router, prefix="/api/training", tags= app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) app.include_router(notes_router, prefix="/api/notes", tags=["Notes"]) +app.include_router(streak_router, prefix="/api", tags=["Streak"]) +app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"]) +app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"]) +app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"]) +app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) +app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) +app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) # ------------------------------------------------------------------ @@ -1674,6 +1688,152 @@ for _hp in _HONEYPOT_PATHS: app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False) +# ------------------------------------------------------------------ +# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig) +# ------------------------------------------------------------------ +@app.get("/pass/{token}") +async def passport_share_page(token: str): + from fastapi.responses import HTMLResponse + from database import db as _db + from datetime import date as _date + + with _db() as conn: + share = conn.execute( + "SELECT * FROM passport_shares WHERE token=?", (token,) + ).fetchone() + if not share: + return HTMLResponse( + '' + '

    Link nicht gefunden

    Dieser Hundepass-Link ist ungültig.

    ', + status_code=404 + ) + if share["valid_until"] < _date.today().isoformat(): + return HTMLResponse( + '' + '

    Link abgelaufen

    Dieser Hundepass-Link ist nicht mehr gültig.

    ', + status_code=410 + ) + dog_id = share["dog_id"] + dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone() + meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone() + vaccs = conn.execute( + "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,) + ).fetchall() + meds = conn.execute( + "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,) + ).fetchall() + def _fmt(d): + if not d: + return "–" + try: + from datetime import datetime as _dt + return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return d + + dog = dict(dog) + meta = dict(meta) if meta else {} + vaccs = [dict(v) for v in vaccs] + meds = [dict(m) for m in meds] + + _g_map = {"m": "Rüde", "w": "Hündin"} + + vacc_rows = "".join(f""" + + {v['krankheit'] or ''} + {_fmt(v['datum'])} + {_fmt(v['naechste'])} + {v['tierarzt'] or '–'} + {v['charge_nr'] or '–'} + """ for v in vaccs) or "Keine Einträge" + + med_rows = "".join(f""" + + {m['name'] or ''} + {m['dosierung'] or '–'} + {_fmt(m['von'])} + {_fmt(m['bis']) if m['bis'] else 'dauerhaft'} + {m['notiz'] or '–'} + """ for m in meds) or "Keine Einträge" + + html = f""" + + + + + Hundepass — {dog['name']} + + + +
    +

    Ban Yaro

    +

    Digitaler Hundepass — {dog['name']}

    +
    +
    +
    +

    Hundeangaben

    +
    +
    {dog['name']}
    +
    {dog.get('rasse') or '–'}
    +
    {_fmt(dog.get('geburtstag'))}
    +
    {_g_map.get(dog.get('geschlecht',''), '–')}
    +
    {dog.get('chip_nr') or '–'}
    +
    {meta.get('blutgruppe') or '–'}
    +
    + {('
    ' + f'
    {meta["allergien"]}
    ') if meta.get("allergien") else ''} + {('
    ' + f'
    {meta["besonderheiten"]}
    ') if meta.get("besonderheiten") else ''} +
    + +
    +

    Impfungen

    + + + + + {vacc_rows} +
    KrankheitDatumNächsteTierarztCharge
    +
    + +
    +

    Medikamente

    + + + + + {med_rows} +
    MedikamentDosierungVonBisNotiz
    +
    +
    + + +""" + return HTMLResponse(html) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/requirements.txt b/backend/requirements.txt index 7b268fa..17db134 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ pywebpush==2.0.0 apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 +fpdf2==2.8.3 diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py new file mode 100644 index 0000000..d742ccc --- /dev/null +++ b/backend/routes/adoption.py @@ -0,0 +1,292 @@ +""" +BAN YARO — Adoption (Tierheim-Hunde in der Nähe) + +Strategie: + 1. PetFinder API (falls API-Key gesetzt) → hat kaum deutsche Tierheime, nur als Bonus + 2. Statische Daten: Liste großer deutscher Tierheime mit Koordinaten + 3. Fallback: Weiterleitung zu tierheimhelden.de + +Caching: adoption_cache Tabelle, 24h TTL. +""" + +import os +import math +import logging +import asyncio +import httpx +from datetime import datetime, timedelta +from fastapi import APIRouter, Query, BackgroundTasks +from database import db + +logger = logging.getLogger(__name__) +router = APIRouter() + +PETFINDER_KEY = os.getenv("PETFINDER_API_KEY", "") +PETFINDER_SECRET = os.getenv("PETFINDER_API_SECRET", "") + +# ------------------------------------------------------------------ +# Haversine — Distanz in km +# ------------------------------------------------------------------ +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6371.0 + p1 = math.radians(lat1) + p2 = 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)) + + +# ------------------------------------------------------------------ +# Statische Tierheim-Daten (große deutsche Tierheime) +# ------------------------------------------------------------------ +GERMAN_SHELTERS = [ + # (id, name, plz, stadt, lat, lon, url) + ("de_berlin_tierschutz", "Tierheim Berlin", "12459", "Berlin", 52.4862, 13.5301, "https://www.tierheim-berlin.de/tiere/hunde/"), + ("de_hamburg_tierschutz", "Tierheim Hamburg (Süderstraße)", "20537", "Hamburg", 53.5539, 10.0416, "https://www.hamburger-tierschutzverein.de/hunde/"), + ("de_muenchen_tierschutz", "Tierheim München", "81379", "München", 48.0982, 11.5248, "https://www.tierheim-muenchen.de/hunde/"), + ("de_koeln_tierschutz", "Tierheim Köln", "50769", "Köln", 51.0168, 6.9369, "https://www.tierschutzverein-koeln.de/tiere/hunde/"), + ("de_frankfurt_tierschutz", "Tierheim Frankfurt (Fechenheim)", "60386", "Frankfurt", 50.1246, 8.7597, "https://www.tierheim-frankfurt.de/hunde/"), + ("de_stuttgart_tierschutz", "Tierschutzverein Stuttgart", "70193", "Stuttgart", 48.7790, 9.1634, "https://www.tierschutzverein-stuttgart.de/vermittlung/hunde/"), + ("de_duesseldorf_tierheim", "Tierheim Düsseldorf", "40599", "Düsseldorf", 51.1948, 6.8488, "https://www.tierheim-duesseldorf.de/tiere/hunde/"), + ("de_dortmund_tierheim", "Tierheim Dortmund", "44339", "Dortmund", 51.5481, 7.4584, "https://www.tierschutzverein-dortmund.de/hunde/"), + ("de_essen_tierheim", "Tierheim Essen", "45276", "Essen", 51.4341, 7.0985, "https://www.tierheim-essen.de/tiere/hunde/"), + ("de_leipzig_tierheim", "Tierheim Leipzig", "04109", "Leipzig", 51.3396, 12.3713, "https://www.tierschutzverein-leipzig.de/hunde/"), + ("de_dresden_tierheim", "Tierheim Dresden", "01127", "Dresden", 51.0789, 13.7319, "https://www.tierschutzverein-dresden-heidenau.de/tiere/hunde/"), + ("de_hannover_tierheim", "Tierheim Hannover", "30855", "Hannover", 52.3484, 9.7411, "https://www.tierschutzverein-hannover.de/hunde/"), + ("de_nuernberg_tierheim", "Tierschutzverein Nürnberg", "90461", "Nürnberg", 49.4182, 11.0830, "https://www.tierschutzverein-nuernberg.de/tiere/hunde/"), + ("de_bremen_tierheim", "Tierheim Bremen", "28307", "Bremen", 53.0440, 8.9128, "https://www.tierheim-bremen.de/hunde/"), + ("de_bochum_tierheim", "Tierheim Bochum", "44793", "Bochum", 51.4753, 7.2128, "https://www.tierschutzverein-bochum.de/hunde/"), + ("de_wuppertal_tierheim", "Tierheim Wuppertal", "42283", "Wuppertal", 51.2571, 7.1705, "https://www.tierschutz-wuppertal.de/hunde/"), + ("de_bielefeld_tierheim", "Tierheim Bielefeld", "33649", "Bielefeld", 51.9951, 8.5327, "https://www.tierschutzverein-bielefeld.de/hunde/"), + ("de_mannheim_tierheim", "Tierheim Mannheim", "68309", "Mannheim", 49.5079, 8.5033, "https://www.tierschutzverein-mannheim.de/hunde/"), + ("de_karlsruhe_tierheim", "Tierheim Karlsruhe", "76229", "Karlsruhe", 48.9960, 8.4290, "https://www.tierschutzverein-karlsruhe.de/hunde/"), + ("de_augsburg_tierheim", "Tierheim Augsburg", "86159", "Augsburg", 48.3668, 10.8978, "https://www.tierschutz-augsburg.de/tiere/hunde/"), + ("de_freiburg_tierheim", "Tierheim Freiburg", "79115", "Freiburg", 47.9855, 7.8352, "https://www.tierschutz-freiburg.de/tiere/hunde/"), + ("de_kiel_tierheim", "Tierheim Kiel", "24113", "Kiel", 54.3203, 10.1228, "https://www.tierschutzverein-kiel.de/hunde/"), + ("de_magdeburg_tierheim", "Tierheim Magdeburg", "39118", "Magdeburg", 52.0814, 11.5939, "https://www.tierschutz-magdeburg.de/hunde/"), + ("de_erfurt_tierheim", "Tierheim Erfurt", "99099", "Erfurt", 50.9985, 11.0424, "https://www.tierschutzverein-erfurt.de/hunde/"), + ("de_rostock_tierheim", "Tierheim Rostock", "18059", "Rostock", 54.0831, 12.0965, "https://www.tierschutzverein-rostock.de/hunde/"), +] + + +# ------------------------------------------------------------------ +# PetFinder OAuth2 Token +# ------------------------------------------------------------------ +_pf_token = None +_pf_token_exp = 0.0 + +async def _get_pf_token() -> str | None: + global _pf_token, _pf_token_exp + if not (PETFINDER_KEY and PETFINDER_SECRET): + return None + now = asyncio.get_event_loop().time() + if _pf_token and now < _pf_token_exp - 60: + return _pf_token + try: + async with httpx.AsyncClient(timeout=8) as client: + r = await client.post( + "https://api.petfinder.com/v2/oauth2/token", + data={"grant_type": "client_credentials", + "client_id": PETFINDER_KEY, + "client_secret": PETFINDER_SECRET}, + ) + if r.status_code == 200: + data = r.json() + _pf_token = data.get("access_token") + _pf_token_exp = now + data.get("expires_in", 3600) + return _pf_token + except Exception as e: + logger.warning(f"PetFinder OAuth: {e}") + return None + + +# ------------------------------------------------------------------ +# PetFinder: Hunde in der Nähe holen +# ------------------------------------------------------------------ +async def _fetch_petfinder(lat: float, lon: float, radius: int) -> list[dict]: + token = await _get_pf_token() + if not token: + return [] + try: + async with httpx.AsyncClient(timeout=12) as client: + r = await client.get( + "https://api.petfinder.com/v2/animals", + headers={"Authorization": f"Bearer {token}"}, + params={ + "type": "dog", + "location": f"{lat},{lon}", + "distance": radius, + "limit": 20, + "sort": "distance", + "status": "adoptable", + }, + ) + if r.status_code != 200: + logger.warning(f"PetFinder API: HTTP {r.status_code}") + return [] + animals = r.json().get("animals", []) + result = [] + for a in animals: + org = a.get("organization_id", "") + loc = a.get("contact", {}).get("address", {}) + photos = a.get("photos", []) + foto = photos[0].get("medium") if photos else None + age_map = {"Baby": 0.25, "Young": 1.0, "Adult": 4.0, "Senior": 9.0} + result.append({ + "external_id": f"pf_{a['id']}", + "name": a.get("name", "Unbekannt"), + "rasse": ", ".join( + filter(None, [ + a.get("breeds", {}).get("primary"), + a.get("breeds", {}).get("secondary"), + ]) + ) or None, + "alter_jahre": age_map.get(a.get("age"), None), + "geschlecht": {"Male": "männlich", "Female": "weiblich"}.get(a.get("gender"), None), + "foto_url": foto, + "tierheim": org, + "tierheim_plz": loc.get("postcode"), + "tierheim_lat": None, + "tierheim_lon": None, + "adoptions_url": a.get("url", "https://www.petfinder.com/"), + "quelle": "petfinder", + }) + return result + except Exception as e: + logger.warning(f"PetFinder Fetch: {e}") + return [] + + +# ------------------------------------------------------------------ +# Cache befüllen +# ------------------------------------------------------------------ +async def _refresh_cache(lat: float, lon: float, radius: int): + """Holt frische Daten und schreibt sie in adoption_cache.""" + animals = await _fetch_petfinder(lat, lon, radius) + if not animals: + return + expires = (datetime.utcnow() + timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") + with db() as conn: + for a in animals: + try: + conn.execute(""" + INSERT INTO adoption_cache + (external_id, name, rasse, alter_jahre, geschlecht, + foto_url, tierheim, tierheim_plz, tierheim_lat, tierheim_lon, + adoptions_url, expires_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(external_id) DO UPDATE SET + name=excluded.name, + rasse=excluded.rasse, + alter_jahre=excluded.alter_jahre, + geschlecht=excluded.geschlecht, + foto_url=excluded.foto_url, + tierheim=excluded.tierheim, + tierheim_plz=excluded.tierheim_plz, + tierheim_lat=excluded.tierheim_lat, + tierheim_lon=excluded.tierheim_lon, + adoptions_url=excluded.adoptions_url, + expires_at=excluded.expires_at + """, ( + a["external_id"], a["name"], a["rasse"], a["alter_jahre"], + a["geschlecht"], a["foto_url"], a["tierheim"], a["tierheim_plz"], + a["tierheim_lat"], a["tierheim_lon"], a["adoptions_url"], expires, + )) + except Exception as e: + logger.warning(f"Cache insert: {e}") + + +# ------------------------------------------------------------------ +# GET /api/adoption/nearby +# ------------------------------------------------------------------ +@router.get("/nearby") +async def adoption_nearby( + lat: float = Query(..., description="Breitengrad"), + lon: float = Query(..., description="Längengrad"), + radius: int = Query(50, ge=5, le=200, description="Radius in km"), + background_tasks: BackgroundTasks = None, +): + """ + Gibt Adoptionshunde in der Nähe zurück. + + Priorisierung: + 1. Frische PetFinder-Einträge aus Cache + 2. Statische Tierheim-Liste (immer vorhanden, mit Entfernung) + """ + now_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + # ------ Cache lesen ------ + cached_animals = [] + with db() as conn: + rows = conn.execute(""" + SELECT * FROM adoption_cache + WHERE expires_at > ? + ORDER BY created_at DESC + """, (now_str,)).fetchall() + for row in rows: + d = dict(row) + if d.get("tierheim_lat") and d.get("tierheim_lon"): + dist = _haversine(lat, lon, d["tierheim_lat"], d["tierheim_lon"]) + if dist <= radius: + d["distanz_km"] = round(dist, 1) + cached_animals.append(d) + else: + # PetFinder-Einträge ohne Koordinaten: immer anzeigen + d["distanz_km"] = None + cached_animals.append(d) + + # ------ Cache refreshen wenn leer oder alt ------ + if not cached_animals and background_tasks is not None: + background_tasks.add_task(_refresh_cache, lat, lon, radius) + + # ------ Statische Tierheime (immer) ------ + shelters = [] + for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS: + dist = _haversine(lat, lon, slat, slon) + if dist <= radius: + shelters.append({ + "id": sid, + "name": name, + "plz": plz, + "stadt": stadt, + "lat": slat, + "lon": slon, + "url": url, + "distanz_km": round(dist, 1), + }) + + shelters.sort(key=lambda x: x["distanz_km"]) + + return { + "animals": cached_animals[:40], + "shelters": shelters[:10], + "has_petfinder": bool(PETFINDER_KEY), + } + + +# ------------------------------------------------------------------ +# GET /api/adoption/geocode?plz=… — PLZ → Koordinaten via Nominatim +# ------------------------------------------------------------------ +@router.get("/geocode") +async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): + """Wandelt eine PLZ in Koordinaten um (via Nominatim).""" + try: + async with httpx.AsyncClient(timeout=8) as client: + r = await client.get( + "https://nominatim.openstreetmap.org/search", + params={ + "q": f"{plz}, Germany", + "format": "json", + "limit": 1, + "accept-language": "de", + "countrycodes": "de", + }, + headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"}, + ) + results = r.json() + if results: + return {"lat": float(results[0]["lat"]), "lon": float(results[0]["lon"]), "display": results[0].get("display_name", plz)} + except Exception as e: + logger.warning(f"Geocode PLZ {plz}: {e}") + return {"lat": None, "lon": None, "display": plz} diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py new file mode 100644 index 0000000..a3bcf7a --- /dev/null +++ b/backend/routes/expenses.py @@ -0,0 +1,228 @@ +"""BAN YARO — Ausgaben-Tracker Routes""" + +import logging +from datetime import date +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +logger = logging.getLogger(__name__) + +KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"} + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class ExpenseCreate(BaseModel): + dog_id: Optional[int] = None + kategorie: str + betrag: float + datum: str + notiz: Optional[str] = None + + +class ExpenseUpdate(BaseModel): + dog_id: Optional[int] = None + kategorie: Optional[str] = None + betrag: Optional[float] = None + datum: Optional[str] = None + notiz: Optional[str] = None + + +def _serialize(row) -> dict: + return dict(row) + + +# ------------------------------------------------------------------ +# GET /api/expenses/summary — Monats- und Jahressummen +# WICHTIG: Diese Route muss VOR /{id} stehen! +# ------------------------------------------------------------------ +@router.get("/summary") +async def get_summary( + dog_id: Optional[int] = Query(default=None), + user=Depends(get_current_user), +): + today = date.today() + monat_prefix = today.strftime("%Y-%m") + jahr_prefix = today.strftime("%Y") + + extra_cond = "" + extra_params: list = [] + if dog_id is not None: + extra_cond = " AND dog_id=?" + extra_params = [dog_id] + + with db() as conn: + # Monats-Summen pro Kategorie + rows_monat = conn.execute( + f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe + FROM expenses + WHERE user_id=? AND datum LIKE ?{extra_cond} + GROUP BY kategorie""", + [user["id"], f"{monat_prefix}%"] + extra_params, + ).fetchall() + + # Jahres-Summen pro Kategorie + rows_jahr = conn.execute( + f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe + FROM expenses + WHERE user_id=? AND datum LIKE ?{extra_cond} + GROUP BY kategorie""", + [user["id"], f"{jahr_prefix}%"] + extra_params, + ).fetchall() + + monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat} + jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr} + + gesamt_monat = round(sum(monat.values()), 2) + gesamt_jahr = round(sum(jahr.values()), 2) + + return { + "monat": monat, + "jahr": jahr, + "gesamt_monat": gesamt_monat, + "gesamt_jahr": gesamt_jahr, + } + + +# ------------------------------------------------------------------ +# GET /api/expenses — Liste mit optionalen Filtern +# ------------------------------------------------------------------ +@router.get("") +async def list_expenses( + dog_id: Optional[int] = Query(default=None), + von: Optional[str] = Query(default=None), + bis: Optional[str] = Query(default=None), + limit: int = Query(default=100, le=500), + offset: int = Query(default=0), + user=Depends(get_current_user), +): + conditions = ["e.user_id=?"] + params: list = [user["id"]] + + if dog_id is not None: + conditions.append("e.dog_id=?") + params.append(dog_id) + if von: + conditions.append("e.datum >= ?") + params.append(von) + if bis: + conditions.append("e.datum <= ?") + params.append(bis) + + where = " AND ".join(conditions) + params += [limit, offset] + + with db() as conn: + rows = conn.execute( + f"""SELECT e.*, d.name AS dog_name + FROM expenses e + LEFT JOIN dogs d ON d.id = e.dog_id + WHERE {where} + ORDER BY e.datum DESC, e.id DESC + LIMIT ? OFFSET ?""", + params, + ).fetchall() + + return [_serialize(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/expenses — neuer Eintrag +# ------------------------------------------------------------------ +@router.post("", status_code=201) +async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)): + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") + if data.betrag <= 0: + raise HTTPException(400, "Betrag muss größer als 0 sein.") + + with db() as conn: + # dog_id prüfen — muss dem User gehören + if data.dog_id is not None: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, user["id"]), + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + conn.execute( + """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz) + VALUES (?, ?, ?, ?, ?, ?)""", + (user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz), + ) + row = conn.execute( + "SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],), + ).fetchone() + + return _serialize(row) + + +# ------------------------------------------------------------------ +# PATCH /api/expenses/{id} — bearbeiten +# ------------------------------------------------------------------ +@router.patch("/{expense_id}") +async def update_expense( + expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user) +): + with db() as conn: + row = conn.execute( + "SELECT * FROM expenses WHERE id=? AND user_id=?", + (expense_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + + updates = {} + if data.kategorie is not None: + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") + updates["kategorie"] = data.kategorie + if data.betrag is not None: + if data.betrag <= 0: + raise HTTPException(400, "Betrag muss größer als 0 sein.") + updates["betrag"] = data.betrag + if data.datum is not None: + updates["datum"] = data.datum + if data.notiz is not None: + updates["notiz"] = data.notiz + if data.dog_id is not None: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, user["id"]), + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + updates["dog_id"] = data.dog_id + + if not updates: + return _serialize(row) + + set_clause = ", ".join(f"{k}=?" for k in updates) + values = list(updates.values()) + [expense_id] + conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values) + row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone() + + return _serialize(row) + + +# ------------------------------------------------------------------ +# DELETE /api/expenses/{id} — löschen +# ------------------------------------------------------------------ +@router.delete("/{expense_id}", status_code=204) +async def delete_expense(expense_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT id FROM expenses WHERE id=? AND user_id=?", + (expense_id, user["id"]), + ).fetchone() + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,)) + return None diff --git a/backend/routes/health_docs.py b/backend/routes/health_docs.py new file mode 100644 index 0000000..0c2d4a7 --- /dev/null +++ b/backend/routes/health_docs.py @@ -0,0 +1,138 @@ +"""BAN YARO — Gesundheitsdokumente (Befunde, Röntgen, Rezepte …)""" + +import os +import uuid +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} +MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB + +ERLAUBTE_TYPEN = {"blutbild", "roentgen", "rezept", "impfausweis", "sonstiges"} + + +def _check_dog_owner(conn, dog_id: int, user_id: int): + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dog + + +# ------------------------------------------------------------------ +# GET /api/health-docs?dog_id=... +# ------------------------------------------------------------------ +@router.get("") +async def list_docs(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + rows = conn.execute( + """SELECT hd.*, t.name AS vet_name + FROM health_documents hd + LEFT JOIN tieraerzte t ON t.id = hd.vet_id + WHERE hd.dog_id=? + ORDER BY hd.created_at DESC""", + (dog_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# POST /api/health-docs/upload (multipart/form-data) +# ------------------------------------------------------------------ +@router.post("/upload", status_code=201) +async def upload_doc( + dog_id: int = Form(...), + typ: str = Form(...), + titel: str = Form(...), + beschreibung: Optional[str] = Form(None), + datum: Optional[str] = Form(None), + vet_id: Optional[int] = Form(None), + file: UploadFile = File(...), + user=Depends(get_current_user), +): + if typ not in ERLAUBTE_TYPEN: + raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(sorted(ERLAUBTE_TYPEN))}") + + ext = os.path.splitext(file.filename or "")[1].lower() + if not ext: + ext = ".jpg" + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.") + + content = await file.read() + if len(content) > MAX_SIZE_BYTES: + raise HTTPException(413, "Datei zu groß. Maximal 10 MB erlaubt.") + + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + if vet_id: + vet = conn.execute("SELECT id FROM tieraerzte WHERE id=?", (vet_id,)).fetchone() + if not vet: + vet_id = None + + # Datei speichern + dog_dir = os.path.join(MEDIA_DIR, "health_docs", str(dog_id)) + os.makedirs(dog_dir, exist_ok=True) + filename = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(dog_dir, filename) + with open(filepath, "wb") as f: + f.write(content) + + file_url = f"/media/health_docs/{dog_id}/{filename}" + file_type = "pdf" if ext == ".pdf" else ext.lstrip(".") + + with db() as conn: + conn.execute( + """INSERT INTO health_documents + (dog_id, user_id, typ, titel, beschreibung, file_path, file_type, datum, vet_id) + VALUES (?,?,?,?,?,?,?,?,?)""", + (dog_id, user["id"], typ, titel.strip(), beschreibung, + file_url, file_type, datum or None, vet_id) + ) + row = conn.execute( + """SELECT hd.*, t.name AS vet_name + FROM health_documents hd + LEFT JOIN tieraerzte t ON t.id = hd.vet_id + WHERE hd.id = last_insert_rowid()""" + ).fetchone() + + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /api/health-docs/{id} +# ------------------------------------------------------------------ +@router.delete("/{doc_id}", status_code=204) +async def delete_doc(doc_id: int, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT * FROM health_documents WHERE id=? AND user_id=?", + (doc_id, user["id"]) + ).fetchone() + if not row: + raise HTTPException(404, "Dokument nicht gefunden.") + + # Datei löschen — file_path ist z.B. /media/health_docs/42/abc123.pdf + file_path = row["file_path"] + if file_path: + # /media/... → MEDIA_DIR/... + rel = file_path.lstrip("/") + if rel.startswith("media/"): + rel = rel[len("media/"):] + abs_path = os.path.join(MEDIA_DIR, rel) + if os.path.isfile(abs_path): + try: + os.remove(abs_path) + except OSError: + pass + + conn.execute("DELETE FROM health_documents WHERE id=?", (doc_id,)) + + return None diff --git a/backend/routes/ki.py b/backend/routes/ki.py index aa8d001..80d663c 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -1,10 +1,11 @@ """BAN YARO — KI Routes""" -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from pydantic import BaseModel from typing import Optional import ki as ki_module from auth import get_current_user from ratelimit import check as rl_check +from database import db router = APIRouter() @@ -62,3 +63,224 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon.""" raise HTTPException(503, str(e)) except Exception as e: raise HTTPException(500, "KI momentan nicht verfügbar.") + + +# ------------------------------------------------------------------ +# POST /ki/tierarzt — KI-Tierarztfragen +# ------------------------------------------------------------------ +class TierarztRequest(BaseModel): + symptom: str + dog_id: Optional[int] = None + dog_name: Optional[str] = None + rasse: Optional[str] = None + + +@router.post("/tierarzt") +async def ki_tierarzt(req: TierarztRequest, request: Request, + user=Depends(get_current_user)): + """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung.""" + if not req.symptom or len(req.symptom.strip()) < 5: + raise HTTPException(400, "Bitte beschreibe das Symptom genauer.") + if len(req.symptom) > 1000: + raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).") + + # Rate-Limit: max 5 Anfragen pro User pro Tag + with db() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM ki_tierarzt_log " + "WHERE user_id=? AND created_at >= datetime('now','-1 day')", + (user["id"],) + ).fetchone()[0] + if count >= 5: + raise HTTPException(429, "Tageslimit erreicht. Du kannst maximal 5 Tierarztfragen pro Tag stellen.") + + dog_name = req.dog_name or "unbekannt" + rasse = req.rasse or "unbekannt" + + system = ( + "Du bist ein erfahrener Tierarzt-Assistent für Hunde. " + "Deine Aufgabe ist es, Hundebesitzern eine erste Orientierung zu geben — " + "kein Ersatz für eine echte tierärztliche Untersuchung. " + "Antworte immer auf Deutsch, klar und verständlich. " + "Stelle keine medizinischen Diagnosen. " + "Empfehle im Zweifel immer den Gang zum Tierarzt." + ) + + prompt = f"""Hund: {dog_name}, Rasse: {rasse} +Symptom: {req.symptom.strip()} + +Gib eine strukturierte, verständliche Einschätzung: +1. Mögliche Ursachen (2-3 wahrscheinlichste) +2. Was der Besitzer jetzt tun kann (Erstmaßnahmen) +3. Wann unbedingt zum Tierarzt (Dringlichkeit: beobachten / bald / sofort) + +Antworte auf Deutsch, klar und verständlich. Maximal 300 Wörter. +Schreibe KEINE medizinischen Diagnosen und empfehle im Zweifel immer den Tierarzt.""" + + try: + antwort = await ki_module.complete( + prompt=prompt, + system=system, + max_tokens=600, + requires_premium=False, + user_id=user["id"], + ) + # Erfolg: Rate-Limit-Eintrag speichern + with db() as conn: + conn.execute( + "INSERT INTO ki_tierarzt_log (user_id, dog_id) VALUES (?, ?)", + (user["id"], req.dog_id) + ) + return {"antwort": antwort, "anfragen_heute": count + 1, "limit": 5} + except ki_module.KIUnavailableError as e: + raise HTTPException(503, str(e)) + except HTTPException: + raise + except Exception: + raise HTTPException(500, "KI momentan nicht verfügbar.") + + +# ------------------------------------------------------------------ +# Rate-Limit-Helfer für Rassen-Erkennung +# ------------------------------------------------------------------ +_RASSE_DAILY_LIMIT = 10 + + +def _check_rasse_limit(user_id: int) -> int: + """Gibt verbleibende Erkennungen zurück. Wirft HTTPException wenn Limit erreicht.""" + with db() as conn: + used = conn.execute( + """SELECT COUNT(*) FROM ki_rasse_log + WHERE user_id = ? AND created_at >= datetime('now', 'start of day')""", + (user_id,) + ).fetchone()[0] + remaining = _RASSE_DAILY_LIMIT - used + if remaining <= 0: + raise HTTPException(429, f"Tageslimit erreicht ({_RASSE_DAILY_LIMIT} Erkennungen/Tag). Morgen wieder verfügbar.") + return remaining + + +def _log_rasse_request(user_id: int): + with db() as conn: + conn.execute( + "INSERT INTO ki_rasse_log (user_id) VALUES (?)", (user_id,) + ) + + +# ------------------------------------------------------------------ +# POST /ki/rasse-erkennung — Vision-basierte Rassenerkennung +# ------------------------------------------------------------------ +@router.post("/rasse-erkennung") +async def ki_rasse_erkennung( + request: Request, + file: UploadFile = File(...), + user=Depends(get_current_user), +): + """Hunderassen per Foto erkennen (Claude Vision, max 5 MB, 10x/Tag).""" + import base64 + import json + import re + import anthropic + + # Dateigröße prüfen + content = await file.read() + if len(content) > 5 * 1024 * 1024: + raise HTTPException(400, "Bild zu groß. Maximal 5 MB erlaubt.") + + # MIME-Typ prüfen + ct = (file.content_type or "").lower() + if not ct.startswith("image/"): + raise HTTPException(400, "Nur Bilddateien erlaubt (JPG, PNG, WebP).") + + # MIME-Typ auf erlaubte Werte beschränken + allowed_mimes = {"image/jpeg", "image/png", "image/webp", "image/gif"} + mime_type = ct if ct in allowed_mimes else "image/jpeg" + + # Rate-Limit prüfen + remaining_before = _check_rasse_limit(user["id"]) + + # Anthropic-Client holen (nutzt cached Instanz aus ki.py) + if not ki_module.ANTHROPIC_KEY: + raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.") + + api_key = ki_module.ANTHROPIC_KEY + base64_data = base64.standard_b64encode(content).decode("utf-8") + + prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n). + +Antworte NUR im folgenden JSON-Format (kein anderer Text): +{ + "rassen": [ + {"name": "Labrador Retriever", "sicherheit": 85, "beschreibung": "Kurze Begründung"}, + {"name": "Golden Retriever", "sicherheit": 12, "beschreibung": "Falls Mischling"} + ], + "ist_hund": true, + "hinweis": "Optionaler Hinweis z.B. bei Welpen oder schlechter Bildqualität" +} + +Gib 1-3 Rassen nach Wahrscheinlichkeit sortiert an. Sicherheit in Prozent (0-100). +Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" + + try: + def _sync_call(): + client = anthropic.Anthropic(api_key=api_key) + return client.messages.create( + model="claude-opus-4-7", + max_tokens=500, + messages=[{ + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": mime_type, + "data": base64_data, + } + }, + { + "type": "text", + "text": prompt_text, + } + ] + }] + ) + + import asyncio + response = await asyncio.get_event_loop().run_in_executor(None, _sync_call) + raw = response.content[0].text.strip() + + except anthropic.APIError as e: + raise HTTPException(503, f"KI-Bildanalyse nicht verfügbar: {e}") + except Exception as e: + raise HTTPException(500, "Fehler bei der Bildanalyse.") + + # JSON parsen — Claude kann manchmal ```json ... ``` wrappen + cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL).strip() + try: + parsed = json.loads(cleaned) + except json.JSONDecodeError: + raise HTTPException(500, "KI-Antwort konnte nicht verarbeitet werden.") + + # Usage loggen (erst nach erfolgreicher KI-Antwort) + _log_rasse_request(user["id"]) + remaining_after = remaining_before - 1 + + # Wiki-Slugs für erkannte Rassen nachschlagen + rassen = parsed.get("rassen", []) + if rassen: + with db() as conn: + for r in rassen: + name = r.get("name", "") + # Exakter Name-Match (case-insensitive) + row = conn.execute( + "SELECT slug FROM wiki_rassen WHERE LOWER(name) = LOWER(?)", (name,) + ).fetchone() + r["wiki_slug"] = row["slug"] if row else None + + return { + "rassen": rassen, + "ist_hund": parsed.get("ist_hund", False), + "hinweis": parsed.get("hinweis") or None, + "verbleibende_anfragen": remaining_after, + } diff --git a/backend/routes/passport.py b/backend/routes/passport.py new file mode 100644 index 0000000..884e8d3 --- /dev/null +++ b/backend/routes/passport.py @@ -0,0 +1,377 @@ +"""BAN YARO — Digitaler Hundepass""" + +import io +import secrets +from datetime import date, datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Optional +from database import db +from auth import get_current_user + +router = APIRouter() + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class PassportMeta(BaseModel): + blutgruppe: Optional[str] = None + allergien: Optional[str] = None + besonderheiten: Optional[str] = None + + +class VaccinationCreate(BaseModel): + krankheit: str + datum: str + naechste: Optional[str] = None + tierarzt: Optional[str] = None + charge_nr: Optional[str] = None + + +class MedicationCreate(BaseModel): + name: str + dosierung: Optional[str] = None + von: Optional[str] = None + bis: Optional[str] = None + notiz: Optional[str] = None + + +# ------------------------------------------------------------------ +# Hilfsfunktion: Eigentümer-Prüfung +# ------------------------------------------------------------------ +def _get_own_dog(conn, dog_id: int, user_id: int): + dog = conn.execute( + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + return dog + + +def _load_passport_data(conn, dog_id: int) -> dict: + dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + meta = conn.execute( + "SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,) + ).fetchone() + vaccinations = conn.execute( + "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,) + ).fetchall() + medications = conn.execute( + "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,) + ).fetchall() + + return { + "dog": dict(dog), + "meta": dict(meta) if meta else {}, + "vaccinations": [dict(v) for v in vaccinations], + "medications": [dict(m) for m in medications], + } + + +# ------------------------------------------------------------------ +# GET /passport/{dog_id} — vollständige Passdaten +# ------------------------------------------------------------------ +@router.get("/{dog_id}") +async def get_passport(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + return _load_passport_data(conn, dog_id) + + +# ------------------------------------------------------------------ +# PUT /passport/{dog_id}/meta +# ------------------------------------------------------------------ +@router.put("/{dog_id}/meta") +async def update_meta(dog_id: int, data: PassportMeta, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute(""" + INSERT INTO dog_passport_meta (dog_id, blutgruppe, allergien, besonderheiten, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(dog_id) DO UPDATE SET + blutgruppe = excluded.blutgruppe, + allergien = excluded.allergien, + besonderheiten = excluded.besonderheiten, + updated_at = excluded.updated_at + """, (dog_id, data.blutgruppe, data.allergien, data.besonderheiten)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# POST /passport/{dog_id}/vaccinations +# ------------------------------------------------------------------ +@router.post("/{dog_id}/vaccinations") +async def add_vaccination(dog_id: int, data: VaccinationCreate, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute(""" + INSERT INTO vaccinations (dog_id, krankheit, datum, naechste, tierarzt, charge_nr) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, data.krankheit, data.datum, data.naechste, data.tierarzt, data.charge_nr)) + row = conn.execute( + "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /passport/{dog_id}/vaccinations/{vacc_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/vaccinations/{vacc_id}", status_code=204) +async def delete_vaccination(dog_id: int, vacc_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute( + "DELETE FROM vaccinations WHERE id=? AND dog_id=?", (vacc_id, dog_id) + ) + + +# ------------------------------------------------------------------ +# POST /passport/{dog_id}/medications +# ------------------------------------------------------------------ +@router.post("/{dog_id}/medications") +async def add_medication(dog_id: int, data: MedicationCreate, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute(""" + INSERT INTO medications (dog_id, name, dosierung, von, bis, notiz) + VALUES (?, ?, ?, ?, ?, ?) + """, (dog_id, data.name, data.dosierung, data.von, data.bis, data.notiz)) + row = conn.execute( + "SELECT * FROM medications WHERE dog_id=? ORDER BY id DESC LIMIT 1", (dog_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# DELETE /passport/{dog_id}/medications/{med_id} +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/medications/{med_id}", status_code=204) +async def delete_medication(dog_id: int, med_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + conn.execute( + "DELETE FROM medications WHERE id=? AND dog_id=?", (med_id, dog_id) + ) + + +# ------------------------------------------------------------------ +# POST /passport/{dog_id}/share — Share-Token erstellen +# ------------------------------------------------------------------ +@router.post("/{dog_id}/share") +async def create_share(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + token = secrets.token_urlsafe(32) + valid_until = (date.today() + timedelta(days=30)).isoformat() + conn.execute(""" + INSERT INTO passport_shares (dog_id, token, valid_until) + VALUES (?, ?, ?) + """, (dog_id, token, valid_until)) + return { + "token": token, + "valid_until": valid_until, + "url": f"/pass/{token}", + } + + +# ------------------------------------------------------------------ +# GET /passport/share/{token} — öffentlicher Endpunkt (kein Auth) +# ------------------------------------------------------------------ +@router.get("/share/{token}") +async def get_shared_passport(token: str): + with db() as conn: + share = conn.execute( + "SELECT * FROM passport_shares WHERE token=?", (token,) + ).fetchone() + if not share: + raise HTTPException(404, "Link nicht gefunden.") + if share["valid_until"] < date.today().isoformat(): + raise HTTPException(410, "Dieser Link ist abgelaufen.") + return _load_passport_data(conn, share["dog_id"]) + + +# ------------------------------------------------------------------ +# GET /passport/{dog_id}/pdf — PDF generieren +# ------------------------------------------------------------------ +@router.get("/{dog_id}/pdf") +async def download_pdf(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + _get_own_dog(conn, dog_id, user["id"]) + data = _load_passport_data(conn, dog_id) + + pdf_bytes = _generate_pdf(data) + dog_name = data["dog"]["name"].replace(" ", "_") + filename = f"Hundepass_{dog_name}.pdf" + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ------------------------------------------------------------------ +# PDF-Generierung mit fpdf2 +# ------------------------------------------------------------------ +def _generate_pdf(data: dict) -> bytes: + try: + from fpdf import FPDF + except ImportError: + raise HTTPException(500, "PDF-Bibliothek nicht verfügbar. Bitte fpdf2 installieren.") + + dog = data["dog"] + meta = data["meta"] + vaccs = data["vaccinations"] + meds = data["medications"] + + # Datumsformatierung DE + def _fmt_date(d): + if not d: + return "–" + try: + return datetime.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return d + + # Geschlecht + geschlecht_map = {"m": "Rüde", "w": "Hündin"} + + pdf = FPDF() + pdf.set_auto_page_break(auto=True, margin=20) + pdf.add_page() + + # ---- Header ---- + pdf.set_fill_color(40, 167, 100) # Ban Yaro Grün + pdf.rect(0, 0, 210, 38, style="F") + + pdf.set_text_color(255, 255, 255) + pdf.set_font("Helvetica", style="B", size=20) + pdf.set_y(8) + pdf.cell(0, 10, "Ban Yaro", align="C", ln=True) + pdf.set_font("Helvetica", size=11) + pdf.cell(0, 8, f"Digitaler Hundepass — {dog['name']}", align="C", ln=True) + pdf.set_font("Helvetica", size=8) + pdf.cell(0, 6, f"Erstellt am {date.today().strftime('%d.%m.%Y')}", align="C", ln=True) + + pdf.set_text_color(30, 30, 30) + pdf.set_y(46) + + # ---- Hundedaten ---- + pdf.set_fill_color(245, 250, 247) + pdf.set_draw_color(200, 200, 200) + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Hundeangaben", ln=True, fill=True, border="B") + pdf.ln(3) + + def _info_row(label, value): + pdf.set_font("Helvetica", style="B", size=9) + pdf.cell(45, 6, label + ":", ln=False) + pdf.set_font("Helvetica", size=9) + pdf.cell(0, 6, str(value) if value else "–", ln=True) + + _info_row("Name", dog["name"]) + _info_row("Rasse", dog.get("rasse") or "–") + _info_row("Geburtstag", _fmt_date(dog.get("geburtstag"))) + _info_row("Geschlecht", geschlecht_map.get(dog.get("geschlecht", ""), "–")) + _info_row("Chip-Nr.", dog.get("chip_nr") or "–") + if meta.get("blutgruppe"): + _info_row("Blutgruppe", meta["blutgruppe"]) + + pdf.ln(5) + + # ---- Allergien & Besonderheiten ---- + if meta.get("allergien") or meta.get("besonderheiten"): + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Allergien & Besonderheiten", ln=True, fill=True, border="B") + pdf.ln(3) + if meta.get("allergien"): + pdf.set_font("Helvetica", style="B", size=9) + pdf.cell(45, 6, "Allergien:", ln=False) + pdf.set_font("Helvetica", size=9) + pdf.multi_cell(0, 6, meta["allergien"]) + if meta.get("besonderheiten"): + pdf.set_font("Helvetica", style="B", size=9) + pdf.cell(45, 6, "Besonderheiten:", ln=False) + pdf.set_font("Helvetica", size=9) + pdf.multi_cell(0, 6, meta["besonderheiten"]) + pdf.ln(5) + + # ---- Impfungen ---- + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Impfungen", ln=True, fill=True, border="B") + pdf.ln(3) + + if vaccs: + # Tabellen-Header + pdf.set_fill_color(220, 240, 228) + pdf.set_font("Helvetica", style="B", size=8) + pdf.cell(50, 6, "Krankheit", border=1, fill=True) + pdf.cell(25, 6, "Datum", border=1, fill=True) + pdf.cell(25, 6, "Nächste fällig", border=1, fill=True) + pdf.cell(55, 6, "Tierarzt", border=1, fill=True) + pdf.cell(35, 6, "Charge-Nr.", border=1, fill=True, ln=True) + + pdf.set_font("Helvetica", size=8) + for i, v in enumerate(vaccs): + fill = (i % 2 == 0) + pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255) + pdf.cell(50, 6, (v["krankheit"] or "")[:28], border=1, fill=fill) + pdf.cell(25, 6, _fmt_date(v["datum"]), border=1, fill=fill) + pdf.cell(25, 6, _fmt_date(v["naechste"]), border=1, fill=fill) + pdf.cell(55, 6, (v["tierarzt"] or "–")[:32], border=1, fill=fill) + pdf.cell(35, 6, (v["charge_nr"] or "–")[:20], border=1, fill=fill, ln=True) + else: + pdf.set_font("Helvetica", style="I", size=9) + pdf.set_text_color(140, 140, 140) + pdf.cell(0, 6, "Keine Impfungen eingetragen.", ln=True) + pdf.set_text_color(30, 30, 30) + + pdf.ln(5) + + # ---- Medikamente ---- + pdf.set_font("Helvetica", style="B", size=12) + pdf.set_fill_color(235, 247, 240) + pdf.cell(0, 8, " Medikamente", ln=True, fill=True, border="B") + pdf.ln(3) + + if meds: + pdf.set_fill_color(220, 240, 228) + pdf.set_font("Helvetica", style="B", size=8) + pdf.cell(55, 6, "Medikament", border=1, fill=True) + pdf.cell(35, 6, "Dosierung", border=1, fill=True) + pdf.cell(25, 6, "Von", border=1, fill=True) + pdf.cell(25, 6, "Bis", border=1, fill=True) + pdf.cell(50, 6, "Notiz", border=1, fill=True, ln=True) + + pdf.set_font("Helvetica", size=8) + for i, m in enumerate(meds): + fill = (i % 2 == 0) + pdf.set_fill_color(248, 252, 250) if fill else pdf.set_fill_color(255, 255, 255) + pdf.cell(55, 6, (m["name"] or "")[:32], border=1, fill=fill) + pdf.cell(35, 6, (m["dosierung"] or "–")[:22], border=1, fill=fill) + pdf.cell(25, 6, _fmt_date(m["von"]), border=1, fill=fill) + bis = _fmt_date(m["bis"]) if m["bis"] else "dauerhaft" + pdf.cell(25, 6, bis, border=1, fill=fill) + pdf.cell(50, 6, (m["notiz"] or "–")[:30], border=1, fill=fill, ln=True) + else: + pdf.set_font("Helvetica", style="I", size=9) + pdf.set_text_color(140, 140, 140) + pdf.cell(0, 6, "Keine Medikamente eingetragen.", ln=True) + pdf.set_text_color(30, 30, 30) + + # ---- Footer ---- + pdf.set_y(-15) + pdf.set_font("Helvetica", style="I", size=8) + pdf.set_text_color(140, 140, 140) + pdf.cell(0, 5, "Erstellt mit Ban Yaro — banyaro.app", align="C", ln=True) + + return bytes(pdf.output()) diff --git a/backend/routes/playdate.py b/backend/routes/playdate.py new file mode 100644 index 0000000..01d57ae --- /dev/null +++ b/backend/routes/playdate.py @@ -0,0 +1,364 @@ +"""BAN YARO — Playdate-Matching""" + +import math +import logging +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() +logger = logging.getLogger(__name__) + + +# ------------------------------------------------------------------ +# Haversine +# ------------------------------------------------------------------ +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + R = 6371.0 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) + * math.sin(dlon / 2) ** 2) + return R * 2 * math.asin(math.sqrt(a)) + + +def _calc_alter(geburtstag: Optional[str]) -> Optional[str]: + """Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'.""" + if not geburtstag: + return None + try: + from datetime import date + geb = date.fromisoformat(geburtstag[:10]) + today = date.today() + monate = (today.year - geb.year) * 12 + (today.month - geb.month) + if today.day < geb.day: + monate -= 1 + if monate < 0: + return None + if monate < 24: + return f"{monate} {'Monat' if monate == 1 else 'Monate'}" + jahre = monate // 12 + return f"{jahre} {'Jahr' if jahre == 1 else 'Jahre'}" + except Exception: + return None + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ +class ListingUpsert(BaseModel): + dog_id: int + lat: float + lon: float + ort_name: Optional[str] = None + radius_km: int = 10 + beschreibung: Optional[str] = None + + +class RequestCreate(BaseModel): + to_dog_id: int + nachricht: Optional[str] = None + + +class RequestPatch(BaseModel): + status: str # accepted | declined + + +# ------------------------------------------------------------------ +# Helpers — Konversation für Playdate öffnen (ohne Freundschaftspflicht) +# ------------------------------------------------------------------ +def _ensure_conversation(conn, user_a: int, user_b: int) -> int: + a, b = (min(user_a, user_b), max(user_a, user_b)) + existing = conn.execute( + "SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?", + (a, b) + ).fetchone() + if existing: + return existing["id"] + cur = conn.execute( + "INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)", + (a, b) + ) + return cur.lastrowid + + +# ------------------------------------------------------------------ +# Routes +# ------------------------------------------------------------------ + +@router.get("/nearby") +async def nearby(lat: float, lon: float, radius: int = 10, + user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + rows = conn.execute(""" + SELECT pl.id AS listing_id, + pl.lat, pl.lon, pl.ort_name, pl.beschreibung, + d.id AS dog_id, d.name AS dog_name, d.rasse, + d.geburtstag, d.foto_url, d.geschlecht + FROM playdate_listings pl + JOIN dogs d ON d.id = pl.dog_id + WHERE pl.aktiv = 1 + AND pl.user_id != ? + """, (uid,)).fetchall() + + result = [] + for r in rows: + dist = _haversine(lat, lon, r["lat"], r["lon"]) + if dist <= radius: + result.append({ + "listing_id": r["listing_id"], + "dog_id": r["dog_id"], + "dog_name": r["dog_name"], + "rasse": r["rasse"], + "alter": _calc_alter(r["geburtstag"]), + "geschlecht": r["geschlecht"], + "foto_url": r["foto_url"], + "ort_name": r["ort_name"], + "beschreibung": r["beschreibung"], + "entfernung_km": round(dist, 1), + }) + + result.sort(key=lambda x: x["entfernung_km"]) + return result + + +@router.put("/listing", status_code=200) +async def upsert_listing(data: ListingUpsert, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + # Sicherstellen dass der Hund dem User gehört + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + existing = conn.execute( + "SELECT id FROM playdate_listings WHERE dog_id=?", + (data.dog_id,) + ).fetchone() + + if existing: + conn.execute(""" + UPDATE playdate_listings + SET lat=?, lon=?, ort_name=?, radius_km=?, beschreibung=?, + aktiv=1, updated_at=datetime('now') + WHERE dog_id=? + """, (data.lat, data.lon, data.ort_name, data.radius_km, + data.beschreibung, data.dog_id)) + return {"ok": True, "id": existing["id"]} + else: + cur = conn.execute(""" + INSERT INTO playdate_listings + (dog_id, user_id, lat, lon, ort_name, radius_km, beschreibung) + VALUES (?,?,?,?,?,?,?) + """, (data.dog_id, uid, data.lat, data.lon, data.ort_name, + data.radius_km, data.beschreibung)) + return {"ok": True, "id": cur.lastrowid} + + +@router.delete("/listing/{dog_id}", status_code=200) +async def deactivate_listing(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + row = conn.execute( + "SELECT id FROM playdate_listings WHERE dog_id=? AND user_id=?", + (dog_id, uid) + ).fetchone() + if not row: + raise HTTPException(404, "Inserat nicht gefunden.") + conn.execute( + "UPDATE playdate_listings SET aktiv=0, updated_at=datetime('now') WHERE dog_id=?", + (dog_id,) + ) + return {"ok": True} + + +@router.get("/my-listing/{dog_id}") +async def my_listing(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + row = conn.execute( + """SELECT id, dog_id, lat, lon, ort_name, radius_km, beschreibung, aktiv + FROM playdate_listings WHERE dog_id=? AND user_id=?""", + (dog_id, uid) + ).fetchone() + if not row: + return None + return dict(row) + + +@router.post("/request", status_code=201) +async def create_request(data: RequestCreate, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + # Eigenen Hund ermitteln — nimm den ersten aktiven Hund des Users + own_dog = conn.execute( + "SELECT id FROM dogs WHERE user_id=? ORDER BY id LIMIT 1", + (uid,) + ).fetchone() + if not own_dog: + raise HTTPException(400, "Du hast noch keinen Hund eingetragen.") + + from_dog_id = own_dog["id"] + + # Zielhund + Besitzer prüfen + target = conn.execute( + "SELECT d.id, d.user_id FROM dogs d WHERE d.id=?", + (data.to_dog_id,) + ).fetchone() + if not target: + raise HTTPException(404, "Zielhund nicht gefunden.") + if target["user_id"] == uid: + raise HTTPException(400, "Du kannst nicht dir selbst eine Anfrage schicken.") + + to_user_id = target["user_id"] + + # Doppelte Anfrage verhindern + existing = conn.execute( + "SELECT id, status FROM playdate_requests WHERE from_dog_id=? AND to_dog_id=?", + (from_dog_id, data.to_dog_id) + ).fetchone() + if existing: + if existing["status"] == "pending": + raise HTTPException(409, "Du hast bereits eine offene Anfrage an diesen Hund.") + # Alte abgelehnte Anfrage: löschen und neu anlegen + conn.execute( + "DELETE FROM playdate_requests WHERE id=?", + (existing["id"],) + ) + + cur = conn.execute(""" + INSERT INTO playdate_requests + (from_dog_id, to_dog_id, from_user_id, to_user_id, nachricht) + VALUES (?,?,?,?,?) + """, (from_dog_id, data.to_dog_id, uid, to_user_id, data.nachricht)) + request_id = cur.lastrowid + + # Chat-Konversation anlegen (ohne Freundschaftspflicht) + conv_id = _ensure_conversation(conn, uid, to_user_id) + + # Erste Nachricht mit Kontext senden + intro = f"Hallo! Ich habe eine Playdate-Anfrage für unsere Hunde geschickt." + if data.nachricht: + intro += f" Meine Nachricht: {data.nachricht}" + conn.execute(""" + INSERT INTO direct_messages (conversation_id, sender_id, text) + VALUES (?,?,?) + """, (conv_id, uid, intro)) + conn.execute( + "UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?", + (conv_id,) + ) + + try: + from routes.push import send_push_to_user + send_push_to_user(to_user_id, { + "title": "Playdate-Anfrage", + "body": f"{user['name']} möchte ein Treffen vereinbaren!", + "type": "playdate_request", + "tag": f"playdate-{request_id}", + "data": {"page": "playdate"}, + }) + except Exception: + pass + + return {"ok": True, "request_id": request_id, "conversation_id": conv_id} + + +@router.get("/requests") +async def list_requests(user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + incoming = conn.execute(""" + SELECT pr.id, pr.status, pr.nachricht, pr.created_at, + pr.from_user_id, + uf.name AS from_user_name, + df.name AS from_dog_name, df.rasse AS from_dog_rasse, + df.foto_url AS from_dog_foto, + df.geburtstag AS from_dog_geburtstag, + dt.name AS to_dog_name + FROM playdate_requests pr + JOIN users uf ON uf.id = pr.from_user_id + JOIN dogs df ON df.id = pr.from_dog_id + JOIN dogs dt ON dt.id = pr.to_dog_id + WHERE pr.to_user_id = ? + ORDER BY pr.created_at DESC + """, (uid,)).fetchall() + + outgoing = conn.execute(""" + SELECT pr.id, pr.status, pr.nachricht, pr.created_at, + pr.to_user_id, + ut.name AS to_user_name, + dt.name AS to_dog_name, dt.rasse AS to_dog_rasse, + dt.foto_url AS to_dog_foto, + df.name AS from_dog_name + FROM playdate_requests pr + JOIN users ut ON ut.id = pr.to_user_id + JOIN dogs dt ON dt.id = pr.to_dog_id + JOIN dogs df ON df.id = pr.from_dog_id + WHERE pr.from_user_id = ? + ORDER BY pr.created_at DESC + """, (uid,)).fetchall() + + def _enrich(rows, direction): + result = [] + for r in rows: + d = dict(r) + d["direction"] = direction + if direction == "incoming": + d["alter"] = _calc_alter(d.get("from_dog_geburtstag")) + result.append(d) + return result + + return { + "incoming": _enrich(incoming, "incoming"), + "outgoing": _enrich(outgoing, "outgoing"), + } + + +@router.patch("/requests/{req_id}", status_code=200) +async def patch_request(req_id: int, data: RequestPatch, + user=Depends(get_current_user)): + uid = user["id"] + if data.status not in ("accepted", "declined"): + raise HTTPException(400, "Status muss 'accepted' oder 'declined' sein.") + + with db() as conn: + req = conn.execute( + "SELECT * FROM playdate_requests WHERE id=? AND to_user_id=?", + (req_id, uid) + ).fetchone() + if not req: + raise HTTPException(404, "Anfrage nicht gefunden.") + if req["status"] != "pending": + raise HTTPException(409, "Anfrage wurde bereits beantwortet.") + + conn.execute( + "UPDATE playdate_requests SET status=? WHERE id=?", + (data.status, req_id) + ) + + conv_id = None + if data.status == "accepted": + conv_id = _ensure_conversation(conn, uid, req["from_user_id"]) + + try: + from routes.push import send_push_to_user + verb = "angenommen" if data.status == "accepted" else "abgelehnt" + send_push_to_user(req["from_user_id"], { + "title": f"Playdate {verb}!", + "body": f"{user['name']} hat deine Anfrage {verb}.", + "type": "playdate_response", + "tag": f"playdate-{req_id}", + "data": {"page": "playdate"}, + }) + except Exception: + pass + + return {"ok": True, "conversation_id": conv_id} diff --git a/backend/routes/recalls.py b/backend/routes/recalls.py new file mode 100644 index 0000000..d0182a3 --- /dev/null +++ b/backend/routes/recalls.py @@ -0,0 +1,138 @@ +"""BAN YARO — Rückruf-Alarm (Tierfutter) +RASFF EU Rapid Alert System for Food and Feed +""" + +import logging +import httpx +from fastapi import APIRouter +from database import db + +router = APIRouter() +logger = logging.getLogger(__name__) + +RASFF_URL = "https://webgate.ec.europa.eu/rasff-window/backend/public/notification/list/with-filters" +RASFF_PARAMS = { + "filters": '{"subject.product_category":["pet food and animal feed"]}', + "pageNumber": 0, + "pageSize": 20, + "sortColumn": "notificationDate", + "sortDirection": "DESC", +} + + +# ------------------------------------------------------------------ +# GET /api/recalls — Letzte 50 Rückrufe +# ------------------------------------------------------------------ +@router.get("") +async def list_recalls(q: str = ""): + with db() as conn: + if q: + like = f"%{q}%" + rows = conn.execute(""" + SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at + FROM feed_recalls + WHERE titel LIKE ? OR produkt LIKE ? OR gefahr LIKE ? OR herkunft LIKE ? + ORDER BY datum DESC + LIMIT 50 + """, (like, like, like, like)).fetchall() + else: + rows = conn.execute(""" + SELECT id, external_id, titel, produkt, gefahr, herkunft, datum, quelle, url, created_at + FROM feed_recalls + ORDER BY datum DESC + LIMIT 50 + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# Interne Hilfsfunktion: RASFF API abfragen +# ------------------------------------------------------------------ +async def fetch_rasff_recalls() -> list[dict]: + """Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück.""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(RASFF_URL, params=RASFF_PARAMS) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.error(f"RASFF API-Fehler: {e}") + return [] + + entries = [] + try: + items = data.get("data", {}).get("list", []) + for item in items: + reference = item.get("reference", "") + if not reference: + continue + + # Datum + datum_raw = item.get("notificationDate", "") + datum = datum_raw[:10] if datum_raw else "" + + # Produkt + subject = item.get("subject") or {} + produkt = subject.get("product", "") or "" + + # Gefahr + hazards = subject.get("hazard") or [] + gefahr = "" + if hazards: + gefahr = hazards[0].get("hazardDescription", "") or "" + + # Herkunft + origin = item.get("origin") or {} + herkunft = origin.get("name", "") or "" + + # URL zur RASFF-Seite + url = f"https://webgate.ec.europa.eu/rasff-window/screen/notificationDetail?notifRef={reference}" + + entries.append({ + "external_id": reference, + "titel": produkt or reference, + "produkt": produkt, + "gefahr": gefahr, + "herkunft": herkunft, + "datum": datum, + "quelle": "rasff", + "url": url, + }) + except Exception as e: + logger.error(f"RASFF Parsing-Fehler: {e}") + + return entries + + +# ------------------------------------------------------------------ +# Interne Hilfsfunktion: Neue Einträge in DB speichern +# ------------------------------------------------------------------ +def save_new_recalls(entries: list[dict]) -> list[dict]: + """Speichert neue Einträge und gibt die Liste der neuen Einträge zurück.""" + new_entries = [] + for entry in entries: + try: + with db() as conn: + exists = conn.execute( + "SELECT id FROM feed_recalls WHERE external_id=?", + (entry["external_id"],) + ).fetchone() + if not exists: + conn.execute(""" + INSERT INTO feed_recalls + (external_id, titel, produkt, gefahr, herkunft, datum, quelle, url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, ( + entry["external_id"], + entry["titel"], + entry["produkt"], + entry["gefahr"], + entry["herkunft"], + entry["datum"], + entry["quelle"], + entry["url"], + )) + new_entries.append(entry) + except Exception as e: + logger.warning(f"Recall DB-Fehler für {entry.get('external_id')}: {e}") + return new_entries diff --git a/backend/routes/streak.py b/backend/routes/streak.py new file mode 100644 index 0000000..ea03522 --- /dev/null +++ b/backend/routes/streak.py @@ -0,0 +1,114 @@ +"""BAN YARO — Trainings-Streak""" + +import datetime +from fastapi import APIRouter, Depends, HTTPException +from database import db +from auth import get_current_user + +router = APIRouter() + +_today = lambda: datetime.date.today().isoformat() +_yesterday = lambda: (datetime.date.today() - datetime.timedelta(days=1)).isoformat() + + +# ------------------------------------------------------------------ +# GET /streak/leaderboard — Top-10 Streaks (öffentliche Hunde) +# Muss VOR /{dog_id} stehen, sonst greift der int-Parameter zuerst. +# ------------------------------------------------------------------ +@router.get("/streak/leaderboard") +async def get_leaderboard(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute(""" + SELECT + u.name AS user_name, + d.name AS dog_name, + d.rasse, + d.foto_url, + ts.current_streak + FROM training_streaks ts + JOIN dogs d ON d.id = ts.dog_id + JOIN users u ON u.id = ts.user_id + WHERE ts.current_streak > 0 + AND (d.is_public = 1 OR d.user_id = ts.user_id) + ORDER BY ts.current_streak DESC + LIMIT 10 + """).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /streak/{dog_id} — aktueller Streak eines Hundes +# ------------------------------------------------------------------ +@router.get("/streak/{dog_id}") +async def get_streak(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + row = conn.execute( + "SELECT current_streak, longest_streak, last_training_date " + "FROM training_streaks WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone() + + if not row: + return {"current_streak": 0, "longest_streak": 0, "last_training_date": None} + return dict(row) + + +# ------------------------------------------------------------------ +# POST /streak/{dog_id}/ping — Training heute registrieren +# ------------------------------------------------------------------ +@router.post("/streak/{dog_id}/ping") +async def ping_streak(dog_id: int, user=Depends(get_current_user)): + uid = user["id"] + today = _today() + yest = _yesterday() + + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, uid) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + row = conn.execute( + "SELECT current_streak, longest_streak, last_training_date " + "FROM training_streaks WHERE user_id=? AND dog_id=?", + (uid, dog_id) + ).fetchone() + + if row: + cur = row["current_streak"] + longest = row["longest_streak"] + last = row["last_training_date"] + + if last == today: + # Bereits heute gepingt — nichts tun + return {"current_streak": cur, "longest_streak": longest, "last_training_date": last} + elif last == yest: + cur += 1 + else: + cur = 1 + + longest = max(longest, cur) + + conn.execute( + "UPDATE training_streaks SET current_streak=?, longest_streak=?, last_training_date=? " + "WHERE user_id=? AND dog_id=?", + (cur, longest, today, uid, dog_id) + ) + else: + cur = 1 + longest = 1 + conn.execute( + "INSERT INTO training_streaks (user_id, dog_id, current_streak, longest_streak, last_training_date) " + "VALUES (?,?,?,?,?)", + (uid, dog_id, cur, longest, today) + ) + + return {"current_streak": cur, "longest_streak": longest, "last_training_date": today} diff --git a/backend/routes/tieraerzte.py b/backend/routes/tieraerzte.py index 55107ec..48287f9 100644 --- a/backend/routes/tieraerzte.py +++ b/backend/routes/tieraerzte.py @@ -63,15 +63,68 @@ def _fmt_opening_hours(raw: str | None) -> str | None: return result +@router.get("/my-favorite") +async def get_my_favorite(user=Depends(get_current_user)): + """Favoriten-Tierarzt des Users (oder null).""" + with db() as conn: + row = conn.execute( + """SELECT t.* FROM tieraerzte t + JOIN favorite_vets fv ON fv.vet_id = t.id + WHERE fv.user_id = ? + LIMIT 1""", + (user["id"],) + ).fetchone() + if not row: + return None + return dict(row) + + +@router.post("/{vet_id}/favorite") +async def toggle_favorite(vet_id: int, user=Depends(get_current_user)): + """Tierarzt als Favorit setzen oder entfernen (toggle). Gibt {is_favorite: bool} zurück.""" + with db() as conn: + vet = conn.execute( + "SELECT id FROM tieraerzte WHERE id=?", (vet_id,) + ).fetchone() + if not vet: + raise HTTPException(404, "Tierarzt nicht gefunden.") + + existing = conn.execute( + "SELECT 1 FROM favorite_vets WHERE user_id=? AND vet_id=?", + (user["id"], vet_id) + ).fetchone() + + if existing: + conn.execute( + "DELETE FROM favorite_vets WHERE user_id=? AND vet_id=?", + (user["id"], vet_id) + ) + return {"is_favorite": False} + else: + conn.execute( + "INSERT INTO favorite_vets (user_id, vet_id) VALUES (?, ?)", + (user["id"], vet_id) + ) + return {"is_favorite": True} + + @router.get("") async def list_tieraerzte(user=Depends(get_current_user)): - """Alle Tierärzte des Users — aktive zuerst, dann inaktive.""" + """Alle Tierärzte des Users — aktive zuerst, dann inaktive. Enthält is_favorite.""" with db() as conn: rows = conn.execute( "SELECT * FROM tieraerzte WHERE user_id=? ORDER BY aktiv DESC, name", (user["id"],) ).fetchall() - return [dict(r) for r in rows] + favs = {r["vet_id"] for r in conn.execute( + "SELECT vet_id FROM favorite_vets WHERE user_id=?", (user["id"],) + ).fetchall()} + result = [] + for r in rows: + d = dict(r) + d["is_favorite"] = r["id"] in favs + result.append(d) + return result @router.get("/osm-nearby") diff --git a/backend/scheduler.py b/backend/scheduler.py index 68a4c07..4dcab4c 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -132,8 +132,24 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 19:00 Uhr — Streak-Erinnerung + _scheduler.add_job( + _job_streak_reminder, + CronTrigger(hour=19, minute=0), + id="streak_reminder", + replace_existing=True, + misfire_grace_time=3600, + ) + # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF) + _scheduler.add_job( + _job_recall_check, + CronTrigger(hour=8, minute=0), + id="recall_check", + replace_existing=True, + 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. 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. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -855,6 +871,8 @@ async def _job_status_report(): "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "ki_health_report": "KI-Gesundheitsberichte", "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", + "streak_reminder": "Streak-Erinnerung (täglich 19:00)", + "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", } job_rows_html = "" job_rows_txt = "" @@ -1172,3 +1190,79 @@ async def _job_hdm_winner(): logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.") _log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)") + + +# ------------------------------------------------------------------ +# JOB: Streak-Erinnerung (täglich 19:00) +# ------------------------------------------------------------------ +async def _job_streak_reminder(): + """ + Findet alle User die heute noch nicht trainiert haben (last_training_date < heute) + und deren current_streak > 0. Sendet einen motivierenden Push pro Hund. + """ + today = str(date.today()) + logger.info(f"Streak-Reminder Job läuft für {today}") + + with db() as conn: + rows = conn.execute(""" + SELECT ts.user_id, ts.dog_id, ts.current_streak, d.name AS dog_name + FROM training_streaks ts + JOIN dogs d ON d.id = ts.dog_id + WHERE ts.current_streak > 0 + AND (ts.last_training_date IS NULL OR ts.last_training_date < ?) + """, (today,)).fetchall() + + sent_total = 0 + for r in rows: + n = r["current_streak"] + sent = send_push_to_user(r["user_id"], { + "type": "streak_reminder", + "title": f"🔥 {r['dog_name']} wartet auf sein Training!", + "body": f"Streak: {n} {'Tag' if n == 1 else 'Tage'} — nicht jetzt aufhören.", + "data": {"page": "uebungen"}, + "tag": f"streak-{r['dog_id']}-{today}", + }) + sent_total += sent + + logger.info(f"Streak-Reminder Job fertig — {len(rows)} Hunde geprüft, {sent_total} Push gesendet.") + _log_job("streak_reminder", "ok", f"{sent_total} Push an {len(rows)} Hunde") + + +# ------------------------------------------------------------------ +# JOB: Tierfutter-Rückrufe prüfen (RASFF, täglich 08:00) +# ------------------------------------------------------------------ +async def _job_recall_check(): + """ + Fragt täglich die RASFF EU-API nach neuen Tierfutter-Rückrufen ab. + Neue Einträge werden in DB gespeichert, für jeden wird ein Push + an alle abonnierten User gesendet. + """ + logger.info("Rückruf-Check Job läuft") + try: + from routes.recalls import fetch_rasff_recalls, save_new_recalls + entries = await fetch_rasff_recalls() + if not entries: + logger.info("Rückruf-Check: Keine Einträge von RASFF erhalten (API-Fehler oder leer).") + _log_job("recall_check", "ok", "0 neue Rückrufe (API leer)") + return + + new_entries = save_new_recalls(entries) + logger.info(f"Rückruf-Check: {len(new_entries)} neue von {len(entries)} geprüften Einträgen.") + + for entry in new_entries: + produkt = entry.get("produkt") or entry.get("titel") or "Unbekanntes Produkt" + gefahr = entry.get("gefahr") or "Bitte Produktdetails prüfen" + ext_id = entry["external_id"] + body = f"{produkt} — {gefahr[:80]}" + send_push_to_all({ + "title": "⚠️ Tierfutter-Rückruf", + "body": body, + "data": {"page": "recalls"}, + "tag": f"recall-{ext_id}", + }) + logger.info(f"Rückruf-Push gesendet: {ext_id} — {produkt}") + + _log_job("recall_check", "ok", f"{len(new_entries)} neue Rückrufe") + except Exception as e: + logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}") + _log_job("recall_check", "error", str(e)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 3582760..ddaa344 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6803,3 +6803,124 @@ svg.empty-state-icon { pointer-events: none; letter-spacing: 0.01em; } + +/* ------------------------------------------------------------ + STREAK-WIDGET (Welcome-Seite) + ------------------------------------------------------------ */ +.wc-streak-card { + display: flex; + align-items: center; + gap: var(--space-3); + margin-top: var(--space-5); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-lg, 14px); + background: linear-gradient(135deg, #ff6b00 0%, #c0392b 100%); + color: #fff; + box-shadow: 0 4px 18px rgba(196, 63, 0, 0.35); + position: relative; + overflow: hidden; +} +.wc-streak-card::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at 10% 50%, rgba(255,255,255,0.15) 0%, transparent 60%); + pointer-events: none; +} +.wc-streak-flame-wrap { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.wc-streak-flame { + font-size: 2.2rem; + line-height: 1; + filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3)); +} +.wc-streak-number { + font-size: 2.6rem; + font-weight: 800; + line-height: 1; + letter-spacing: -0.03em; + text-shadow: 0 2px 8px rgba(0,0,0,0.2); +} +.wc-streak-info { + flex: 1; + min-width: 0; +} +.wc-streak-label { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + opacity: 0.95; +} +.wc-streak-best { + font-size: var(--text-xs); + opacity: 0.75; + margin-top: 2px; +} +.wc-streak-lb-btn { + background: rgba(255,255,255,0.2); + border: 1.5px solid rgba(255,255,255,0.45); + border-radius: 50%; + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #fff; + transition: background 0.15s; +} +.wc-streak-lb-btn:active { background: rgba(255,255,255,0.35); } + +/* ------------------------------------------------------------ + KI RASSEN-ERKENNUNG — Ergebnis-Block + ------------------------------------------------------------ */ +.rasse-result-card { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-bottom: var(--space-3); +} +.rasse-result-card--top { + border-color: var(--c-primary); + background: var(--c-primary-subtle, #f0f9ff); +} +.rasse-result-name { + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--c-text); + margin-bottom: var(--space-1); +} +.rasse-result-bar-wrap { + background: var(--c-surface-2); + border-radius: 999px; + height: 8px; + overflow: hidden; + margin: var(--space-2) 0; +} +.rasse-result-bar { + height: 8px; + border-radius: 999px; + background: var(--c-primary); + transition: width 0.6s ease; +} +.rasse-result-bar--dim { + background: var(--c-text-muted, #9ca3af); +} +.rasse-result-pct { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-primary); +} +.rasse-result-pct--dim { + color: var(--c-text-muted); +} +.rasse-result-desc { + font-size: var(--text-xs); + color: var(--c-text-secondary); + margin-top: var(--space-1); + line-height: 1.4; +} diff --git a/backend/static/index.html b/backend/static/index.html index 8356b59..f42f248 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -158,6 +158,9 @@ + - `; + `}; + return `
    @@ -2156,6 +2215,306 @@ window.Page_health = (() => { }); } + // ---------------------------------------------------------- + // MEIN TIERARZT — Kachel + // ---------------------------------------------------------- + async function _loadMeinTierarzt() { + const el = _container.querySelector('#health-mein-tierarzt'); + if (!el) return; + _renderMeinTierarztKachel(el); + } + + function _renderMeinTierarztKachel(el) { + if (!el) return; + const vet = _favoritVet; + const adresse = vet + ? [vet.strasse, [vet.plz, vet.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ') + : ''; + + el.innerHTML = ` +
    +
    + Mein Tierarzt +
    +
    +
    + +
    +
    + ${vet ? ` +
    ${_esc(vet.name)}
    + ${adresse ? `
    ${_esc(adresse)}
    ` : ''} + ${vet.telefon ? ` + ` : ''} + ${vet.notfall_telefon ? ` + ` : ''} + ` : ` +
    + Noch kein Tierarzt als Favorit gespeichert. +
    + + `} +
    + ${vet ? ` + + ` : ''} +
    +
    + `; + + el.querySelector('#health-suche-tierarzt-btn')?.addEventListener('click', () => { + App.navigate('map', { filter: 'tierarzt' }); + }); + + el.querySelector('#health-remove-fav-btn')?.addEventListener('click', async (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + await UI.asyncButton(btn, async () => { + await API.tieraerzte.toggleFavorite(_favoritVet.id); + _favoritVet = null; + const elAgain = _container.querySelector('#health-mein-tierarzt'); + if (elAgain) _renderMeinTierarztKachel(elAgain); + UI.toast.success('Tierarzt-Favorit entfernt.'); + }); + }); + } + + // ---------------------------------------------------------- + // BEFUNDE & DOKUMENTE — Sektion (unabhängig von den Tabs) + // ---------------------------------------------------------- + // Diese Sektion erscheint im "dokument"-Tab als zweite Liste. + // Wir ergänzen _renderDokumente um einen Abschnitt unten. + + function _renderBefundeSection() { + const dog = _appState.activeDog; + const docs = _healthDocs; + const DOC_ICONS = { + blutbild: 'drop', + roentgen: 'file-text', + rezept: 'note', + impfausweis:'certificate', + sonstiges: 'file-text', + }; + const DOC_LABELS = { + blutbild: 'Blutbild', + roentgen: 'Röntgen', + rezept: 'Rezept', + impfausweis:'Impfausweis', + sonstiges: 'Sonstiges', + }; + + const uploadBtn = ` + `; + + const items = docs.length + ? docs.map(doc => { + const icon = DOC_ICONS[doc.typ] || 'file-text'; + const label = DOC_LABELS[doc.typ] || doc.typ; + const isImg = !['pdf'].includes(doc.file_type); + const datum = doc.datum ? UI.time.format(doc.datum + 'T00:00:00') : ''; + return ` +
    +
    + +
    +
    +
    ${_esc(doc.titel)}
    +
    + ${_esc(label)}${datum ? ' · ' + datum : ''} + ${doc.vet_name ? ' · ' + _esc(doc.vet_name) : ''} +
    + ${doc.beschreibung ? `
    ${_esc(doc.beschreibung)}
    ` : ''} + +
    +
    `; + }).join('') + : `

    + Noch keine Befunde hochgeladen. +

    `; + + return ` +
    +
    +
    + Befunde & Dokumente +
    + ${uploadBtn} +
    +
    ${items}
    +
    + `; + } + + function _bindBefundeEvents(content) { + content.querySelector('#health-docs-upload-btn')?.addEventListener('click', () => { + _showBefundUploadModal(); + }); + content.querySelectorAll('[data-action="delete-hdoc"]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const docId = parseInt(btn.dataset.docId); + const ok = window.confirm('Befund wirklich löschen?'); + if (!ok) return; + await UI.asyncButton(btn, async () => { + await API.healthDocs.delete(docId); + _healthDocs = _healthDocs.filter(d => d.id !== docId); + _renderTab(); + UI.toast.success('Befund gelöscht.'); + }); + }); + }); + } + + function _showBefundUploadModal() { + const aktivePraxen = _praxen.filter(p => p.aktiv); + const dog = _appState.activeDog; + + UI.modal.open({ + title: ` Befund hochladen`, + body: ` +
    +
    + + +
    +
    + + +
    +
    + + +
    + ${aktivePraxen.length ? ` +
    + + +
    ` : ''} +
    + + +
    +
    + + +
    +
    +
    + `, + footer: ` + + + `, + }); + + document.getElementById('befund-cancel')?.addEventListener('click', UI.modal.close); + + document.getElementById('befund-file-input')?.addEventListener('change', function () { + const preview = document.getElementById('befund-file-preview'); + if (this.files?.length) { + const f = this.files[0]; + preview.textContent = `${f.name} (${(f.size / 1024 / 1024).toFixed(2)} MB)`; + } else { + preview.textContent = ''; + } + }); + + document.getElementById('befund-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.querySelector('[form="befund-form"][type="submit"]'); + const form = e.target; + const fd = UI.formData(form); + const fileInput = form.querySelector('[name="file"]'); + const file = fileInput?.files?.[0]; + + if (!fd.typ) { UI.toast.warning('Bitte Art des Dokuments wählen.'); return; } + if (!fd.titel) { UI.toast.warning('Bitte Titel eingeben.'); return; } + if (!file) { UI.toast.warning('Bitte eine Datei auswählen.'); return; } + + if (file.size > 10 * 1024 * 1024) { + UI.toast.error('Datei ist zu groß. Maximum: 10 MB.'); + return; + } + + await UI.asyncButton(btn, async () => { + const formData = new FormData(); + formData.append('dog_id', String(dog.id)); + formData.append('typ', fd.typ); + formData.append('titel', fd.titel); + formData.append('beschreibung', fd.beschreibung || ''); + formData.append('datum', fd.datum || ''); + if (fd.vet_id) formData.append('vet_id', fd.vet_id); + formData.append('file', file); + + try { + const doc = await API.healthDocs.upload(formData); + _healthDocs.unshift(doc); + UI.modal.close(); + _renderTab(); + UI.toast.success('Befund hochgeladen.'); + } catch (err) { + UI.toast.error(err.message || 'Fehler beim Hochladen.'); + } + }); + }); + } + // ---------------------------------------------------------- async function _showKiSummary() { const btn = _container.querySelector('#health-ki-btn'); @@ -2323,6 +2682,129 @@ window.Page_health = (() => { }); } + // ---------------------------------------------------------- + // KI-TIERARZTFRAGEN + // ---------------------------------------------------------- + function _showKiTierarzt() { + const dog = _appState.activeDog; + const dogName = dog?.name || ''; + const rasse = dog?.rasse || ''; + const placeholder = dogName + ? `Welche Symptome zeigt ${dogName}? z.B. frisst nicht, schläft viel, lahmt...` + : 'Welche Symptome zeigt dein Hund? z.B. frisst nicht, schläft viel, lahmt...'; + + UI.modal.open({ + title: ' KI-Tierarzt', + body: ` +

    + Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung — + kein Ersatz für einen echten Tierarzt. +

    +
    + +
    + +
    + ⚠️ Hinweis: Dies ist keine medizinische Diagnose. + Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt. +
    `, + footer: ` + + `, + }); + + document.getElementById('ki-tierarzt-submit-btn') + .addEventListener('click', async function () { + const btn = this; + const symptom = document.getElementById('ki-tierarzt-symptom').value.trim(); + const resultEl = document.getElementById('ki-tierarzt-result'); + + if (!symptom) { + UI.toast.warning('Bitte Symptome eingeben.'); + return; + } + + await UI.asyncButton(btn, async () => { + resultEl.style.display = 'none'; + resultEl.innerHTML = ''; + + let result; + try { + result = await API.post('/ki/tierarzt', { + symptom, + dog_id: dog?.id || null, + dog_name: dogName || null, + rasse: rasse || null, + }); + } catch (err) { + if (err.status === 429) { + resultEl.innerHTML = ` +
    + + 5 Anfragen pro Tag erreicht. Morgen wieder verfügbar. +
    `; + } else if (err.status === 503) { + resultEl.innerHTML = ` +
    + KI momentan nicht verfügbar. Bitte später versuchen. +
    `; + } else { + UI.toast.error(err.message || 'Fehler bei der KI-Anfrage.'); + return; + } + resultEl.style.display = ''; + return; + } + + const antwortHtml = _esc(result.antwort) + .replace(/\n\n/g, '

    ') + .replace(/\n/g, '
    '); + const restHtml = result.limit - result.anfragen_heute > 0 + ? `

    + Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar. +

    ` + : `

    + Tageslimit erreicht. Morgen wieder verfügbar. +

    `; + + resultEl.innerHTML = ` +
    +
    + + Einschätzung +
    +

    ${antwortHtml}

    + ${restHtml} +
    +
    + ⚠️ Dies ist keine medizinische Diagnose. + Bei ernsthaften Symptomen sofort zum Tierarzt. +
    `; + resultEl.style.display = ''; + + // Submit-Button ausblenden wenn Limit erschöpft + if (result.anfragen_heute >= result.limit) { + btn.disabled = true; + btn.textContent = 'Limit erreicht'; + } + }); + }); + } + return { init, refresh, openNew, onDogChange }; })(); diff --git a/backend/static/js/pages/playdate.js b/backend/static/js/pages/playdate.js new file mode 100644 index 0000000..60eba05 --- /dev/null +++ b/backend/static/js/pages/playdate.js @@ -0,0 +1,708 @@ +/* ============================================================ + BAN YARO — Playdate-Matching + Spielkameraden in der Nähe finden, Inserate verwalten, Anfragen + ============================================================ */ + +window.Page_playdate = (() => { + + let _container = null; + let _appState = null; + let _activeTab = 'nearby'; // 'nearby' | 'listings' | 'requests' + let _userPos = null; + let _radius = 10; + let _dogs = []; + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + function _esc(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function _fmtDate(iso) { + if (!iso) return ''; + const d = new Date(iso.replace(' ', 'T')); + return d.toLocaleDateString('de-DE'); + } + + function _dogAvatar(foto_url, name, size = 48) { + const initials = _esc((name || '?').charAt(0).toUpperCase()); + if (foto_url) { + return `${initials}`; + } + return `
    ${initials}
    `; + } + + function _statusBadge(status) { + const map = { + pending: ['warning', 'Ausstehend'], + accepted: ['success', 'Angenommen'], + declined: ['danger', 'Abgelehnt'], + }; + const [type, label] = map[status] || ['default', status]; + const colors = { + warning: 'var(--c-warning, #f59e0b)', + success: 'var(--c-success, #10b981)', + danger: 'var(--c-danger, #ef4444)', + default: 'var(--c-text-muted)', + }; + return `${label}`; + } + + // ------------------------------------------------------------------ + // INIT + // ------------------------------------------------------------------ + async function init(container, appState) { + _container = container; + _appState = appState; + _dogs = appState.dogs?.filter(d => !d.is_guest) || []; + _render(); + _switchTab(_activeTab); + } + + function refresh() { + _dogs = _appState?.dogs?.filter(d => !d.is_guest) || []; + _switchTab(_activeTab); + } + + function onDogChange() { + _dogs = _appState?.dogs?.filter(d => !d.is_guest) || []; + if (_activeTab === 'listings') _loadListings(); + } + + // ------------------------------------------------------------------ + // RENDER — Grundstruktur mit Tabs + // ------------------------------------------------------------------ + function _render() { + _container.innerHTML = ` +
    + + +
    + + + +
    + + +
    + +
    + `; + + document.getElementById('playdate-tabs').addEventListener('click', e => { + const btn = e.target.closest('.by-tab'); + if (!btn) return; + _switchTab(btn.dataset.tab); + }); + } + + function _switchTab(tab) { + _activeTab = tab; + document.querySelectorAll('#playdate-tabs .by-tab').forEach(b => { + b.classList.toggle('active', b.dataset.tab === tab); + }); + const content = document.getElementById('playdate-content'); + if (!content) return; + + if (tab === 'nearby') _renderNearby(content); + if (tab === 'listings') _renderListings(content); + if (tab === 'requests') _renderRequests(content); + } + + // ------------------------------------------------------------------ + // TAB: IN DER NÄHE + // ------------------------------------------------------------------ + async function _renderNearby(el) { + el.innerHTML = ` +
    + +
    +
    + ${UI.icon('map-pin')} + + ${_userPos ? 'Standort bekannt' : 'Kein Standort'} + +
    + + +
    + + +
    + ${UI.icon('info')} Nur Hunde von Nutzern die sich ebenfalls mit einem Inserat eingetragen haben. + Dein genauer Standort bleibt privat — es wird nur der Ortsname und die ungefähre Entfernung angezeigt. +
    + + +
    +

    + Standort wird ermittelt… +

    +
    +
    + `; + + document.getElementById('nearby-radius').addEventListener('change', e => { + _radius = parseInt(e.target.value, 10); + _loadNearby(); + }); + + document.getElementById('nearby-locate-btn').addEventListener('click', async () => { + const btn = document.getElementById('nearby-locate-btn'); + UI.setLoading(btn, true); + try { + _userPos = await API.getLocation(); + const label = document.getElementById('nearby-location-label'); + if (label) label.textContent = 'Standort aktualisiert'; + await _loadNearby(); + } catch { + UI.toast.error('Standort konnte nicht ermittelt werden.'); + } finally { + UI.setLoading(btn, false); + } + }); + + if (!_userPos) { + try { + _userPos = await API.getLocation(); + const label = document.getElementById('nearby-location-label'); + if (label) label.textContent = 'Standort bekannt'; + } catch { + document.getElementById('nearby-results').innerHTML = ` +
    + ${UI.icon('map-pin')} +

    + Standort konnte nicht automatisch ermittelt werden.
    + Klicke auf "Standort aktualisieren". +

    +
    + `; + return; + } + } + await _loadNearby(); + } + + async function _loadNearby() { + if (!_userPos) return; + const resultsEl = document.getElementById('nearby-results'); + if (!resultsEl) return; + resultsEl.innerHTML = `

    ${UI.icon('spinner')} Suche…

    `; + + try { + const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`); + + if (!data || data.length === 0) { + resultsEl.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Niemand in der Nähe', + text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`, + }); + return; + } + + resultsEl.innerHTML = ` +
    + ${data.map(d => _nearbyCard(d)).join('')} +
    + `; + + resultsEl.querySelectorAll('.playdate-anfrage-btn').forEach(btn => { + btn.addEventListener('click', () => { + const toDogId = parseInt(btn.dataset.dogId, 10); + const dogName = btn.dataset.dogName; + _showRequestModal(toDogId, dogName); + }); + }); + + } catch (err) { + resultsEl.innerHTML = `

    ${err.message}

    `; + } + } + + function _nearbyCard(d) { + return ` +
    +
    + ${_dogAvatar(d.foto_url, d.dog_name, 56)} +
    +
    ${_esc(d.dog_name)}
    + ${d.rasse ? `
    ${_esc(d.rasse)}
    ` : ''} + ${d.alter ? `
    ${_esc(d.alter)}
    ` : ''} +
    +
    + +
    + + ${UI.icon('map-pin')} + ${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt + + ${d.geschlecht ? `${_esc(d.geschlecht)}` : ''} +
    + + ${d.beschreibung ? ` +

    + ${_esc(d.beschreibung)} +

    ` : ''} + + +
    + `; + } + + function _showRequestModal(toDogId, dogName) { + const formId = 'playdate-req-form'; + UI.modal.open({ + title: `Anfrage an ${dogName}`, + body: ` +
    +
    + + +
    +
    + `, + footer: ` + + + `, + }); + + document.getElementById('req-cancel-btn').addEventListener('click', () => UI.modal.close()); + document.getElementById('req-send-btn').addEventListener('click', async () => { + const btn = document.getElementById('req-send-btn'); + const nachricht = document.getElementById('req-nachricht').value.trim(); + await UI.asyncButton(btn, async () => { + const result = await API.post('/playdate/request', { + to_dog_id: toDogId, + nachricht: nachricht || null, + }); + UI.modal.close(); + UI.toast.success('Anfrage gesendet! Ein Chat wurde geöffnet.'); + // Zum Chat navigieren + if (result.conversation_id) { + setTimeout(() => { + App.navigate('chat', true, { conversation_id: result.conversation_id }); + }, 800); + } + }, { errorMsg: null }); + }); + } + + // ------------------------------------------------------------------ + // TAB: MEINE INSERATE + // ------------------------------------------------------------------ + async function _renderListings(el) { + el.innerHTML = `

    ${UI.icon('spinner')} Lädt…

    `; + await _loadListings(el); + } + + async function _loadListings(el) { + const target = el || document.getElementById('playdate-content'); + if (!target) return; + + if (_dogs.length === 0) { + target.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Noch kein Hund', + text: 'Lege zuerst einen Hund in deinem Profil an.', + action: ``, + }); + return; + } + + // Listings für alle eigenen Hunde laden + const listings = {}; + await Promise.all(_dogs.map(async dog => { + try { + const data = await API.get(`/playdate/my-listing/${dog.id}`); + listings[dog.id] = data; + } catch { + listings[dog.id] = null; + } + })); + + target.innerHTML = ` +
    + ${_dogs.map(dog => _listingCard(dog, listings[dog.id])).join('')} +
    + `; + + // Event-Delegation für alle Buttons + target.addEventListener('click', async e => { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + const dogId = parseInt(btn.dataset.dogId, 10); + const dog = _dogs.find(d => d.id === dogId); + + if (action === 'edit') { + _showListingModal(dog, listings[dogId], async () => { + await _loadListings(); + }); + } + if (action === 'deactivate') { + if (!window.confirm(`Inserat für ${dog?.name} deaktivieren?`)) return; + try { + await API.del(`/playdate/listing/${dogId}`); + UI.toast.success('Inserat deaktiviert.'); + await _loadListings(); + } catch (err) { + UI.toast.error(err.message); + } + } + }); + } + + function _listingCard(dog, listing) { + const isAktiv = listing && listing.aktiv; + return ` +
    +
    + ${_dogAvatar(dog.foto_url, dog.name, 44)} +
    +
    ${_esc(dog.name)}
    + ${dog.rasse ? `
    ${_esc(dog.rasse)}
    ` : ''} +
    + + ${isAktiv ? 'Aktiv' : 'Inaktiv'} + +
    + + ${isAktiv ? ` +
    + ${UI.icon('map-pin')} + ${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''} + Radius: ${listing.radius_km} km +
    + ${listing.beschreibung ? ` +

    ${_esc(listing.beschreibung)}

    ` : ''} + ` : ` +

    + Noch kein Inserat — trage dich ein, damit andere dich finden können. +

    + `} + +
    + + ${isAktiv ? ` + ` : ''} +
    +
    + `; + } + + function _showListingModal(dog, existing, onSaved) { + const formId = 'listing-form'; + UI.modal.open({ + title: `Inserat für ${dog.name}`, + body: ` +
    +
    + +
    + + +
    + + +
    + Tipp: Klicke auf ${UI.icon('crosshair')} um deinen Ortsnamen automatisch zu ermitteln. + Nur der Ortsname wird für andere sichtbar — nicht dein genauer Standort. +
    +
    + +
    + + +
    + +
    + + +
    +
    + `, + footer: ` + + + `, + }); + + // GPS-Button + document.getElementById('listing-gps-btn').addEventListener('click', async () => { + const gpsBtn = document.getElementById('listing-gps-btn'); + UI.setLoading(gpsBtn, true); + try { + const pos = await API.getLocation(); + document.getElementById('listing-lat').value = pos.lat; + document.getElementById('listing-lon').value = pos.lon; + + // Reverse-Geocoding für Ortsname + try { + const rev = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${pos.lat}&lon=${pos.lon}&format=json&zoom=10&accept-language=de`, + { cache: 'no-store' } + ); + const geoData = await rev.json(); + const a = geoData.address || {}; + const ort = a.city || a.town || a.village || a.municipality || ''; + if (ort) document.getElementById('listing-ort').value = ort; + } catch {} + UI.toast.success('Standort ermittelt.'); + } catch { + UI.toast.error('Standort konnte nicht ermittelt werden.'); + } finally { + UI.setLoading(gpsBtn, false); + } + }); + + document.getElementById('listing-cancel-btn').addEventListener('click', () => UI.modal.close()); + + document.getElementById('listing-save-btn').addEventListener('click', async () => { + const btn = document.getElementById('listing-save-btn'); + const lat = parseFloat(document.getElementById('listing-lat').value); + const lon = parseFloat(document.getElementById('listing-lon').value); + const ort = document.getElementById('listing-ort').value.trim(); + const rad = parseInt(document.getElementById('listing-radius').value, 10); + const desc = document.getElementById('listing-beschreibung').value.trim(); + + if (!lat || !lon) { + UI.toast.error('Bitte ermittle zuerst deinen Standort über den GPS-Button.'); + return; + } + + await UI.asyncButton(btn, async () => { + await API.put('/playdate/listing', { + dog_id: dog.id, + lat, + lon, + ort_name: ort || null, + radius_km: rad, + beschreibung: desc || null, + }); + UI.modal.close(); + UI.toast.success('Inserat gespeichert!'); + onSaved?.(); + }, { errorMsg: null }); + }); + } + + // ------------------------------------------------------------------ + // TAB: ANFRAGEN + // ------------------------------------------------------------------ + async function _renderRequests(el) { + el.innerHTML = `

    ${UI.icon('spinner')} Lädt…

    `; + try { + const data = await API.get('/playdate/requests'); + const incoming = data.incoming || []; + const outgoing = data.outgoing || []; + + // Badge aktualisieren + const pendingCount = incoming.filter(r => r.status === 'pending').length; + const badge = document.getElementById('playdate-req-badge'); + if (badge) { + badge.textContent = pendingCount; + badge.style.display = pendingCount > 0 ? '' : 'none'; + } + + if (incoming.length === 0 && outgoing.length === 0) { + el.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Noch keine Anfragen', + text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.', + }); + return; + } + + el.innerHTML = ` +
    + + ${incoming.length > 0 ? ` +
    +

    Eingehende Anfragen

    +
    + ${incoming.map(r => _incomingCard(r)).join('')} +
    +
    ` : ''} + + ${outgoing.length > 0 ? ` +
    +

    Ausgehende Anfragen

    +
    + ${outgoing.map(r => _outgoingCard(r)).join('')} +
    +
    ` : ''} + +
    + `; + + // Button-Events (Accept/Decline) + el.querySelectorAll('.req-accept-btn, .req-decline-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const reqId = parseInt(btn.dataset.reqId, 10); + const status = btn.dataset.status; + await UI.asyncButton(btn, async () => { + const result = await API.patch(`/playdate/requests/${reqId}`, { status }); + if (status === 'accepted' && result.conversation_id) { + UI.toast.success('Anfrage angenommen! Chat wurde geöffnet.'); + setTimeout(() => { + App.navigate('chat', true, { conversation_id: result.conversation_id }); + }, 800); + } else { + UI.toast.success(status === 'accepted' ? 'Anfrage angenommen.' : 'Anfrage abgelehnt.'); + } + await _renderRequests(el); + }, { errorMsg: null }); + }); + }); + + // Chat-Buttons + el.querySelectorAll('.req-chat-btn').forEach(btn => { + btn.addEventListener('click', () => { + App.navigate('chat', true); + }); + }); + + } catch (err) { + el.innerHTML = `

    ${err.message}

    `; + } + } + + function _incomingCard(r) { + const isPending = r.status === 'pending'; + return ` +
    +
    + ${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)} +
    +
    ${_esc(r.from_dog_name)}
    +
    + ${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''} + ${r.alter ? _esc(r.alter) + ' · ' : ''} + von ${_esc(r.from_user_name)} +
    +
    ${_fmtDate(r.created_at)}
    +
    + ${_statusBadge(r.status)} +
    + + ${r.nachricht ? ` +
    + "${_esc(r.nachricht)}" +
    ` : ''} + + ${isPending ? ` +
    + + +
    ` : ` + ${r.status === 'accepted' ? ` + ` : ''} + `} +
    + `; + } + + function _outgoingCard(r) { + return ` +
    +
    + ${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)} +
    +
    ${_esc(r.to_dog_name)}
    +
    + ${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''} + von ${_esc(r.to_user_name)} +
    +
    ${_fmtDate(r.created_at)}
    +
    + ${_statusBadge(r.status)} +
    + + ${r.nachricht ? ` +

    + "${_esc(r.nachricht)}" +

    ` : ''} + + ${r.status === 'accepted' ? ` + ` : ''} +
    + `; + } + + // ------------------------------------------------------------------ + return { init, refresh, onDogChange }; +})(); diff --git a/backend/static/js/pages/recalls.js b/backend/static/js/pages/recalls.js new file mode 100644 index 0000000..86ac5d5 --- /dev/null +++ b/backend/static/js/pages/recalls.js @@ -0,0 +1,190 @@ +/* ============================================================ + BAN YARO — Tierfutter-Rückrufe + Seiten-Modul: RASFF EU Rückruf-Alarm für Heimtierfutter. + ============================================================ */ + +window.Page_recalls = (() => { + + // ---------------------------------------------------------- + // MODUL-STATE + // ---------------------------------------------------------- + let _container = null; + let _appState = null; + let _recalls = []; + let _query = ''; + + // ---------------------------------------------------------- + // INIT + // ---------------------------------------------------------- + async function init(container, appState) { + _container = container; + _appState = appState; + _query = ''; + await _render(); + } + + // ---------------------------------------------------------- + // REFRESH + // ---------------------------------------------------------- + async function refresh() { + _recalls = []; + _query = ''; + await _render(); + } + + // ---------------------------------------------------------- + // RENDER + // ---------------------------------------------------------- + async function _render() { + _container.innerHTML = ` + +
    + +

    + Hinweis: Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer + bevor du ein gemeldetes Produkt entsorgst oder zurückgibst. +

    +
    + + +
    + + +
    + + +
    ${UI.skeleton(4)}
    + `; + + // Suchfeld-Handler + _container.querySelector('#recalls-search').addEventListener('input', (e) => { + _query = e.target.value.trim(); + _renderList(); + }); + + await _loadRecalls(); + } + + // ---------------------------------------------------------- + // DATEN LADEN + // ---------------------------------------------------------- + async function _loadRecalls() { + try { + const url = _query ? `/recalls?q=${encodeURIComponent(_query)}` : '/recalls'; + _recalls = await API.get(url); + } catch { + _container.querySelector('#recalls-list').innerHTML = UI.emptyState({ + icon: 'warning-circle', + title: 'Rückrufe konnten nicht geladen werden', + text: 'Bitte versuche es später erneut.', + }); + return; + } + _renderList(); + } + + // ---------------------------------------------------------- + // LISTE RENDERN + // ---------------------------------------------------------- + function _renderList() { + const listEl = _container.querySelector('#recalls-list'); + if (!listEl) return; + + const filtered = _query + ? _recalls.filter(r => { + const q = _query.toLowerCase(); + return (r.titel || '').toLowerCase().includes(q) + || (r.produkt || '').toLowerCase().includes(q) + || (r.gefahr || '').toLowerCase().includes(q) + || (r.herkunft || '').toLowerCase().includes(q); + }) + : _recalls; + + if (!filtered.length) { + const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + listEl.innerHTML = UI.emptyState({ + icon: 'check-circle', + title: 'Aktuell keine Rückrufe', + text: `Letzte Prüfung: ${today}`, + }); + return; + } + + listEl.innerHTML = filtered.map(r => _cardHtml(r)).join(''); + } + + // ---------------------------------------------------------- + // EINZELNE KARTE + // ---------------------------------------------------------- + function _cardHtml(r) { + const datum = r.datum + ? new Date(r.datum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : ''; + + const meta = [ + r.herkunft ? `${UI.icon('globe-hemisphere-west')} ${UI.escape(r.herkunft)}` : '', + datum ? `${UI.icon('calendar-blank')} ${datum}` : '', + r.quelle ? `${UI.escape(r.quelle)}` : '', + ].filter(Boolean).join(' · '); + + const linkHtml = r.url + ? ` + ${UI.icon('arrow-square-out')} Details auf RASFF + ` + : ''; + + return ` +
    + +
    + + + ${UI.escape(r.produkt || r.titel)} + +
    + + + ${r.gefahr ? ` +

    + ${UI.escape(r.gefahr)} +

    ` : ''} + + +
    + ${meta} +
    + + + ${linkHtml ? `
    ${linkHtml}
    ` : ''} +
    + `; + } + + // ---------------------------------------------------------- + // PUBLIC API + // ---------------------------------------------------------- + return { init, refresh }; + +})(); diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index f5019a7..fed9bac 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -1747,6 +1747,16 @@ window.Page_uebungen = (() => { _closeModal(); if (!resp) { UI.toast.error('Speichern fehlgeschlagen.'); return; } + // Streak aktualisieren — fire-and-forget, Toast bei neuem Streak-Rekord + API.post(`/streak/${body.dog_id}/ping`).then(streak => { + if (!streak) return; + if (streak.current_streak > 1 && streak.current_streak === streak.longest_streak) { + setTimeout(() => UI.toast.success(`🔥 Neuer Rekord! ${streak.current_streak} Tage in Folge trainiert!`), 1500); + } else if (streak.current_streak > 1) { + setTimeout(() => UI.toast.info(`🔥 Streak: ${streak.current_streak} Tage in Folge!`), 1500); + } + }).catch(() => {}); + if (resp.ist_top) { UI.toast.success('🎉 Top-Training! Das war eine eurer besten Einheiten.'); } else { diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index e917eca..e3514d5 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -463,6 +463,8 @@ window.Page_welcome = (() => { `).join('')}
    + ${dog?.id ? `
    ` : ''} +

    Mehr entdecken

    ${FEATURES.filter(f => !FEATURE_CARD_PAGES.has(f.page)).map(f => ` @@ -497,9 +499,85 @@ window.Page_welcome = (() => { _updateChipsFromDash(dash); _tryRouteChip(dash); // nach Chips-Update: ggf. Gassirunden-Vorschlag einfügen }).catch(() => { /* Skeleton bleibt sichtbar */ }); + + // Streak-Widget asynchron laden + _loadStreakWidget(dog.id); } } + // ---------------------------------------------------------- + // STREAK-WIDGET + // ---------------------------------------------------------- + async function _loadStreakWidget(dogId) { + const slot = _container.querySelector('#wc-streak-widget'); + if (!slot) return; + + let streak; + try { + streak = await API.get(`/streak/${dogId}`); + } catch { return; } + + if (!streak || (streak.current_streak === 0 && streak.longest_streak === 0)) return; + + slot.innerHTML = _streakWidgetHTML(streak); + + slot.querySelector('#wc-streak-leaderboard-btn')?.addEventListener('click', async () => { + const modalEl = UI.modal.open({ + title: '🔥 Trainings-Bestenliste', + body: '

    Wird geladen…

    ', + }); + let board; + try { board = await API.get('/streak/leaderboard'); } catch { board = []; } + const bodyEl = modalEl?.querySelector('.modal-body'); + if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board); + }); + } + + function _streakWidgetHTML(s) { + const cur = s.current_streak || 0; + const best = s.longest_streak || 0; + return ` +
    +
    + 🔥 + ${cur} +
    +
    +
    Tage in Folge trainiert
    +
    Rekord: ${best} ${best === 1 ? 'Tag' : 'Tage'}
    +
    + +
    `; + } + + function _leaderboardHTML(rows) { + if (!rows || !rows.length) { + return '

    Noch keine Einträge.

    '; + } + const medals = ['🥇', '🥈', '🥉']; + return ` +
    + ${rows.map((r, i) => ` +
    + ${medals[i] || (i + 1) + '.'} + ${r.foto_url + ? `` + : `
    `} +
    +
    ${UI.escape(r.dog_name)}
    +
    ${UI.escape(r.rasse || '')}${r.user_name ? ' · ' + UI.escape(r.user_name) : ''}
    +
    +
    + 🔥 + ${r.current_streak} +
    +
    + `).join('')} +
    `; + } + function _updateHeroFromDash(dash, dog) { const heroBox = _container.querySelector('#wc-hero-box'); if (!heroBox) return; diff --git a/backend/static/js/pages/wiki.js b/backend/static/js/pages/wiki.js index 69f2e4a..b2b6390 100644 --- a/backend/static/js/pages/wiki.js +++ b/backend/static/js/pages/wiki.js @@ -255,6 +255,15 @@ window.Page_wiki = (() => {
    +
    + + +
    -
    ${UI.icon('pie-chart')} Aufteilung nach Kategorie
    +
    ${UI.icon('chart-pie')} Aufteilung nach Kategorie
    ${katBalken || `
    Noch keine Ausgaben dieses Jahr.
    `}
    diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index e7312b4..3e14d48 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -7,6 +7,7 @@ services: - "3012:8000" volumes: - ./data:/data + - /volume1/docker/banyaro/data/media:/data/media:ro env_file: - .env environment: From d082a89a9f7da6c34a580293bcfea32c5b3ba681 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 09:41:05 +0200 Subject: [PATCH 35/63] =?UTF-8?q?Fix:=20Staging=20=E2=80=94=20Container=20?= =?UTF-8?q?erst=20stoppen=20bevor=20DB=20kopiert=20wird?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 76c31a8..37d99ea 100644 --- a/Makefile +++ b/Makefile @@ -118,12 +118,15 @@ staging: check-ssh @git push $(GIT_REMOTE) develop @echo "→ Sync zu DS (Staging)..." @COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH_STAGING)/" - @echo "→ Produktions-DB nach Staging kopieren..." - @ssh $(DS_HOST) "mkdir -p $(DS_PATH_STAGING)/data && cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db" + @echo "→ Staging stoppen + DB aus Produktion kopieren..." + @ssh $(DS_HOST) " \ + cd $(DS_PATH_STAGING) && \ + $(DOCKER) compose -f docker-compose.staging.yml down; \ + mkdir -p $(DS_PATH_STAGING)/data && \ + cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db" @echo "→ Staging rebuild + restart..." @ssh $(DS_HOST) " \ cd $(DS_PATH_STAGING) && \ - $(DOCKER) compose -f docker-compose.staging.yml down && \ $(DOCKER) compose -f docker-compose.staging.yml build && \ $(DOCKER) compose -f docker-compose.staging.yml up -d" @echo "" From 4e3b673c22d2fd800fae6bf822c9d8686517933e Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 09:41:56 +0200 Subject: [PATCH 36/63] Fix: Staging DB-Kopie mit sudo --- Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 37d99ea..a2df0e4 100644 --- a/Makefile +++ b/Makefile @@ -122,8 +122,9 @@ staging: check-ssh @ssh $(DS_HOST) " \ cd $(DS_PATH_STAGING) && \ $(DOCKER) compose -f docker-compose.staging.yml down; \ - mkdir -p $(DS_PATH_STAGING)/data && \ - cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db" + sudo mkdir -p $(DS_PATH_STAGING)/data && \ + sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \ + sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db" @echo "→ Staging rebuild + restart..." @ssh $(DS_HOST) " \ cd $(DS_PATH_STAGING) && \ From 00109a09296d513883c56ab26653815c7f7c0c91 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 09:43:37 +0200 Subject: [PATCH 37/63] =?UTF-8?q?Fix:=20Staging=20=E2=80=94=20DB-Kopie=20a?= =?UTF-8?q?ls=20separates=20make=20staging-db=20Ziel=20(braucht=20sudo=20-?= =?UTF-8?q?t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index a2df0e4..692e7bb 100644 --- a/Makefile +++ b/Makefile @@ -118,22 +118,27 @@ staging: check-ssh @git push $(GIT_REMOTE) develop @echo "→ Sync zu DS (Staging)..." @COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH_STAGING)/" - @echo "→ Staging stoppen + DB aus Produktion kopieren..." - @ssh $(DS_HOST) " \ - cd $(DS_PATH_STAGING) && \ - $(DOCKER) compose -f docker-compose.staging.yml down; \ - sudo mkdir -p $(DS_PATH_STAGING)/data && \ - sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \ - sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db" @echo "→ Staging rebuild + restart..." @ssh $(DS_HOST) " \ cd $(DS_PATH_STAGING) && \ + $(DOCKER) compose -f docker-compose.staging.yml down && \ $(DOCKER) compose -f docker-compose.staging.yml build && \ $(DOCKER) compose -f docker-compose.staging.yml up -d" @echo "" @echo " ✓ Staging fertig — https://staging.banyaro.app" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10" +# ---------------------------------------------------------- +# STAGING-DB — Produktions-DB in Staging kopieren (interaktiv, braucht sudo) +# Aufruf: make staging-db +# ---------------------------------------------------------- +staging-db: check-ssh + @echo "→ Produktions-DB nach Staging kopieren..." + @ssh -t $(DS_HOST) " \ + sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \ + sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \ + echo '✓ DB kopiert'" + # ---------------------------------------------------------- # RELEASE — develop → main → Production (VERSION= pflichtangabe) # Beispiel: make release VERSION=1.1.0 From 5a412c425c19c2d8aae17ade88426b4492b45886 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 09:51:03 +0200 Subject: [PATCH 38/63] =?UTF-8?q?Fix:=20Staging-Media=20=E2=80=94=20PROD?= =?UTF-8?q?=5FMEDIA=5FDIR=20Fallback=20statt=20=C3=BCberlappende=20Docker-?= =?UTF-8?q?Volumes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 21 +++++++++++++++++++-- docker-compose.staging.yml | 3 ++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8b259f7..15136bb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -279,9 +279,26 @@ app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") # User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) -MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +PROD_MEDIA_DIR = os.getenv("PROD_MEDIA_DIR", "") # Staging-only: Fallback auf Prod-Media os.makedirs(MEDIA_DIR, exist_ok=True) -app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") + +if PROD_MEDIA_DIR: + # Staging: erst eigenes media-Verzeichnis, dann Prod-Fallback + from pathlib import Path as _Path + from starlette.responses import FileResponse as _FileResponse + + @app.get("/media/{path:path}") + async def serve_media(path: str): + p = _Path(MEDIA_DIR) / path + if p.is_file(): + return _FileResponse(str(p)) + pp = _Path(PROD_MEDIA_DIR) / path + if pp.is_file(): + return _FileResponse(str(pp)) + raise HTTPException(404, "Media not found") +else: + app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") @app.get("/robots.txt") async def robots(): diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 3e14d48..610c0e2 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -7,12 +7,13 @@ services: - "3012:8000" volumes: - ./data:/data - - /volume1/docker/banyaro/data/media:/data/media:ro + - /volume1/docker/banyaro/data/media:/prod-media:ro env_file: - .env environment: - DB_PATH=/data/banyaro.db - MEDIA_DIR=/data/media + - PROD_MEDIA_DIR=/prod-media - STAGING=true - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 From 2677cff882e1bd3a2d7b104a3924f78d0358a5e8 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 09:57:12 +0200 Subject: [PATCH 39/63] =?UTF-8?q?Fix:=20Ausweis=20=C3=B6ffnet=20neuen=20Ta?= =?UTF-8?q?b=20(kein=20iframe),=20Mein=20Tierarzt=20im=20Praxen-Tab=20inte?= =?UTF-8?q?griert,=20SW=20by-v599?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/js/app.js | 2 +- backend/static/js/pages/dog-profile.js | 8 +------- backend/static/js/pages/health.js | 15 +++++++++++++-- backend/static/sw.js | 2 +- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 66f4bc6..8f56651 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '598'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '599'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 566c3a6..a36fbaa 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -753,13 +753,7 @@ window.Page_dog_profile = (() => { // AUSWEIS // ---------------------------------------------------------- function _showAusweisModal(dogId) { - UI.modal.open({ - title: 'Heimtierausweis', - body: ``, - footer: ` - ${UI.icon('printer')} Drucken`, - size: 'fullscreen', - }); + window.open(`/ausweis/${dogId}`, '_blank', 'noopener'); } // ---------------------------------------------------------- diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 59e2a03..859d880 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -157,7 +157,6 @@ window.Page_health = (() => {
    ${transponderHtml} -
    @@ -1690,12 +1689,24 @@ window.Page_health = (() => { `}; + const favCard = _favoritVet ? ` +
    +
    + ${UI.icon('heart')} Mein Tierarzt +
    + ${renderCard(_favoritVet)} +
    ` : ''; + + const ohneGesetzt = aktive.filter(p => p.id !== _favoritVet?.id); + return `
    ${addBtn}
    + ${favCard}
    - ${aktive.map(renderCard).join('')} + ${ohneGesetzt.map(renderCard).join('')} ${inaktive.length ? `
    diff --git a/backend/static/sw.js b/backend/static/sw.js index 9f13a44..5444477 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v598'; +const CACHE_VERSION = 'by-v599'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From b47a54db39c3693f567668e3d977760d35a2793e Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 10:09:06 +0200 Subject: [PATCH 40/63] Fix: Media-Symlinks beim Start, Sitter-Datenschutztext, Recalls Dark-Mode, Ausweis neuer Tab, SW by-v600 --- backend/main.py | 33 +++++++++++++------------- backend/static/css/components.css | 23 ++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/dog-profile.js | 3 ++- backend/static/js/pages/recalls.js | 8 +++---- backend/static/sw.js | 2 +- 6 files changed, 47 insertions(+), 24 deletions(-) diff --git a/backend/main.py b/backend/main.py index 15136bb..5cf7e95 100644 --- a/backend/main.py +++ b/backend/main.py @@ -48,6 +48,7 @@ async def lifespan(app: FastAPI): init_db() from routes.movies import seed_movies seed_movies() + _link_prod_media() logger.info(f"KI-Modus: {ki.KI_MODE}") sched.start() yield @@ -280,25 +281,25 @@ app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") # User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") -PROD_MEDIA_DIR = os.getenv("PROD_MEDIA_DIR", "") # Staging-only: Fallback auf Prod-Media +PROD_MEDIA_DIR = os.getenv("PROD_MEDIA_DIR", "") # Staging-only: Production-Media einlinken os.makedirs(MEDIA_DIR, exist_ok=True) +app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -if PROD_MEDIA_DIR: - # Staging: erst eigenes media-Verzeichnis, dann Prod-Fallback - from pathlib import Path as _Path - from starlette.responses import FileResponse as _FileResponse - @app.get("/media/{path:path}") - async def serve_media(path: str): - p = _Path(MEDIA_DIR) / path - if p.is_file(): - return _FileResponse(str(p)) - pp = _Path(PROD_MEDIA_DIR) / path - if pp.is_file(): - return _FileResponse(str(pp)) - raise HTTPException(404, "Media not found") -else: - app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") +def _link_prod_media(): + """Staging: symlinkt Production-Media-Verzeichnisse in das Staging-Media-Verzeichnis.""" + if not PROD_MEDIA_DIR or not os.path.isdir(PROD_MEDIA_DIR): + return + import pathlib + staging = pathlib.Path(MEDIA_DIR) + for item in pathlib.Path(PROD_MEDIA_DIR).iterdir(): + link = staging / item.name + if not link.exists() and not link.is_symlink(): + try: + link.symlink_to(item) + logger.info(f"Prod-Media verlinkt: {link} → {item}") + except OSError as e: + logger.warning(f"Symlink fehlgeschlagen {link}: {e}") @app.get("/robots.txt") async def robots(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index ddaa344..0acff7a 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6924,3 +6924,26 @@ svg.empty-state-icon { margin-top: var(--space-1); line-height: 1.4; } + +/* Rückrufe — Warnbanner (Dark-Mode-sicher) */ +.recalls-warning-banner { + background: var(--c-danger-subtle); + border: 1px solid var(--c-danger); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + display: flex; + align-items: flex-start; + gap: var(--space-2); +} +.recalls-warning-icon { + color: var(--c-danger); + flex-shrink: 0; + margin-top: 2px; +} +.recalls-warning-text { + margin: 0; + font-size: var(--text-sm); + color: var(--c-text); + line-height: 1.5; +} diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 8f56651..3cd9d67 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '599'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '600'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index a36fbaa..9f60609 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -213,7 +213,8 @@ window.Page_dog_profile = (() => {
    Sitter-Zugang
    - Gib einem Freund temporären Schreibzugang für diesen Hund + Gib einem Freund temporären Schreibzugang für diesen Hund. + Deine bestehenden Daten und Medien bleiben unsichtbar und privat — der Sitter kann nur neue Einträge anlegen.
    Lade…
    diff --git a/backend/static/js/pages/recalls.js b/backend/static/js/pages/recalls.js index 86ac5d5..fd45d64 100644 --- a/backend/static/js/pages/recalls.js +++ b/backend/static/js/pages/recalls.js @@ -38,13 +38,11 @@ window.Page_recalls = (() => { async function _render() { _container.innerHTML = ` -
    -
    + -

    +

    Hinweis: Prüfe immer das Mindesthaltbarkeitsdatum und die Chargen-Nummer bevor du ein gemeldetes Produkt entsorgst oder zurückgibst.

    diff --git a/backend/static/sw.js b/backend/static/sw.js index 5444477..abcaaed 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v599'; +const CACHE_VERSION = 'by-v600'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 0d31d04275c5a0f60b701676bfcc881a11399064 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 10:11:43 +0200 Subject: [PATCH 41/63] =?UTF-8?q?Feature:=20Ausgaben-Seite=20=E2=80=94=20v?= =?UTF-8?q?isuelles=20Redesign=20aller=203=20Tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Übersicht: Hero-Card mit Gradient statt grauer Zeile, Vormonat-Trendpfeil (+/-%) - Kacheln: Icon oben, Betrag in primary-Farbe, Label klein darunter - Einträge: farbiges Icon-Badge (--kat-color), kompakter Monat-Trennstreifen mit Summe, Betrag fett rechts, Löschen-Icon direkt am Eintrag ohne Modal-Umweg - Statistik: gestapelte Top-2-Kategorien-Balken pro Monat (CSS-only), Donut-Diagramm via CSS conic-gradient, Kategorie-Legende - CSS: 435 neue Zeilen (exp-*) in components.css angehängt, keine bestehenden geändert --- backend/static/css/components.css | 435 ++++++++++++++++++++++++++++ backend/static/js/pages/expenses.js | 182 +++++++++--- 2 files changed, 583 insertions(+), 34 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 0acff7a..1930060 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -6947,3 +6947,438 @@ svg.empty-state-icon { color: var(--c-text); line-height: 1.5; } + +/* ============================================================ + Ausgaben-Tracker (expenses.js) + ============================================================ */ + +/* FAB */ +.exp-fab { + position: fixed; + bottom: calc(var(--nav-height, 64px) + var(--space-4)); + right: var(--space-4); + z-index: 100; + width: 52px; + height: 52px; + border-radius: 50%; + background: var(--c-primary); + color: #fff; + border: none; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 14px rgba(0,0,0,.25); + cursor: pointer; + font-size: 1.35rem; + transition: transform .15s, box-shadow .15s; +} +.exp-fab:active { + transform: scale(.93); + box-shadow: 0 2px 8px rgba(0,0,0,.2); +} + +/* Lade-/Fehler-Zustände */ +.exp-loading { padding: var(--space-4); } +.exp-error { + padding: var(--space-4); + color: var(--c-danger); + font-size: var(--text-sm); + text-align: center; +} +.exp-empty-hint { + color: var(--c-text-secondary); + font-size: var(--text-sm); + padding: var(--space-3) 0; + text-align: center; +} + +/* ---- Hero-Card (Übersicht & Statistik oben) ---- */ +.exp-hero-card { + background: linear-gradient(135deg, var(--c-primary) 0%, color-mix(in srgb, var(--c-primary) 75%, #000) 100%); + color: #fff; + border-radius: var(--radius-xl, 16px); + padding: var(--space-5) var(--space-4); + margin: var(--space-3) var(--space-3) var(--space-4); + text-align: center; + box-shadow: 0 6px 20px rgba(0,0,0,.15); +} +.exp-hero-card--sm { + padding: var(--space-4) var(--space-4); +} +.exp-hero-label { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + opacity: .85; + margin-bottom: var(--space-1); + text-transform: uppercase; + letter-spacing: .04em; +} +.exp-hero-betrag { + font-size: clamp(1.9rem, 7vw, 2.8rem); + font-weight: var(--weight-bold); + line-height: 1.1; + letter-spacing: -.02em; +} +.exp-hero-meta { + margin-top: var(--space-2); + font-size: var(--text-sm); + opacity: .85; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +/* Trend-Badge */ +.exp-trend { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + padding: 2px 8px; + border-radius: 999px; +} +.exp-trend--up { background: rgba(239,68,68,.25); } +.exp-trend--down { background: rgba(16,185,129,.25); } + +/* ---- Kachel-Grid (Übersicht) ---- */ +.exp-kachel-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); + padding: 0 var(--space-3) var(--space-3); +} +.exp-kachel { + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + padding: var(--space-3) var(--space-2); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); +} +.exp-kachel-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.3rem; + margin-bottom: var(--space-1); +} +.exp-kachel-betrag { + font-size: var(--text-sm); + font-weight: var(--weight-bold); + line-height: 1.1; +} +.exp-kachel-label { + font-size: var(--text-xs); + color: var(--c-text-secondary); + line-height: 1.2; +} + +/* ---- Sektion-Block (Verlauf etc.) ---- */ +.exp-section { + margin: 0 var(--space-3) var(--space-4); + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + padding: var(--space-4); +} +.exp-section-title { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + margin-bottom: var(--space-3); + display: flex; + align-items: center; + gap: var(--space-1); + text-transform: uppercase; + letter-spacing: .04em; +} + +/* ---- Balkendiagramm (Verlauf) ---- */ +.exp-bar-chart { + display: flex; + align-items: flex-end; + gap: var(--space-1); + height: 80px; +} +.exp-bar-chart--12 { + height: 90px; + gap: 4px; +} +.exp-bar-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; +} +.exp-bar-item--aktiv .exp-bar-label { + color: var(--c-primary); + font-weight: var(--weight-semibold); +} +.exp-bar-track { + width: 100%; + height: 60px; + background: var(--c-surface-2, #f3f4f6); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + overflow: hidden; +} +.exp-bar-track--stack { + height: 70px; +} +.exp-bar-fill { + width: 100%; + background: var(--c-primary); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + transition: height .4s ease; +} +.exp-bar-fill--aktiv { background: var(--c-primary); } +.exp-stack-seg { + width: 100%; + min-height: 2px; + transition: height .4s ease; +} +.exp-bar-label { + font-size: var(--text-xs); + color: var(--c-text-muted, #9ca3af); + white-space: nowrap; +} +.exp-bar-val { + font-size: var(--text-xs); + color: var(--c-text-secondary); +} + +/* ---- Einträge-Liste ---- */ +.exp-list { + padding: 0 var(--space-3); +} +.exp-month-group { + margin-bottom: var(--space-3); +} +.exp-month-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + background: var(--c-surface-2, #f3f4f6); + border-radius: var(--radius-md); + margin-bottom: var(--space-2); +} +.exp-month-title { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: .04em; +} +.exp-month-summe { + font-size: var(--text-sm); + font-weight: var(--weight-bold); + color: var(--c-primary); +} + +/* Einzelner Eintrag */ +.exp-entry { + display: flex; + align-items: center; + gap: var(--space-3); + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-md); + padding: var(--space-3); + margin-bottom: var(--space-2); + cursor: pointer; + transition: background .15s; +} +.exp-entry:active { background: var(--c-surface-2, #f3f4f6); } + +/* Icon-Badge mit Kategorie-Farbe */ +.exp-entry-icon-badge { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--kat-color) 15%, transparent); + color: var(--kat-color); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.exp-entry-body { + flex: 1; + min-width: 0; +} +.exp-entry-head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: 2px; +} +.exp-entry-datum { + font-size: var(--text-xs); + color: var(--c-text-muted, #9ca3af); + flex-shrink: 0; +} +.exp-entry-kat { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text); +} +.exp-entry-notiz { + display: block; + font-size: var(--text-xs); + color: var(--c-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.exp-dog-badge { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: var(--text-xs); + color: var(--c-text-secondary); + background: var(--c-surface-2, #f3f4f6); + border-radius: 999px; + padding: 1px 6px; +} + +/* Rechte Spalte: Betrag + Löschen-Icon */ +.exp-entry-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-1); + flex-shrink: 0; +} +.exp-entry-betrag { + font-size: var(--text-base); + font-weight: var(--weight-bold); + color: var(--c-text); + white-space: nowrap; +} +.exp-entry-del { + background: transparent; + border: none; + color: var(--c-text-muted, #9ca3af); + cursor: pointer; + padding: 2px 4px; + border-radius: var(--radius-sm); + font-size: 1rem; + line-height: 1; + transition: color .15s; +} +.exp-entry-del:hover { color: var(--c-danger); } + +/* ---- Statistik: Kategorie-Balken-Reihen ---- */ +.exp-stat-rows { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.exp-stat-row { + display: grid; + grid-template-columns: 120px 1fr 36px 80px; + align-items: center; + gap: var(--space-2); +} +.exp-stat-label { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-sm); + color: var(--c-text); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.exp-stat-icon { flex-shrink: 0; } +.exp-stat-bar-wrap { + height: 8px; + background: var(--c-surface-2, #f3f4f6); + border-radius: 999px; + overflow: hidden; +} +.exp-stat-bar { + height: 8px; + border-radius: 999px; + transition: width .5s ease; +} +.exp-stat-pct { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-align: right; +} +.exp-stat-val { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text); + text-align: right; + white-space: nowrap; +} + +/* ---- Donut-Diagramm (CSS conic-gradient) ---- */ +.exp-donut-wrap { + display: flex; + align-items: center; + gap: var(--space-5); + flex-wrap: wrap; +} +.exp-donut { + position: relative; + width: 120px; + height: 120px; + border-radius: 50%; + flex-shrink: 0; +} +.exp-donut-hole { + position: absolute; + inset: 28px; + background: var(--c-surface); + border-radius: 50%; +} +.exp-donut-legend { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 130px; +} +.exp-donut-legend-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); +} +.exp-donut-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.exp-donut-legend-label { + flex: 1; + color: var(--c-text); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.exp-donut-legend-pct { + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); +} diff --git a/backend/static/js/pages/expenses.js b/backend/static/js/pages/expenses.js index c37d19e..e7c2b61 100644 --- a/backend/static/js/pages/expenses.js +++ b/backend/static/js/pages/expenses.js @@ -12,7 +12,6 @@ window.Page_expenses = (() => { // Cache let _summary = null; let _entries = []; - // Monats-Statistik-Daten (pro Monat und Kategorie) let _statsData = null; const TABS = [ @@ -114,6 +113,10 @@ window.Page_expenses = (() => { } const s = _summary; + // Vormonatsvergleich berechnen + const letzteMonat = await _getLetzteMonateData(); + const trendHtml = _trendHtml(letzteMonat); + const kacheln = KATEGORIEN.map(k => { const betrag = s.monat[k.id] || 0; return ` @@ -121,38 +124,35 @@ window.Page_expenses = (() => {
    ${UI.icon(k.icon)}
    +
    ${_fmt(betrag)}
    ${k.label}
    -
    ${_fmt(betrag)}
    `; }).join(''); - const letzteMonat = await _getLetzteMonateData(); - const vergleich = letzteMonat.length > 1 - ? _vergleichHtml(letzteMonat) - : ''; + const verlauf = letzteMonat.length > 1 ? _vergleichHtml(letzteMonat) : ''; el.innerHTML = ` -
    -
    Dieser Monat
    -
    ${_fmt(s.gesamt_monat)}
    -
    - ${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)} +
    +
    Dieser Monat
    +
    ${_fmt(s.gesamt_monat)}
    +
    + ${UI.icon('calendar')} Dieses Jahr: ${_fmt(s.gesamt_jahr)} + ${trendHtml}
    ${kacheln}
    - ${vergleich} + ${verlauf}
    `; } async function _getLetzteMonateData() { - // Letzten 6 Monate aus den Einträgen berechnen if (!_entries.length) { _entries = await API.get('/expenses?limit=500'); } const monatMap = {}; _entries.forEach(e => { - const m = e.datum.substring(0, 7); // YYYY-MM + const m = e.datum.substring(0, 7); monatMap[m] = (monatMap[m] || 0) + e.betrag; }); return Object.entries(monatMap) @@ -161,6 +161,21 @@ window.Page_expenses = (() => { .reverse(); } + function _trendHtml(data) { + // Vergleich: aktueller Monat vs. Vormonat + if (data.length < 2) return ''; + const aktuell = data[data.length - 1][1]; + const vormonat = data[data.length - 2][1]; + if (!vormonat) return ''; + const diff = aktuell - vormonat; + const pct = Math.round(Math.abs(diff / vormonat) * 100); + if (pct === 0) return ''; + const pfeil = diff > 0 + ? `${UI.icon('arrow-up')} +${pct}% ggü. Vormonat` + : `${UI.icon('arrow-down')} −${pct}% ggü. Vormonat`; + return pfeil; + } + function _vergleichHtml(data) { if (!data.length) return ''; const max = Math.max(...data.map(d => d[1]), 1); @@ -227,22 +242,28 @@ window.Page_expenses = (() => { ? `${UI.icon('paw-print')} ${_esc(e.dog_name)}` : ''; const notiz = e.notiz - ? `${_esc(e.notiz)}` + ? `${_esc(e.notiz)}` : ''; return `
    -
    +
    ${UI.icon(k.icon)}
    + ${datum} ${k.label} ${dogBadge} - ${datum}
    ${notiz}
    -
    ${_fmt(e.betrag)}
    +
    +
    ${_fmt(e.betrag)}
    + +
    `; }).join(''); @@ -258,13 +279,32 @@ window.Page_expenses = (() => { el.innerHTML = `
    ${html}
    `; + // Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button) el.querySelectorAll('.exp-entry').forEach(row => { - row.addEventListener('click', () => { + row.addEventListener('click', (ev) => { + if (ev.target.closest('.exp-entry-del')) return; const id = parseInt(row.dataset.id); const entry = _entries.find(e => e.id === id); if (entry) _showForm(entry); }); }); + + // Löschen-Buttons + el.querySelectorAll('.exp-entry-del').forEach(btn => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const id = parseInt(btn.dataset.del); + if (!window.confirm('Diesen Eintrag wirklich löschen?')) return; + try { + await API.del(`/expenses/${id}`); + UI.toast.success('Ausgabe gelöscht.'); + _invalidateCache(); + await _renderTab(); + } catch (e) { + UI.toast.error(e.message || 'Fehler beim Löschen.'); + } + }); + }); } // ---------------------------------------------------------- @@ -281,7 +321,7 @@ window.Page_expenses = (() => { const s = _summary; const gesamtJahr = s.gesamt_jahr || 1; - // Jahres-Aufteilung nach Kategorien + // Jahres-Aufteilung nach Kategorien (als Balken-Reihen) const katBalken = KATEGORIEN .filter(k => (s.jahr[k.id] || 0) > 0) .sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0)) @@ -291,7 +331,7 @@ window.Page_expenses = (() => { return `
    - ${UI.icon(k.icon)} + ${UI.icon(k.icon)} ${k.label}
    @@ -302,38 +342,71 @@ window.Page_expenses = (() => {
    `; }).join(''); - // Monats-Balken (aktuelles Jahr, Monat für Monat) + // Monats-Balken mit gestapelten Top-2-Kategorien const heute = new Date(); const jahrStr = heute.getFullYear().toString(); - const monatMap = {}; + + // Pro Monat: Summe je Kategorie berechnen + const monatKatMap = {}; // { monat: { katId: summe } } _entries .filter(e => e.datum.startsWith(jahrStr)) .forEach(e => { const m = parseInt(e.datum.split('-')[1]); - monatMap[m] = (monatMap[m] || 0) + e.betrag; + if (!monatKatMap[m]) monatKatMap[m] = {}; + monatKatMap[m][e.kategorie] = (monatKatMap[m][e.kategorie] || 0) + e.betrag; }); - const maxMonat = Math.max(...Object.values(monatMap), 1); + const monatTotalMap = {}; + Object.entries(monatKatMap).forEach(([m, katObj]) => { + monatTotalMap[m] = Object.values(katObj).reduce((a, b) => a + b, 0); + }); + + const maxMonat = Math.max(...Object.values(monatTotalMap), 1); const MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; const monatsBalken = MONATE.map((label, i) => { - const val = monatMap[i + 1] || 0; - const pct = Math.round((val / maxMonat) * 100); - const isAktiv = (i + 1) === (heute.getMonth() + 1); + const mi = i + 1; + const total = monatTotalMap[mi] || 0; + const pct = Math.round((total / maxMonat) * 100); + const isAktiv = mi === (heute.getMonth() + 1); + + // Top-2-Kategorien für gestapelten Balken + let stackHtml = ''; + if (total > 0 && monatKatMap[mi]) { + const sorted = Object.entries(monatKatMap[mi]) + .sort((a, b) => b[1] - a[1]) + .slice(0, 2); + // Gesamthöhe = pct%, verteile anteilig auf Top-2 + let rest = pct; + const segments = sorted.map(([katId, val], idx) => { + const k = _kat(katId); + const segPct = idx < sorted.length - 1 + ? Math.round((val / total) * pct) + : rest; + rest -= segPct; + return `
    `; + }); + stackHtml = segments.join(''); + } else { + stackHtml = `
    `; + } + return `
    -
    -
    +
    + ${stackHtml}
    ${label}
    `; }).join(''); + // Donut-Übersicht (CSS-gradient) + const donutHtml = _donutHtml(s, gesamtJahr); + el.innerHTML = ` -
    -
    Gesamt dieses Jahr
    -
    ${_fmt(s.gesamt_jahr)}
    +
    +
    Gesamt dieses Jahr
    +
    ${_fmt(s.gesamt_jahr)}
    @@ -341,6 +414,8 @@ window.Page_expenses = (() => {
    ${monatsBalken}
    + ${donutHtml} +
    ${UI.icon('chart-pie')} Aufteilung nach Kategorie
    @@ -351,6 +426,45 @@ window.Page_expenses = (() => { `; } + // Donut via CSS conic-gradient + function _donutHtml(s, gesamt) { + const aktiveKat = KATEGORIEN.filter(k => (s.jahr[k.id] || 0) > 0); + if (!aktiveKat.length) return ''; + + // Stops für conic-gradient berechnen + let offset = 0; + const stops = []; + aktiveKat.forEach(k => { + const pct = (s.jahr[k.id] || 0) / gesamt * 100; + stops.push(`${k.color} ${offset.toFixed(1)}% ${(offset + pct).toFixed(1)}%`); + offset += pct; + }); + const gradient = `conic-gradient(${stops.join(', ')})`; + + const legendeItems = aktiveKat + .sort((a, b) => (s.jahr[b.id] || 0) - (s.jahr[a.id] || 0)) + .map(k => { + const pct = Math.round((s.jahr[k.id] || 0) / gesamt * 100); + return ` +
    + + ${k.label} + ${pct}% +
    `; + }).join(''); + + return ` +
    +
    ${UI.icon('chart-pie')} Kategorien-Verteilung
    +
    +
    +
    +
    +
    ${legendeItems}
    +
    +
    `; + } + // ---------------------------------------------------------- // FORMULAR — Neu / Bearbeiten // ---------------------------------------------------------- From fb1a9d27cd10be5a1ac0cbaf20c0cae3d86f4be4 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 10:12:51 +0200 Subject: [PATCH 42/63] =?UTF-8?q?Feature:=20Hundepass=20=E2=80=94=20Auswei?= =?UTF-8?q?s=20integriert,=20Icons=20&=20Kontrast-Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ausweis-Button aus Profil-Ansicht entfernt; stattdessen "Ausweis öffnen"-Button im Hundepass-Modal-Footer - Bearbeiten-Button in Gesundheits-Info von btn-link auf btn-secondary (besserer Kontrast) - 7 fehlende Phosphor-Icons zum Sprite hinzugefügt: file-pdf, notebook, link, identification-card, wave-sine, list-checks, share-network - Leere Sektionen (Impfungen, Medikamente) zeigen einladenden Empty-State mit Icon statt nacktem Text --- backend/static/icons/phosphor.svg | 28 ++++++++++++++++++++++ backend/static/js/pages/dog-profile.js | 33 ++++++++++++++------------ 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index bc2df08..bdbfd78 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -230,4 +230,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 9f60609..82a2e8a 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -187,16 +187,10 @@ window.Page_dog_profile = (() => { ${!dog.is_guest ? `` : ''} -
    - - ${!dog.is_guest ? `` : ''} -
    ${!dog.is_guest ? ` + + + Ausweis öffnen +
    `, size: 'large', @@ -1427,7 +1421,10 @@ window.Page_dog_profile = (() => { Gesundheits-Info - +
    @@ -1464,7 +1461,10 @@ window.Page_dog_profile = (() => {
    ${vaccs.length === 0 - ? '

    Keine Impfungen eingetragen.

    ' + ? `
    + +

    Noch keine Impfungen eingetragen.
    Klicke auf „+ Eintragen" um loszulegen.

    +
    ` : vaccs.map(v => `
    ${meds.length === 0 - ? '

    Keine Medikamente eingetragen.

    ' + ? `
    + +

    Noch keine Medikamente eingetragen.
    Klicke auf „+ Eintragen" um loszulegen.

    +
    ` : meds.map(m => `
    Date: Sat, 2 May 2026 10:45:50 +0200 Subject: [PATCH 46/63] =?UTF-8?q?Fix:=20Symptom-Check-Tab=20entfernt=20(KI?= =?UTF-8?q?-Tierarzt-Button=20=C3=BCbernimmt),=20SW=20by-v604?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/js/app.js | 2 +- backend/static/js/pages/health.js | 2 -- backend/static/sw.js | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 9a733f8..5028691 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '603'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '604'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 859d880..6308eda 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -22,7 +22,6 @@ window.Page_health = (() => { { key: 'allergie', label: 'Allergien', icon: '' }, { key: 'dokument', label: 'Dokumente', icon: '' }, { key: 'praxen', label: 'Praxen', icon: '' }, - { key: 'symptomcheck', label: 'Symptom-Check', icon: '' }, ]; const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '' }; @@ -380,7 +379,6 @@ window.Page_health = (() => { case 'allergie': content.innerHTML = _renderAllergien(entries); break; case 'dokument': content.innerHTML = _renderDokumente(entries); break; case 'praxen': content.innerHTML = _renderPraxen(); break; - case 'symptomcheck': _renderSymptomCheck(content); break; } _bindTabEvents(content); diff --git a/backend/static/sw.js b/backend/static/sw.js index 7491ee3..f15c053 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v603'; +const CACHE_VERSION = 'by-v604'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 798289ae5a318ad88b629bfb51517d5dce46570d Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 10:51:28 +0200 Subject: [PATCH 47/63] =?UTF-8?q?Feature:=20Dauerauftr=C3=A4ge=20in=20Ausg?= =?UTF-8?q?aben=20=E2=80=94=20monatlich/quartalsweise/j=C3=A4hrlich,=20Sch?= =?UTF-8?q?eduler,=20SW=20by-v605?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 18 +++ backend/requirements.txt | 1 + backend/routes/expenses.py | 170 +++++++++++++++++++++++++- backend/scheduler.py | 22 ++++ backend/static/css/components.css | 53 ++++++++ backend/static/icons/phosphor.svg | 8 ++ backend/static/js/app.js | 2 +- backend/static/js/pages/expenses.js | 181 +++++++++++++++++++++++++++- backend/static/sw.js | 2 +- 9 files changed, 448 insertions(+), 9 deletions(-) diff --git a/backend/database.py b/backend/database.py index 5d992eb..1a70aa5 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1857,3 +1857,21 @@ def _migrate(conn_factory): UNIQUE(from_dog_id, to_dog_id) ) """) + + # Wiederkehrende Ausgaben (Daueraufträge) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS recurring_expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich + startdatum TEXT NOT NULL, + naechste_faelligkeit TEXT NOT NULL, + notiz TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); + """) diff --git a/backend/requirements.txt b/backend/requirements.txt index 17db134..c4e830c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,4 @@ apscheduler==3.10.4 odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 +python-dateutil>=2.9 diff --git a/backend/routes/expenses.py b/backend/routes/expenses.py index a3bcf7a..9c93475 100644 --- a/backend/routes/expenses.py +++ b/backend/routes/expenses.py @@ -1,7 +1,8 @@ """BAN YARO — Ausgaben-Tracker Routes""" import logging -from datetime import date +from datetime import date, timedelta +from dateutil.relativedelta import relativedelta from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional @@ -33,6 +34,43 @@ class ExpenseUpdate(BaseModel): notiz: Optional[str] = None +class RecurringCreate(BaseModel): + dog_id: Optional[int] = None + kategorie: str + betrag: float + haeufigkeit: str # monatlich | quartalsweise | jaehrlich + startdatum: str # ISO date + notiz: Optional[str] = None + +class RecurringUpdate(BaseModel): + dog_id: Optional[int] = None + kategorie: Optional[str] = None + betrag: Optional[float] = None + haeufigkeit: Optional[str] = None + startdatum: Optional[str] = None + notiz: Optional[str] = None + aktiv: Optional[bool] = None + + +HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"} + + +def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date: + """Berechnet das nächste Fälligkeitsdatum nach `after`.""" + d = date.fromisoformat(startdatum) + if d > after: + return d + if haeufigkeit == "monatlich": + delta = relativedelta(months=1) + elif haeufigkeit == "quartalsweise": + delta = relativedelta(months=3) + else: + delta = relativedelta(years=1) + while d <= after: + d += delta + return d + + def _serialize(row) -> dict: return dict(row) @@ -226,3 +264,133 @@ async def delete_expense(expense_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Eintrag nicht gefunden.") conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,)) return None + + +# ------------------------------------------------------------------ +# Wiederkehrende Ausgaben +# ------------------------------------------------------------------ +@router.get("/recurring") +async def list_recurring(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute( + """SELECT r.*, d.name AS dog_name + FROM recurring_expenses r + LEFT JOIN dogs d ON d.id = r.dog_id + WHERE r.user_id=? ORDER BY r.startdatum DESC""", + (user["id"],), + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/recurring", status_code=201) +async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)): + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}") + if data.haeufigkeit not in HAEUFIGKEITEN: + raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}") + if data.betrag <= 0: + raise HTTPException(400, "Betrag muss größer als 0 sein.") + + today = date.today() + naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1)) + + with db() as conn: + if data.dog_id: + if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?", + (data.dog_id, user["id"])).fetchone(): + raise HTTPException(404, "Hund nicht gefunden.") + conn.execute( + """INSERT INTO recurring_expenses + (user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz) + VALUES (?,?,?,?,?,?,?,?)""", + (user["id"], data.dog_id, data.kategorie, data.betrag, + data.haeufigkeit, data.startdatum, str(naechste), data.notiz), + ) + row = conn.execute( + "SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1", + (user["id"],), + ).fetchone() + return dict(row) + + +@router.patch("/recurring/{rid}") +async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"]) + ).fetchone() + if not row: + raise HTTPException(404, "Dauerauftrag nicht gefunden.") + updates: dict = {} + if data.kategorie is not None: + if data.kategorie not in KATEGORIEN: + raise HTTPException(400, f"Ungültige Kategorie.") + updates["kategorie"] = data.kategorie + if data.betrag is not None: + updates["betrag"] = data.betrag + if data.haeufigkeit is not None: + if data.haeufigkeit not in HAEUFIGKEITEN: + raise HTTPException(400, "Ungültige Häufigkeit.") + updates["haeufigkeit"] = data.haeufigkeit + if data.startdatum is not None: + updates["startdatum"] = data.startdatum + if data.notiz is not None: + updates["notiz"] = data.notiz + if data.aktiv is not None: + updates["aktiv"] = 1 if data.aktiv else 0 + if updates: + # naechste_faelligkeit neu berechnen wenn relevante Felder geändert + startdatum = updates.get("startdatum", row["startdatum"]) + haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"]) + today = date.today() + updates["naechste_faelligkeit"] = str( + _next_due(startdatum, haeufigkeit, today - timedelta(days=1)) + ) + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?", + [*updates.values(), rid]) + row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone() + return dict(row) + + +@router.delete("/recurring/{rid}", status_code=204) +async def delete_recurring(rid: int, user=Depends(get_current_user)): + with db() as conn: + if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?", + (rid, user["id"])).fetchone(): + raise HTTPException(404, "Dauerauftrag nicht gefunden.") + conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,)) + return None + + +def process_due_recurring(user_id: int | None = None): + """Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen.""" + today = date.today() + today_str = str(today) + with db() as conn: + where = "aktiv=1 AND naechste_faelligkeit <= ?" + params: list = [today_str] + if user_id: + where += " AND user_id=?" + params.append(user_id) + rows = conn.execute( + f"SELECT * FROM recurring_expenses WHERE {where}", params + ).fetchall() + + for r in rows: + # Eintrag anlegen + conn.execute( + """INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz) + VALUES (?,?,?,?,?,?)""", + (r["user_id"], r["dog_id"], r["kategorie"], r["betrag"], + r["naechste_faelligkeit"], + f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"), + ) + # Nächste Fälligkeit berechnen + naechste = _next_due(r["startdatum"], r["haeufigkeit"], + date.fromisoformat(r["naechste_faelligkeit"])) + conn.execute( + "UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?", + (str(naechste), r["id"]), + ) + return len(rows) if rows else 0 diff --git a/backend/scheduler.py b/backend/scheduler.py index 4dcab4c..4aeb89a 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -124,6 +124,14 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 06:30 — Wiederkehrende Ausgaben anlegen + _scheduler.add_job( + _job_recurring_expenses, + CronTrigger(hour=6, minute=30), + id="recurring_expenses", + replace_existing=True, + misfire_grace_time=3600, + ) # 1. des Monats 00:05 — Hund des Monats Sieger festlegen _scheduler.add_job( _job_hdm_winner, @@ -1266,3 +1274,17 @@ async def _job_recall_check(): except Exception as e: logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}") _log_job("recall_check", "error", str(e)) + + +# ------------------------------------------------------------------ +# JOB: Wiederkehrende Ausgaben anlegen +# ------------------------------------------------------------------ +async def _job_recurring_expenses(): + try: + from routes.expenses import process_due_recurring + count = process_due_recurring() + logger.info(f"Daueraufträge: {count} Einträge angelegt.") + _log_job("recurring_expenses", "ok", f"{count} Einträge") + except Exception as e: + logger.error(f"Daueraufträge-Job Fehler: {e}") + _log_job("recurring_expenses", "error", str(e)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 1930060..60cdfb4 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7382,3 +7382,56 @@ svg.empty-state-icon { font-weight: var(--weight-semibold); color: var(--c-text-secondary); } + +/* Daueraufträge */ +.exp-recurring-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--c-surface); + border: 1.5px solid var(--c-border); + border-radius: var(--radius-lg); + margin-bottom: var(--space-2); + transition: opacity .2s; +} +.exp-recurring-card--inaktiv { opacity: .55; } +.exp-recurring-freq { + font-size: var(--text-xs); + color: var(--c-primary); + font-weight: var(--weight-semibold); + background: var(--c-primary-subtle); + padding: 1px var(--space-2); + border-radius: var(--radius-full); +} +.exp-recurring-next { + font-size: var(--text-xs); + color: var(--c-text-muted); + margin-top: var(--space-1); + display: flex; + align-items: center; + gap: var(--space-1); + flex-wrap: wrap; +} +.exp-badge-inaktiv { + background: var(--c-surface-2); + color: var(--c-text-muted); + padding: 1px var(--space-2); + border-radius: var(--radius-full); + font-size: var(--text-xs); +} +.exp-icon-btn { + width: 28px; + height: 28px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + background: var(--c-surface); + color: var(--c-text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color .15s, border-color .15s; +} +.exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); } +.exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); } diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index bdbfd78..ac08636 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -258,4 +258,12 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 5028691..7e2c144 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '604'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '605'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/expenses.js b/backend/static/js/pages/expenses.js index e7c2b61..8d00c2e 100644 --- a/backend/static/js/pages/expenses.js +++ b/backend/static/js/pages/expenses.js @@ -15,9 +15,10 @@ window.Page_expenses = (() => { let _statsData = null; const TABS = [ - { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, - { id: 'eintraege', label: 'Einträge', icon: 'list-bullets' }, - { id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, + { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, + { id: 'eintraege', label: 'Ausgaben', icon: 'list-bullets' }, + { id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' }, + { id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, ]; const KATEGORIEN = [ @@ -95,9 +96,10 @@ window.Page_expenses = (() => { el.innerHTML = `
    ${UI.skeleton(4)}
    `; try { switch (_tab) { - case 'uebersicht': await _renderUebersicht(el); break; - case 'eintraege': await _renderEintraege(el); break; - case 'statistik': await _renderStatistik(el); break; + case 'uebersicht': await _renderUebersicht(el); break; + case 'eintraege': await _renderEintraege(el); break; + case 'dauerauftraege': await _renderDauerauftraege(el); break; + case 'statistik': await _renderStatistik(el); break; } } catch (e) { el.innerHTML = `
    Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}
    `; @@ -307,6 +309,173 @@ window.Page_expenses = (() => { }); } + // ---------------------------------------------------------- + // TAB: DAUERAUFTRÄGE + // ---------------------------------------------------------- + const HAEUFIGKEIT_LABEL = { + monatlich: 'Monatlich', + quartalsweise: 'Quartalsweise', + jaehrlich: 'Jährlich', + }; + + async function _renderDauerauftraege(el) { + let recurring = []; + try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ } + + const cards = recurring.map(r => { + const k = _kat(r.kategorie); + const naechste = r.naechste_faelligkeit + ? new Date(r.naechste_faelligkeit + 'T00:00:00') + .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : '—'; + return ` +
    +
    ${UI.icon(k.icon)}
    +
    +
    + ${k.label} + ${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit} + ${r.dog_name ? `${UI.icon('paw-print')} ${_esc(r.dog_name)}` : ''} +
    + ${r.notiz ? `
    ${_esc(r.notiz)}
    ` : ''} +
    + ${UI.icon('calendar')} Nächste Buchung: ${naechste} + ${!r.aktiv ? 'Pausiert' : ''} +
    +
    +
    +
    ${_fmt(r.betrag)}
    +
    + + +
    +
    +
    `; + }).join(''); + + el.innerHTML = ` +
    + +
    + ${recurring.length + ? `
    ${cards}
    ` + : UI.emptyState({ icon: UI.icon('arrows-clockwise'), + title: 'Keine Daueraufträge', + text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })} +
    `; + + el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => { + _tab = 'dauerauftraege'; _renderTab(); + })); + + el.querySelectorAll('.exp-recurring-toggle').forEach(btn => { + btn.addEventListener('click', async () => { + const rid = parseInt(btn.dataset.rid); + const aktiv = btn.dataset.aktiv === '1'; + await UI.asyncButton(btn, async () => { + await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv }); + _renderTab(); + }); + }); + }); + + el.querySelectorAll('.exp-recurring-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Dauerauftrag löschen?')) return; + await UI.asyncButton(btn, async () => { + await API.del(`/expenses/recurring/${btn.dataset.rid}`); + _renderTab(); + }); + }); + }); + } + + function _showRecurringForm(r, onSave) { + const today = new Date().toISOString().slice(0, 10); + const katOptions = [ + { id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' }, + { id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' }, + { id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' }, + ].map(k => ``).join(''); + + const dogOptions = (_appState.dogs || []).map(d => + `` + ).join(''); + + const body = ` +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + ${dogOptions ? ` +
    + + +
    ` : ''} +
    + + +
    +
    `; + + const footer = ` + + `; + + UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer }); + + document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]'); + const fd = UI.formData(e.target); + const payload = { + kategorie: fd.kategorie, + betrag: parseFloat(fd.betrag), + haeufigkeit: fd.haeufigkeit, + startdatum: fd.startdatum, + notiz: fd.notiz || null, + dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, + }; + await UI.asyncButton(btn, async () => { + if (r) { + await API.patch(`/expenses/recurring/${r.id}`, payload); + } else { + await API.post('/expenses/recurring', payload); + } + UI.modal.close(); + onSave?.(); + }); + }); + } + // ---------------------------------------------------------- // TAB: STATISTIK // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index f15c053..189813f 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v604'; +const CACHE_VERSION = 'by-v605'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From c96e98917c3a0a440be175951b03e546320f625d Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 10:55:43 +0200 Subject: [PATCH 48/63] =?UTF-8?q?Fix:=20Ausgaben-Tab=20Icon=20=E2=86=92=20?= =?UTF-8?q?currency-eur,=20SW=20by-v606?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/icons/phosphor.svg | 4 ++++ backend/static/js/app.js | 2 +- backend/static/js/pages/expenses.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index ac08636..a9189b9 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -266,4 +266,8 @@ + + + + \ No newline at end of file diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7e2c144..59bd4d3 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '605'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '606'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/expenses.js b/backend/static/js/pages/expenses.js index 8d00c2e..15d6994 100644 --- a/backend/static/js/pages/expenses.js +++ b/backend/static/js/pages/expenses.js @@ -16,7 +16,7 @@ window.Page_expenses = (() => { const TABS = [ { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, - { id: 'eintraege', label: 'Ausgaben', icon: 'list-bullets' }, + { id: 'eintraege', label: 'Ausgaben', icon: 'currency-eur' }, { id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' }, { id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, ]; diff --git a/backend/static/sw.js b/backend/static/sw.js index 189813f..1cef344 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v605'; +const CACHE_VERSION = 'by-v606'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From dfd68f2a07edf759f169888e90e38562bcc48105 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 2 May 2026 10:58:47 +0200 Subject: [PATCH 49/63] =?UTF-8?q?Feature:=20Ausgaben-Formular=20redesigned?= =?UTF-8?q?=20=E2=80=94=20Kategorie-Kacheln,=20=E2=82=AC-Prefix,=20Wiederh?= =?UTF-8?q?olungs-Toggle,=20SW=20by-v607?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 81 ++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/expenses.js | 128 +++++++++++++++++++--------- backend/static/sw.js | 2 +- 4 files changed, 173 insertions(+), 40 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 60cdfb4..9e16ea7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7435,3 +7435,84 @@ svg.empty-state-icon { } .exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); } .exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); } + +/* Ausgaben-Formular — Kategorie-Kacheln */ +.exp-kat-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); +} +.exp-kat-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + padding: var(--space-3) var(--space-2); + border: 1.5px solid var(--c-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color .15s, background .15s; + background: var(--c-surface); + user-select: none; +} +.exp-kat-tile:hover { border-color: var(--c-primary); } +.exp-kat-tile--sel { + border-color: var(--c-primary); + background: var(--c-primary-subtle); +} +.exp-kat-tile-icon { font-size: 1.4rem; line-height: 1; } +.exp-kat-tile-label { + font-size: var(--text-xs); + font-weight: var(--weight-medium); + color: var(--c-text-secondary); + text-align: center; +} +.exp-kat-tile--sel .exp-kat-tile-label { color: var(--c-primary); } + +/* Betrag-Feld mit €-Prefix */ +.exp-betrag-wrap { + position: relative; + display: flex; + align-items: center; +} +.exp-betrag-prefix { + position: absolute; + left: var(--space-3); + color: var(--c-text-muted); + font-weight: var(--weight-semibold); + pointer-events: none; +} +.exp-betrag-input { padding-left: calc(var(--space-3) + 14px + var(--space-2)) !important; } + +/* Form-Label Hint */ +.form-label-hint { color: var(--c-text-muted); font-weight: normal; font-size: var(--text-xs); } + +/* Wiederholungs-Sektion */ +.exp-repeat-section { + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--c-border-light); +} +.exp-repeat-toggle { + display: flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text); + user-select: none; +} +.exp-repeat-toggle-box { + width: 18px; + height: 18px; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-sm); + background: var(--c-surface); + flex-shrink: 0; + transition: background .15s, border-color .15s; +} +.exp-repeat-toggle input:checked ~ .exp-repeat-toggle-box { + background: var(--c-primary); + border-color: var(--c-primary); +} diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 59bd4d3..850b1a0 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '606'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '607'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/expenses.js b/backend/static/js/pages/expenses.js index 15d6994..3bed1dd 100644 --- a/backend/static/js/pages/expenses.js +++ b/backend/static/js/pages/expenses.js @@ -641,64 +641,106 @@ window.Page_expenses = (() => { const isEdit = !!entry; const today = new Date().toISOString().split('T')[0]; const formId = 'exp-form'; + const selKat = entry?.kategorie || 'sonstiges'; const dogOptions = (_appState.dogs || []).map(d => `` ).join(''); - const katOptions = KATEGORIEN.map(k => - `` - ).join(''); + // Kategorie-Kacheln statt Dropdown + const katKacheln = KATEGORIEN.map(k => ` + `).join(''); const body = ` -
    -
    - - -
    + +
    - +
    ${katKacheln}
    -
    - - + +
    +
    + +
    + + +
    +
    +
    + + +
    + ${dogOptions ? `
    - - + ${dogOptions}
    ` : ''} +
    - - Notiz (optional) + + placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
    + + ${!isEdit ? ` +
    + + +
    ` : ''} + `; const footer = isEdit ? ` - - - ` : ` + + ` : ` + + `; - const modal = UI.modal.open({ - title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', - body, - footer, + const modal = UI.modal.open({ title: isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe', body, footer }); + + // Kategorie-Kacheln interaktiv + modal.querySelectorAll('.exp-kat-tile').forEach(tile => { + tile.addEventListener('click', () => { + modal.querySelectorAll('.exp-kat-tile').forEach(t => t.classList.remove('exp-kat-tile--sel')); + tile.classList.add('exp-kat-tile--sel'); + }); + }); + + // Wiederholen-Toggle + modal.querySelector('#exp-wiederholen')?.addEventListener('change', e => { + modal.querySelector('#exp-repeat-opts').style.display = e.target.checked ? 'block' : 'none'; }); if (isEdit) { @@ -718,8 +760,8 @@ window.Page_expenses = (() => { modal.querySelector(`#${formId}`)?.addEventListener('submit', async (ev) => { ev.preventDefault(); - const fd = UI.formData(ev.target); - const body = { + const fd = UI.formData(ev.target); + const payload = { kategorie: fd.kategorie, betrag: parseFloat(fd.betrag), datum: fd.datum, @@ -729,11 +771,21 @@ window.Page_expenses = (() => { try { if (isEdit) { - await API.patch(`/expenses/${entry.id}`, body); + await API.patch(`/expenses/${entry.id}`, payload); UI.toast.success('Ausgabe aktualisiert.'); } else { - await API.post('/expenses', body); - UI.toast.success('Ausgabe gespeichert.'); + await API.post('/expenses', payload); + // Auch als Dauerauftrag anlegen wenn gewünscht + if (fd.wiederholen) { + await API.post('/expenses/recurring', { + ...payload, + haeufigkeit: fd.haeufigkeit || 'jaehrlich', + startdatum: fd.datum, + }); + UI.toast.success('Ausgabe + Dauerauftrag gespeichert.'); + } else { + UI.toast.success('Ausgabe gespeichert.'); + } } UI.modal.close(); _invalidateCache(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 1cef344..177e86b 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v606'; +const CACHE_VERSION = 'by-v607'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From fc2002847c61b172f2e051dd8f16ba8f98c32d49 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:18:11 +0200 Subject: [PATCH 50/63] =?UTF-8?q?Feature:=20Welten=20Info-Cards=20?= =?UTF-8?q?=E2=80=94=20User-Avatar=20in=20JETZT,=20Hunde-Avatar+Cycle+Over?= =?UTF-8?q?lap=20in=20HUND,=20SW=20by-v639?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 42 ++ backend/static/js/app.js | 9 +- backend/static/js/worlds.js | 988 ++++++++++++++++++++++++++++++++++++ backend/static/sw.js | 41 +- 4 files changed, 1074 insertions(+), 6 deletions(-) create mode 100644 backend/static/js/worlds.js diff --git a/backend/static/index.html b/backend/static/index.html index f42f248..559a4e0 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -8,6 +8,11 @@ + + + + + @@ -178,6 +183,9 @@ + + +
    +
    + + + +
    +
    + JETZT + HUND + WELT +
    + +
    +
    +
    +
    +
    + +
    +
    + + Zurück +
    + @@ -525,6 +566,7 @@ + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 850b1a0..792086d 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '607'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '639'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -75,6 +75,7 @@ const App = (() => { recalls: { title: 'Rückrufe', module: null }, adoption: { title: 'Adoption', module: null }, playdate: { title: 'Playdate', module: null, requiresAuth: true }, + wetter: { title: 'Wetter', module: null }, }; // ---------------------------------------------------------- @@ -98,6 +99,7 @@ const App = (() => { // ---------------------------------------------------------- function navigate(pageId, pushHistory = true, params = {}) { if (!pages[pageId]) return; + if (window.Worlds?._visible) window.Worlds.hide(); // Aktive Seite ausblenden document.querySelector('.page.active')?.classList.remove('active'); @@ -852,6 +854,9 @@ const App = (() => { const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. navigate(state.user ? startPage : 'welcome', false, hashParams); + + // Drei Welten nach initialer Navigation starten (damit hide() in navigate() sie nicht gleich killt) + if (window.Worlds) window.Worlds.init(state); } async function _handleInvite(token) { @@ -925,6 +930,8 @@ const App = (() => { })(); +window.App = App; // Worlds kann App.navigate() aufrufen + // App starten document.addEventListener('DOMContentLoaded', () => { App.init(); diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js new file mode 100644 index 0000000..37c9ba7 --- /dev/null +++ b/backend/static/js/worlds.js @@ -0,0 +1,988 @@ +/* ============================================================ + BAN YARO — Drei Welten Navigation + JETZT | HUND | WELT — horizontales Swipe-System + ============================================================ */ + +window.Worlds = (() => { + + let _state = null; + let _cur = 1; // 0=JETZT 1=HUND 2=WELT + let _visible = false; + let _map = null; + let _weltInited = false; + let _lastUserId = undefined; + let _dogs = []; // gecachte Hundesliste + let _dogIdx = 0; // aktuell angezeigter Hund + + // Touch-Tracking + const _t = { x:0, y:0, active:false, vert:null, moved:0 }; + + // ── OFFLINE-CACHE (localStorage) ──────────────────────────── + // Letzten bekannten Zustand speichern → sofort zeigen, dann updaten + + function _wSave(key, data) { + try { localStorage.setItem('w3_' + key, JSON.stringify({ ts: Date.now(), data })); } catch {} + } + function _wLoad(key) { + try { + const p = JSON.parse(localStorage.getItem('w3_' + key) || 'null'); + return p ? { data: p.data, ageMin: Math.round((Date.now() - p.ts) / 60000) } : null; + } catch { return null; } + } + // API-Aufruf mit Cache-Fallback: sofort Cache, dann Netz + async function _cachedGet(cacheKey, path) { + const cached = _wLoad(cacheKey); + let fresh = null; + try { + fresh = await API.get(path); + _wSave(cacheKey, fresh); + } catch {} + return { data: fresh ?? cached?.data ?? null, fromCache: !fresh, ageMin: fresh ? 0 : (cached?.ageMin ?? null) }; + } + + // ── PUBLIC ────────────────────────────────────────────────── + + async function init(appState) { + _state = appState; + _cur = 1; // immer HUND als Start + _setupSwipe(); + _setupButtons(); + _goTo(_cur, false); + show(); + // Welten parallel rendern + _renderJetzt(); + _renderHund(); + } + + function show(worldIdx) { + const ov = document.getElementById('worlds-overlay'); + if (!ov) return; + ov.style.display = 'block'; + requestAnimationFrame(() => ov.classList.add('worlds-visible')); + _visible = true; + document.getElementById('app-header')?.classList.add('worlds-hidden'); + document.getElementById('bottom-nav')?.classList.add('worlds-hidden'); + document.getElementById('worlds-back')?.classList.remove('worlds-back-visible'); + if (worldIdx != null) _goTo(worldIdx, false); + if (_cur === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } + + // Nach Login/Logout neu rendern + const currentUserId = _state?.user?.id ?? null; + if (currentUserId !== _lastUserId) { + _lastUserId = currentUserId; + _renderJetzt(); + _renderHund(); + } + } + + function hide() { + const ov = document.getElementById('worlds-overlay'); + if (!ov) return; + ov.classList.remove('worlds-visible'); + ov.style.display = 'none'; + _visible = false; + document.getElementById('app-header')?.classList.remove('worlds-hidden'); + document.getElementById('bottom-nav')?.classList.remove('worlds-hidden'); + document.getElementById('worlds-back')?.classList.add('worlds-back-visible'); + } + + function navigateTo(pageId) { + hide(); + if (window.App?.navigate) window.App.navigate(pageId); + } + + // ── SWIPE ──────────────────────────────────────────────────── + + function _setupSwipe() { + const track = document.getElementById('worlds-track'); + if (!track) return; + + track.addEventListener('touchstart', e => { + _t.x = e.touches[0].clientX; + _t.y = e.touches[0].clientY; + _t.active = true; _t.vert = null; _t.moved = 0; + track.style.transition = 'none'; + }, { passive: true }); + + track.addEventListener('touchmove', e => { + if (!_t.active) return; + const dx = e.touches[0].clientX - _t.x; + const dy = e.touches[0].clientY - _t.y; + if (_t.vert === null) _t.vert = Math.abs(dy) > Math.abs(dx) + 4; + if (_t.vert) return; + e.preventDefault(); + _t.moved = dx; + const base = -_cur * (100 / 3); + track.style.transform = `translateX(calc(${base}% + ${dx}px))`; + }, { passive: false }); + + track.addEventListener('touchend', () => { + if (!_t.active || _t.vert) { _t.active = false; return; } + _t.active = false; + let next = _cur; + if (_t.moved < -55 && _cur < 2) next = _cur + 1; + else if (_t.moved > 55 && _cur > 0) next = _cur - 1; + _goTo(next, true); + if (next === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } + }); + } + + function _goTo(idx, animated) { + _cur = Math.max(0, Math.min(2, idx)); + const track = document.getElementById('worlds-track'); + if (!track) return; + track.style.transition = animated + ? 'transform 0.32s cubic-bezier(0.25, 0.46, 0.45, 0.94)' + : 'none'; + track.style.transform = `translateX(${-_cur * (100 / 3)}%)`; + _updateDots(); + _updateFab(); + // Karte neu rendern nachdem Transition abgeschlossen + if (_cur === 2 && _map) { + setTimeout(() => _map.invalidateSize(), animated ? 380 : 50); + } + } + + function _updateDots() { + document.querySelectorAll('.wdot').forEach((d, i) => d.classList.toggle('active', i === _cur)); + } + + function _updateFab() { + const fab = document.getElementById('worlds-fab'); + if (!fab) return; + const icons = ['note-pencil', 'paw-print', 'warning']; + const titles = ['Schnelleintrag', 'Hund-Eintrag', 'Alarm melden']; + fab.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${icons[_cur]}`); + fab.title = titles[_cur]; + } + + function _setupButtons() { + document.getElementById('worlds-fab')?.addEventListener('click', _openFab); + document.getElementById('worlds-settings')?.addEventListener('click', () => navigateTo('settings')); + document.getElementById('worlds-back')?.addEventListener('click', () => show()); + document.querySelectorAll('.wdot').forEach((dot, i) => { + dot.style.pointerEvents = 'auto'; + dot.addEventListener('click', () => { + _goTo(i, true); + if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } + }); + }); + } + + function _openFab() { + const isWelt = _cur === 2; + const dogName = _state?.user ? null : null; // falls mehrere Hunde: erweiterbar + + const options = isWelt ? [ + { icon:'skull', color:'#EF4444', label:'Giftköder melden', sub:'Warnung für andere Hundebesitzer', page:'poison' }, + { icon:'dog', color:'#3B82F6', label:'Verlorenen Hund melden', sub:'Hilf beim Wiederfinden', page:'lost' }, + { icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }, + ] : [ + { icon:'book-open', color:'#8B5CF6', label:'Tagebucheintrag', sub:'Erlebnis, Foto oder Notiz', page:'diary', action:'openNew' }, + { icon:'target', color:'#F59E0B', label:'Training aufzeichnen',sub:'Übung absolviert', page:'uebungen' }, + { icon:'heartbeat', color:'#EF4444', label:'Tierarztbesuch', sub:'Befund oder Impfung eintragen', page:'health' }, + { icon:'wave-sine', color:'#06B6D4', label:'Gewicht messen', sub:'Aktuelles Gewicht eintragen', page:'health' }, + ]; + + // Overlay erstellen + const ov = document.createElement('div'); + ov.id = 'fab-overlay'; + ov.style.cssText = 'position:fixed;inset:0;z-index:300;display:flex;flex-direction:column;justify-content:flex-end'; + ov.innerHTML = ` +
    +
    +
    +
    + ${isWelt ? 'Was möchtest du melden?' : 'Was möchtest du eintragen?'} +
    + +
    +
    + ${options.map(o => ` + + `).join('')} +
    +
    + `; + + document.body.appendChild(ov); + + const _close = () => ov.remove(); + ov.querySelector('#fab-backdrop').addEventListener('click', _close); + ov.querySelector('#fab-close').addEventListener('click', _close); + ov.querySelectorAll('.fab-option').forEach(btn => { + btn.addEventListener('click', () => { + _close(); + const page = btn.dataset.page; + const action = btn.dataset.action; + navigateTo(page); + if (action === 'openNew') { + setTimeout(() => window.App?.callModule?.(page, 'openNew'), 400); + } + }); + }); + } + + // ── CHIP-KONFIGURATION ────────────────────────────────────── + // Alle verfügbaren Chips mit Metadaten + + const _ALL_CHIPS = [ + { icon:'note-pencil', label:'Notizblock', page:'notes' }, + { icon:'currency-eur', label:'Ausgaben', page:'expenses' }, + { icon:'first-aid', label:'Erste Hilfe', page:'erste-hilfe' }, + { icon:'handshake', label:'Playdate', page:'playdate' }, + { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, + { icon:'sun', label:'Wetter', page:'wetter' }, + { icon:'gear', label:'Profil', page:'settings', pinned:true }, + { icon:'book-open', label:'Tagebuch', page:'diary' }, + { icon:'heartbeat', label:'Gesundheit', page:'health' }, + { icon:'target', label:'Übungen', page:'uebungen' }, + { icon:'list-checks', label:'Trainings-\npläne',page:'trainingsplaene'}, + { icon:'heart', label:'Adoption', page:'adoption' }, + { icon:'house-line', label:'Sitting', page:'sitting' }, + { icon:'books', label:'Wiki', page:'wiki' }, + { icon:'scales', label:'Wurfbörse', page:'wurfboerse' }, + { icon:'map-trifold', label:'Karte', page:'map' }, + { icon:'push-pin', label:'Forum', page:'forum' }, + { icon:'users', label:'Freunde', page:'friends' }, + { icon:'paw-print', label:'Gassi', page:'walks' }, + { icon:'skull', label:'Giftköder', page:'poison' }, + { icon:'warning-circle', label:'Rückrufe', page:'recalls' }, + { icon:'dog', label:'Verlorene', page:'lost' }, + { icon:'path', label:'Routen', page:'routes' }, + { icon:'calendar-dots', label:'Events', page:'events' }, + { icon:'sparkle', label:'Jobs', page:'jobs' }, + { icon:'book-open', label:'Knigge', page:'knigge' }, + { icon:'film-slate', label:'Filme', page:'movies' }, + { icon:'tree-structure', label:'Zucht-\nkartei', page:'zuchthunde', role:'breeder' }, + { icon:'notebook', label:'Wurfverw.', page:'litters', role:'breeder' }, + { icon:'sparkle', label:'Social', page:'social', role:'social' }, + { icon:'shield-check', label:'Moderation', page:'moderation', role:'mod' }, + { icon:'gear', label:'Admin', page:'admin', role:'admin' }, + ]; + + const _DEFAULT_CONFIG = { + jetzt: ['notes','expenses','erste-hilfe','playdate','chat','wetter','settings'], + hund: ['diary','health','uebungen','trainingsplaene','adoption','sitting','wiki','wurfboerse'], + welt: ['map','forum','friends','walks','poison','recalls','lost','routes','events','jobs','knigge','movies'], + }; + + function _getConfig() { + try { return JSON.parse(localStorage.getItem('world_chips') || 'null') || _DEFAULT_CONFIG; } + catch { return _DEFAULT_CONFIG; } + } + function _saveConfig(cfg) { + try { localStorage.setItem('world_chips', JSON.stringify(cfg)); } catch {} + } + function _chipMeta(page) { + return _ALL_CHIPS.find(c => c.page === page) || null; + } + function _chipAllowed(chip) { + const u = _state?.user; + if (!chip?.role) return true; + if (chip.role === 'breeder') return u?.rolle === 'breeder' || u?.rolle === 'admin'; + if (chip.role === 'social') return u?.is_social_media || u?.rolle === 'admin'; + if (chip.role === 'mod') return u?.rolle === 'admin' || u?.rolle === 'moderator' || u?.is_moderator; + if (chip.role === 'admin') return u?.rolle === 'admin'; + return true; + } + function _chipsForWorld(world) { + const pages = _getConfig()[world] || _DEFAULT_CONFIG[world]; + return pages.map(_chipMeta).filter(c => c && _chipAllowed(c)); + } + + // ── KONFIGURATIONS-MODAL ───────────────────────────────────── + + function _openConfigModal() { + let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy + let _drag = null; // { page, fromWorld, ghost } + + const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' }; + const worldLabels = { jetzt:'JETZT', hund:'HUND', welt:'WELT', pool:'Nicht verwendet' }; + const allAssigned = () => new Set([...cfg.jetzt, ...cfg.hund, ...cfg.welt]); + const poolChips = () => _ALL_CHIPS.filter(c => _chipAllowed(c) && !allAssigned().has(c.page) && !c.pinned); + + const bottomNav = document.getElementById('bottom-nav'); + if (bottomNav) bottomNav.style.display = 'none'; + + const ov = document.createElement('div'); + ov.id = 'wc-overlay'; + ov.style.cssText = 'position:fixed;inset:0;z-index:500;display:flex;flex-direction:column;justify-content:flex-end'; + document.body.appendChild(ov); + + const _removeDragListeners = () => { + document.removeEventListener('touchmove', _onDragMove); + document.removeEventListener('touchend', _onDragEnd); + document.removeEventListener('touchcancel', _onDragEnd); + }; + const _cancelDrag = () => { + if (!_drag) return; + _removeDragListeners(); + _drag.ghost?.remove(); + if (_drag.chipEl) _drag.chipEl.style.opacity = ''; + ov.querySelectorAll('.wc-zone').forEach(z => z.style.borderColor = 'transparent'); + _drag = null; + }; + + const _closeModal = () => { + _cancelDrag(); // laufenden Drag abbrechen + ov.remove(); + if (bottomNav) bottomNav.style.removeProperty('display'); + }; + + function _render() { + ov.innerHTML = ` +
    +
    + +
    + +
    Welten einrichten
    + +
    + +
    +
    + Lang drücken & ziehen zum Verschieben. ✕ zum Entfernen. +
    + +
    + ${['jetzt','hund','welt','pool'].map(w => { + const chips = w === 'pool' ? poolChips() : (cfg[w] || []).map(_chipMeta).filter(c => c && _chipAllowed(c)); + const col = worldColors[w] || 'var(--c-border)'; + return ` +
    +
    + + ${worldLabels[w]} + ${w !== 'pool' ? `(${chips.length})` : ''} +
    +
    + ${chips.map(c => ` +
    + ${!c.pinned ? ` + ` : ` +
    + + + +
    `} + + + + ${c.label.replace('\n','
    ')}
    +
    + `).join('')} + ${chips.length === 0 ? `
    + ${w==='pool'?'Alle Chips sind zugeordnet':'Leer — ziehe Chips hierher'} +
    ` : ''} +
    +
    + `; + }).join('')} +
    + `; + _bindEvents(); + } + + function _bindEvents() { + ov.querySelector('#wc-bg')?.addEventListener('click', _closeModal); + ov.querySelector('#wc-cancel')?.addEventListener('click', _closeModal); + ov.querySelector('#wc-reset')?.addEventListener('click', () => { + cfg = JSON.parse(JSON.stringify(_DEFAULT_CONFIG)); + _render(); + }); + ov.querySelector('#wc-save')?.addEventListener('click', () => { + _saveConfig(cfg); + _closeModal(); + _renderJetzt(); _renderHund(); _renderWelt(); + }); + + // Remove-Buttons + ov.querySelectorAll('.wc-remove').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const page = btn.dataset.page, zone = btn.dataset.zone; + const meta = _chipMeta(page); + if (meta?.pinned) return; // gepinnte Chips können nicht entfernt werden + if (zone !== 'pool') cfg[zone] = cfg[zone].filter(p => p !== page); + _render(); + }); + }); + + // Touch-Drag + ov.querySelectorAll('.wc-chip').forEach(chip => { + chip.addEventListener('touchstart', e => _onDragStart(e, chip), { passive: true }); + }); + document.addEventListener('touchmove', _onDragMove, { passive: false }); + document.addEventListener('touchend', _onDragEnd); + } + + function _onDragStart(e, chipEl) { + if (_drag) _cancelDrag(); + const touch = e.touches[0]; + // Drag erst nach Bewegungs-Threshold aktivieren (verhindert Scroll-Konflikte) + _drag = { + page: chipEl.dataset.page, zone: chipEl.dataset.zone, + chipEl, ghost: null, dropZone: null, active: false, + startX: touch.clientX, startY: touch.clientY, ox: 0, oy: 0, + }; + document.addEventListener('touchmove', _onDragMove, { passive: false }); + document.addEventListener('touchend', _onDragEnd); + document.addEventListener('touchcancel', _onDragEnd); + } + + function _activateDrag(touch) { + const rect = _drag.chipEl.getBoundingClientRect(); + _drag.ox = _drag.startX - rect.left; + _drag.oy = _drag.startY - rect.top; + _drag.active = true; + const ghost = _drag.chipEl.cloneNode(true); + ghost.querySelectorAll('button').forEach(b => b.style.display = 'none'); + ghost.style.position = 'fixed'; + ghost.style.zIndex = '9999'; + ghost.style.opacity = '0.9'; + ghost.style.pointerEvents= 'none'; + ghost.style.transform = 'scale(1.08) rotate(-2deg)'; + ghost.style.width = rect.width + 'px'; + ghost.style.height = rect.height + 'px'; + ghost.style.left = (touch.clientX - _drag.ox) + 'px'; + ghost.style.top = (touch.clientY - _drag.oy) + 'px'; + ghost.style.transition = 'none'; + ghost.style.boxShadow = '0 8px 24px rgba(0,0,0,0.5)'; + document.body.appendChild(ghost); + _drag.ghost = ghost; + _drag.chipEl.style.opacity = '0.2'; + } + + function _onDragMove(e) { + if (!_drag) return; + const touch = e.touches[0]; + + if (!_drag.active) { + const dx = Math.abs(touch.clientX - _drag.startX); + const dy = Math.abs(touch.clientY - _drag.startY); + if (dx < 8 && dy < 8) return; // Threshold noch nicht erreicht + _activateDrag(touch); + } + + e.preventDefault(); // Scroll erst NACH Threshold blockieren + _drag.ghost.style.left = (touch.clientX - _drag.ox) + 'px'; + _drag.ghost.style.top = (touch.clientY - _drag.oy) + 'px'; + + let foundZone = null; + ov.querySelectorAll('.wc-zone').forEach(z => { + const r = z.getBoundingClientRect(); + const over = touch.clientX >= r.left && touch.clientX <= r.right + && touch.clientY >= r.top && touch.clientY <= r.bottom; + z.style.borderColor = over ? (worldColors[z.dataset.zone] || 'rgba(196,132,58,0.8)') : 'transparent'; + if (over) foundZone = z.dataset.zone; + }); + _drag.dropZone = foundZone; + } + + function _onDragEnd() { + if (!_drag) return; + _removeDragListeners(); + + const { ghost, chipEl, dropZone, page, zone: fromZone, active } = _drag; + _drag = null; + ov.querySelectorAll('.wc-zone').forEach(z => z.style.borderColor = 'transparent'); + ghost?.remove(); + if (chipEl) chipEl.style.opacity = ''; + + if (!active) return; // nur Tap, kein Drag — nichts verschieben + + const meta = _chipMeta(page); + if (dropZone && dropZone !== fromZone && !(meta?.pinned && dropZone === 'pool')) { + if (fromZone !== 'pool') cfg[fromZone] = cfg[fromZone].filter(p => p !== page); + if (dropZone !== 'pool' && !cfg[dropZone].includes(page)) cfg[dropZone].push(page); + _render(); + } + } + + _render(); + } + + // ── PANORAMA HINTERGRUNDBILD ───────────────────────────────── + + async function _loadDailyImage(dog) { + if (!dog) return null; + const todayKey = 'bg_' + new Date().toISOString().slice(0, 10); + const cached = _wLoad(todayKey); + if (cached?.data) return cached.data; + try { + const r = await _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=30`); + const entries = r.data?.entries || r.data || []; + const withPhotos = entries.filter(e => (e.foto_urls?.length || e.foto_url)); + if (!withPhotos.length) { const u = dog.foto_url || null; if(u) _wSave(todayKey, u); return u; } + const day = Math.floor(Date.now() / 86400000); + const entry = withPhotos[day % withPhotos.length]; + const url = (entry.foto_urls?.[0] || entry.foto_url); + _wSave(todayKey, url); + return url; + } catch { return dog.foto_url || null; } + } + + function _applyBgImage(url) { + const track = document.getElementById('worlds-track'); + if (!track) return; + if (url) { + const img = new Image(); + img.onload = () => { track.style.backgroundImage = `url('${url}')`; track.style.backgroundSize = '100% auto'; track.style.backgroundPosition = '0 40%'; track.style.backgroundRepeat = 'no-repeat'; }; + img.onerror = () => _applyBgImage(null); + img.src = url; + } else { + track.style.backgroundImage = 'linear-gradient(160deg,#1a1f35 0%,#16213e 33%,#1a2535 67%,#0f1921 100%)'; + track.style.backgroundSize = '100% 100%'; + } + } + + // ── CHIP-HELPER ────────────────────────────────────────────── + + function _chip(icon, label, page) { + return ` +
    + + + + ${label} +
    `; + } + + // ── JETZT WORLD ────────────────────────────────────────────── + + async function _renderJetzt() { + const el = document.getElementById('wj-content'); + if (!el) return; + + const user = _state?.user; + el.innerHTML = _skeleton(3); + + const [weatherRes, dogsRes, alertsRes] = await Promise.allSettled([ + _getCachedWeather(), + user ? _cachedGet('dogs', '/dogs') : Promise.resolve({ data: [], fromCache: false, ageMin: 0 }), + user ? _getNearbyAlerts() : Promise.resolve([]), + ]); + + const weatherObj = weatherRes.value || { data: null, fromCache: false, ageMin: 0 }; + const dogsObj = dogsRes.value || { data: [], fromCache: false, ageMin: 0 }; + const w = weatherObj.data; + const dogList = dogsObj.data || []; + const dog = dogList[0] || null; + const alertList = alertsRes.value || []; + const isOffline = weatherObj.fromCache && dogsObj.fromCache; + const staleMin = Math.max(weatherObj.ageMin || 0, dogsObj.ageMin || 0); + + // Panorama-Bild setzen (nur wenn noch kein Bild vorhanden) + const track = document.getElementById('worlds-track'); + if (dog && !track?.style.backgroundImage?.startsWith('url')) { + _loadDailyImage(dog).then(_applyBgImage); + } else if (!dog) { _applyBgImage(null); } + + const hour = new Date().getHours(); + const greet = hour < 5 ? 'Gute Nacht' : hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Hallo' : 'Guten Abend'; + const firstName = user?.name?.split(' ')[0] || ''; + const dayStr = new Date().toLocaleDateString('de-DE', { weekday:'long', day:'numeric', month:'long' }); + const stale = isOffline && staleMin > 5 + ? `· Offline` : ''; + const weatherLine = w + ? `${Math.round(w.temp_c)}° ${_esc(w.desc?.split(' ')[0] || '')} · ${Math.round(w.wind_kmh || 0)} km/h · ${w.precip_prob || 0}% Regen` + : ''; + + // Streak-Reminder + let streakHtml = ''; + if (user && dog) { + try { + const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`); + const s = sr.data; + const streak = s?.current_streak || 0; + const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10); + const col = trainedToday ? 'rgba(16,185,129,0.8)' : (streak > 0 ? 'rgba(245,158,11,0.8)' : 'rgba(255,255,255,0.4)'); + const label = streak > 0 + ? (trainedToday ? `✓ ${streak} Tage Streak` : `🔥 ${streak} Tage — heute noch trainieren!`) + : (trainedToday ? '✓ Heute trainiert' : 'Noch kein Training heute'); + streakHtml = ` +
    + + + + ${label} +
    `; + } catch {} + } + + // Alert-Reminder + const alertHtml = alertList.slice(0,1).map(a => ` +
    + + + + ${_esc(a.title)} +
    `).join(''); + + // Feature Chips aus Config + const features = user + ? _chipsForWorld('jetzt') + : [ + { icon:'sun', label:'Wetter', page:'wetter' }, + { icon:'first-aid',label:'Erste Hilfe', page:'erste-hilfe' }, + { icon:'gear', label:'Anmelden', page:'settings' }, + ]; + + // User-Avatar für JETZT Info-Card + const uFoto = _state?.user?.foto_url; + const uInitial = firstName ? firstName.charAt(0).toUpperCase() : '?'; + const userAvatarHtml = ` +
    + ${uFoto + ? `` + : `
    ${uInitial}
    `} +
    `; + + el.innerHTML = ` +
    +
    +
    +
    +
    + ${_esc(greet)}${firstName ? `, ${_esc(firstName)}` : ''}${stale} +
    +
    ${_esc(dayStr)}${weatherLine ? ' · ' + weatherLine : ''}
    +
    + ${user ? userAvatarHtml : ''} +
    +
    + ${alertHtml} + ${streakHtml} +
    +
    + +
    + ${features.map(f => _chip(f.icon, f.label, f.page)).join('')} +
    +
    + `; + el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); + } + + // ── HUND WORLD ─────────────────────────────────────────────── + + async function _renderHund() { + const el = document.getElementById('wh-content'); + if (!el) return; + const user = _state?.user; + + if (!user) { + el.innerHTML = ` +
    +
    🐾
    +
    Dein Hund wartet
    +
    Melde dich an um loszulegen
    + +
    `; + return; + } + + el.innerHTML = _skeleton(4); + const dogsRes = await _cachedGet('dogs', '/dogs'); + const dogs = dogsRes.data || []; + + if (!dogs.length) { + el.innerHTML = ` +
    +
    🐶
    +
    Noch kein Hund angelegt
    +
    Erstelle das Profil deines Hundes
    + +
    `; + return; + } + + const dog = dogs[0]; + const [streakRes, diaryRes] = await Promise.allSettled([ + _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), + _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=1`), + ]); + const streak = streakRes.value?.data ?? streakRes.value; + const diaryData = diaryRes.value?.data ?? diaryRes.value; + const lastEntry = diaryData?.entries?.[0] || diaryData?.[0] || null; + + // Hunde global cachen für schnelles Cycling + _dogs = dogs; + if (_dogIdx >= _dogs.length) _dogIdx = 0; + const dog = _dogs[_dogIdx]; + + const ageStr = dog.alter_jahre ? _fmtAlter(dog.alter_jahre) : ''; + const stats = [dog.rasse, ageStr, dog.gewicht_kg ? dog.gewicht_kg + ' kg' : null].filter(Boolean).join(' · '); + const otherDogs = _dogs.filter((_, i) => i !== _dogIdx); + const chips = _chipsForWorld('hund'); + + // Avatar des aktuellen Hundes (links, → Hundeprofil) + const dogAvatarHtml = dog.foto_url + ? `` + : `
    🐶
    `; + + // Andere Hunde-Avatare (rechts, überlagernd, → Hund wechseln) + const otherAvatarsHtml = otherDogs.length > 0 ? ` +
    + ${otherDogs.slice(0, 4).map((d, i) => { + const targetIdx = _dogs.indexOf(d); + return ` +
    + ${d.foto_url + ? `` + : `
    + ${_esc(d.name?.charAt(0)?.toUpperCase() || '?')} +
    `} +
    `; + }).join('')} +
    ` : ''; + + el.innerHTML = ` +
    +
    +
    + ${dogAvatarHtml} +
    +
    ${_esc(dog.name)}
    + ${stats ? `
    ${_esc(stats)}
    ` : ''} +
    + ${otherAvatarsHtml} +
    +
    +
    +
    + +
    + ${chips.map(c => _chip(c.icon, c.label, c.page)).join('')} +
    +
    + `; + + // Avatar → Hundeprofil + el.querySelector('#wh-avatar')?.addEventListener('click', () => navigateTo('dog-profile')); + // Chips + el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); + // Name → nächster Hund + if (_dogs.length > 1) { + el.querySelector('#wh-cycle-btn')?.addEventListener('click', () => { + _dogIdx = (_dogIdx + 1) % _dogs.length; + _renderHund(); + }); + } + // Andere Hund-Avatare → zu diesem Hund wechseln + el.querySelectorAll('.wh-other-dog').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.idx); + if (!isNaN(idx) && idx !== _dogIdx) { _dogIdx = idx; _renderHund(); } + }); + }); + } + + // ── WELT WORLD ─────────────────────────────────────────────── + + const _QUOTES = [ + { t:'Ein Hund ist die einzige Kreatur, die dich mehr liebt als sich selbst.', a:'Josh Billings' }, + { t:'Hunde haben Besitzer. Katzen haben Personal.', a:'Unbekannt' }, + { t:'Bis man einen Hund geliebt hat, ist ein Teil der Seele unerwacht.', a:'Anatole France' }, + { t:'Hunde sind nicht unser ganzes Leben — aber sie machen unser Leben ganz.', a:'Roger Caras' }, + { t:'Der Hund hat nur einen Fehler: Er vertraut dem Menschen zu sehr.', a:'Unbekannt' }, + { t:'Ein treuer Hund ist besser als ein unzuverlässiger Mensch.', a:'Deutsches Sprichwort' }, + { t:'Wer einen Hund hat, gibt nie allein zu Tisch.', a:'Unbekannt' }, + { t:'Die reinste Form der Freude ist die Freude eines Hundes.', a:'Milan Kundera' }, + { t:'Hunde lachen nicht mit dem Mund, sondern mit dem Schwanz.', a:'Max Eastman' }, + { t:'Ein Haus ohne Hund ist ein leeres Haus.', a:'Unbekannt' }, + { t:'Wenn du verstehen willst, was Liebe ist, beobachte einen Hund.', a:'Unbekannt' }, + { t:'Der Hund fragt nicht warum. Er fragt nur: wann gehen wir?', a:'Unbekannt' }, + { t:'Hunde riechen nicht schlecht. Sie riechen nur anders als wir denken.', a:'Unbekannt' }, + { t:'In einer Welt voller Unsicherheiten ist der Hund das Verlässlichste.', a:'Unbekannt' }, + { t:'Ein Spaziergang mit einem Hund ist nie wirklich ein Umweg.', a:'Unbekannt' }, + { t:'Hunde bringen das Beste im Menschen hervor.', a:'Unbekannt' }, + { t:'Wer mit Hunden liegt, steht mit Flöhen auf — und mit einem Lächeln.', a:'Unbekannt' }, + { t:'Ein Hund weiß, wann du traurig bist. Er weiß nicht warum. Aber er ist da.', a:'Unbekannt' }, + { t:'Das Geheimnis eines Hundes: Er beurteilt dich nie.', a:'Unbekannt' }, + { t:'Der Hund ist Gattung: Mensch.', a:'Charles M. Schulz' }, + { t:'Hunde haben viel zu sagen. Wir haben verlernt zuzuhören.', a:'Unbekannt' }, + { t:'Ein guter Hund macht aus einem schlechten Tag einen erträglichen.', a:'Unbekannt' }, + { t:'Der Hund ist der philosophischste aller Haustiere.', a:'George Graham Vest' }, + { t:'Wer einen Hund hat, braucht keinen Therapeuten.', a:'Unbekannt' }, + { t:'Hunde lieben bedingungslos. Das ist ihr größtes Geschenk.', a:'Unbekannt' }, + { t:'Der schönste Empfang ist der eines Hundes an der Tür.', a:'Unbekannt' }, + { t:'Ein Hund ändert dein Leben. Meistens zum Besseren.', a:'Unbekannt' }, + { t:'Hunde altern in Würde. Menschen könnten von ihnen lernen.', a:'Unbekannt' }, + { t:'Kein Psychiater der Welt kann so gut zuhören wie ein Hund.', a:'Unbekannt' }, + { t:'Wo Hunde sind, da ist das Zuhause.', a:'Unbekannt' }, + { t:'Der Hund hat keinen Begriff von Vergangenheit oder Zukunft. Er lebt.', a:'Milan Kundera' }, + ]; + + function _renderWelt() { + const el = document.getElementById('ww-content'); + if (!el) return; + const user = _state?.user; + const isMod = user?.rolle === 'admin' || user?.rolle === 'moderator' || user?.is_moderator; + const isAdmin = user?.rolle === 'admin'; + const isSocial = user?.is_social_media || isAdmin; + + // Tagesbasierter Spruch + const day = Math.floor(Date.now() / 86400000); + const quote = _QUOTES[day % _QUOTES.length]; + + const chips = _chipsForWorld('welt'); + + el.innerHTML = ` +
    +
    +
    Gedanke des Tages
    +
    + »${_esc(quote.t)}« +
    +
    + — ${_esc(quote.a)} +
    +
    +
    +
    + +
    + ${chips.map(c => _chip(c.icon, c.label, c.page)).join('')} +
    +
    + `; + el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); + } + + // ── HELPERS ────────────────────────────────────────────────── + + async function _getCachedWeather() { + const cached = _wLoad('weather'); + let fresh = null, pos = null; + try { + pos = await API.getLocation({ timeout: 5000, maximumAge: 600_000 }); + fresh = await API.weather.get(pos.lat, pos.lon); + _wSave('weather', fresh); + } catch {} + const data = fresh ?? cached?.data ?? null; + return { data, fromCache: !fresh, ageMin: fresh ? 0 : (cached?.ageMin ?? null) }; + } + + async function _getWeather() { + const r = await _getCachedWeather(); + return r.data; + } + + async function _getNearbyAlerts() { + const out = []; + try { + const pos = await API.getLocation({ timeout: 4000, maximumAge: 600_000 }); + const [p, l] = await Promise.allSettled([ + API.get(`/poison/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []), + API.get(`/lost/nearby?lat=${pos.lat}&lon=${pos.lon}&radius=5`).catch(() => []), + ]); + if (p.value?.length) out.push({ icon:'skull', color:'#EF4444', title:'Giftköder in der Nähe', sub:`${p.value.length} Meldung${p.value.length>1?'en':''}`, page:'poison' }); + if (l.value?.length) out.push({ icon:'dog', color:'#3B82F6', title:'Verlorener Hund', sub:`${l.value.length} Meldung${l.value.length>1?'en':''}`, page:'lost' }); + } catch {} + return out; + } + + function _skeleton(n) { + return Array.from({length: n}, (_, i) => ` +
    + `).join(''); + } + + function _fmtDate(d) { + if (!d) return ''; + try { return new Date(d).toLocaleDateString('de-DE', { day:'numeric', month:'short' }); } + catch { return d; } + } + + function _fmtAlter(j) { + if (!j) return ''; + if (j < 1) return `${Math.round(j * 12)} Monate`; + return j < 2 ? '1 Jahr' : `${Math.round(j)} Jahre`; + } + + function _esc(s) { + if (s == null) return ''; + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + return { init, show, hide, navigateTo, openConfig: _openConfigModal, get _visible() { return _visible; } }; + +})(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 177e86b..6ff893c 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v607'; +const CACHE_VERSION = 'by-v639'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache @@ -125,11 +125,34 @@ const _CACHEABLE_GET = [ /^\/api\/training\/progress/, /^\/api\/wiki\/rassen/, /^\/api\/dogs\/\d+\/diary\/stats/, + // Drei Welten — offline-fähig + /^\/api\/streak\/\d+/, + /^\/api\/forum\/threads/, + /^\/api\/weather$/, + /^\/api\/passport\/\d+$/, ]; function _isCacheableGet(pathname) { return _CACHEABLE_GET.some(re => re.test(pathname)); } +// Cache-TTL: stabile Daten länger, dynamische kürzer +const _STABLE_GET = [/^\/api\/training\/exercises/, /^\/api\/wiki\/rassen/]; +const _TTL_STABLE = 60 * 60 * 1000; // 1 Stunde +const _TTL_DEFAULT = 5 * 60 * 1000; // 5 Minuten + +const _cacheTs = new Map(); // pathname → timestamp (in-memory, ok bei SW-Neustart) + +function _cacheTTL(pathname) { + return _STABLE_GET.some(re => re.test(pathname)) ? _TTL_STABLE : _TTL_DEFAULT; +} +function _cacheStale(pathname) { + const ts = _cacheTs.get(pathname); + return !ts || (Date.now() - ts) > _cacheTTL(pathname); +} +function _cacheMark(pathname) { + _cacheTs.set(pathname, Date.now()); +} + // ---------------------------------------------------------- // INSTALL — App Shell cachen // ---------------------------------------------------------- @@ -173,19 +196,27 @@ self.addEventListener('fetch', event => { if (method === 'GET' && _isCacheableGet(url.pathname)) { event.respondWith((async () => { const cached = await caches.match(event.request); + const stale = _cacheStale(url.pathname); + const networkPromise = _fetchTimeout(event.request.clone(), 8000) .then(resp => { - if (resp.ok) caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + if (resp.ok) { + _cacheMark(url.pathname); + caches.open(CACHE_API).then(c => c.put(event.request, resp.clone())); + } return resp; }) .catch(() => null); - // Stale-While-Revalidate: sofort aus Cache, im Hintergrund holen - if (cached) { - networkPromise.catch(() => {}); // fire and forget + + // Cache noch frisch → sofort zurückgeben, Netz im Hintergrund + if (cached && !stale) { + networkPromise.catch(() => {}); return cached; } + // Cache vorhanden aber abgelaufen → Netz zuerst, Cache als Fallback const fresh = await networkPromise; if (fresh) return fresh; + if (cached) return cached; // lieber veraltet als nichts return new Response(JSON.stringify({ detail: 'Offline — keine Daten im Cache.' }), { status: 503, headers: { 'Content-Type': 'application/json' } }); })()); From c266814aa959766b078b0a986cf168f529f538c1 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:22:13 +0200 Subject: [PATCH 51/63] =?UTF-8?q?Fix:=20worlds.js=20doppeltes=20const=20do?= =?UTF-8?q?g=20=E2=86=92=20SyntaxError=20behoben,=20SW=20by-v640?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 2 +- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 11 +++++------ backend/static/sw.js | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 559a4e0..e52f1aa 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -566,7 +566,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 792086d..1b287ad 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '639'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '640'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 37c9ba7..043d2bd 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -761,7 +761,11 @@ window.Worlds = (() => { return; } - const dog = dogs[0]; + // Hunde global cachen für schnelles Cycling + _dogs = dogs; + if (_dogIdx >= _dogs.length) _dogIdx = 0; + const dog = _dogs[_dogIdx]; + const [streakRes, diaryRes] = await Promise.allSettled([ _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`), _cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=1`), @@ -770,11 +774,6 @@ window.Worlds = (() => { const diaryData = diaryRes.value?.data ?? diaryRes.value; const lastEntry = diaryData?.entries?.[0] || diaryData?.[0] || null; - // Hunde global cachen für schnelles Cycling - _dogs = dogs; - if (_dogIdx >= _dogs.length) _dogIdx = 0; - const dog = _dogs[_dogIdx]; - const ageStr = dog.alter_jahre ? _fmtAlter(dog.alter_jahre) : ''; const stats = [dog.rasse, ageStr, dog.gewicht_kg ? dog.gewicht_kg + ' kg' : null].filter(Boolean).join(' · '); const otherDogs = _dogs.filter((_, i) => i !== _dogIdx); diff --git a/backend/static/sw.js b/backend/static/sw.js index 6ff893c..c79f28f 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v639'; +const CACHE_VERSION = 'by-v640'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 9410a8bcd4b629cde3980e8e64fb89f3a55f81ba Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:26:14 +0200 Subject: [PATCH 52/63] =?UTF-8?q?Fix:=20Welten=20Info-Cards=20=E2=80=94=20?= =?UTF-8?q?avatar=5Furl=20statt=20foto=5Furl,=20Hund-Name=20CSS-Grid=201fr?= =?UTF-8?q?-auto-1fr=20zentriert,=20SW=20by-v641?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 2 +- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 12 ++++++------ backend/static/sw.js | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index e52f1aa..0cc63ca 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -566,7 +566,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 1b287ad..5ae66ae 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '640'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '641'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 043d2bd..3b96a26 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -688,7 +688,7 @@ window.Worlds = (() => { ]; // User-Avatar für JETZT Info-Card - const uFoto = _state?.user?.foto_url; + const uFoto = _state?.user?.avatar_url; const uInitial = firstName ? firstName.charAt(0).toUpperCase() : '?'; const userAvatarHtml = `
    { el.innerHTML = `
    -
    - ${dogAvatarHtml} -
    +
    +
    ${dogAvatarHtml}
    +
    ${_esc(dog.name)}
    ${stats ? `
    ${_esc(stats)}
    ` : ''}
    - ${otherAvatarsHtml} +
    ${otherAvatarsHtml}
    diff --git a/backend/static/sw.js b/backend/static/sw.js index c79f28f..030a8be 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v640'; +const CACHE_VERSION = 'by-v641'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 4da20880747fab52aeeb4c1376703f060b7049d8 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:31:21 +0200 Subject: [PATCH 53/63] =?UTF-8?q?Feature:=20Welten=20=E2=80=94=20Profil-Ch?= =?UTF-8?q?ip=20entfernt,=20Footer-Links=20(Impressum/Die100/Datenschutz),?= =?UTF-8?q?=20SW=20by-v642?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 299 ++++++++++++++++++++++++++++++ backend/static/index.html | 4 +- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 11 +- backend/static/sw.js | 2 +- 5 files changed, 313 insertions(+), 5 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 9e16ea7..1d2190b 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7516,3 +7516,302 @@ svg.empty-state-icon { background: var(--c-primary); border-color: var(--c-primary); } + +/* ================================================================ + DREI WELTEN NAVIGATION — JETZT | HUND | WELT + ================================================================ */ + +/* Overlay */ +#worlds-overlay { + position: fixed; + inset: 0; + z-index: 50; + overflow: hidden; + background: var(--c-bg); + display: none; + opacity: 0; + transition: opacity 0.2s; +} +#worlds-overlay.worlds-visible { opacity: 1; } + +/* Track */ +#worlds-track { + display: flex; + width: 300%; + height: 100%; + will-change: transform; + transform: translateX(-33.333%); +} +.world-panel { + width: 33.333%; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; +} +#wp-welt { overflow: hidden; position: relative; } + +/* Navigation-Punkte */ +#world-dots { + position: fixed; + top: calc(env(safe-area-inset-top, 0px) + 14px); + left: 0; right: 0; + display: flex; + justify-content: center; + gap: 5px; + z-index: 60; + pointer-events: none; +} +.wdot { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--c-text); + opacity: 0.2; + transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); + cursor: pointer; +} +.wdot.active { + width: 22px; + border-radius: 3px; + opacity: 1; + background: var(--c-primary); +} + +/* Welt-Labels */ +#world-labels { + position: fixed; + top: calc(env(safe-area-inset-top, 0px) + 28px); + left: 0; right: 0; + display: flex; + justify-content: center; + gap: 28px; + z-index: 59; + pointer-events: none; +} +.wlabel { + font-size: 9px; + font-weight: 800; + letter-spacing: 0.12em; + color: var(--c-text-secondary); + opacity: 0.4; + text-transform: uppercase; +} + +/* Settings-Button */ +#worlds-settings { + position: fixed; + top: calc(env(safe-area-inset-top, 0px) + 6px); + right: 10px; + width: 38px; height: 38px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 61; + color: var(--c-text-secondary); + border: none; + background: none; + border-radius: 50%; +} + +/* FAB */ +#worlds-fab { + position: fixed; + bottom: calc(env(safe-area-inset-bottom, 16px) + 16px); + right: 20px; + width: 54px; height: 54px; + border-radius: 50%; + background: var(--c-primary); + color: white; + border: none; + cursor: pointer; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 20px rgba(196,132,58,0.45); + transition: transform 0.12s, box-shadow 0.12s; +} +#worlds-fab:active { transform: scale(0.92); box-shadow: 0 2px 10px rgba(196,132,58,0.3); } + +/* Back-Button */ +#worlds-back { + position: fixed; + top: 0; left: 0; right: 0; + height: calc(env(safe-area-inset-top, 0px) + 44px); + padding-top: env(safe-area-inset-top, 0px); + display: none; + align-items: center; + gap: 8px; + padding-left: 16px; + background: var(--c-bg); + border-bottom: 1px solid var(--c-border); + z-index: 200; + cursor: pointer; + color: var(--c-primary); + font-weight: 600; + font-size: var(--text-sm); +} +#worlds-back.worlds-back-visible { display: flex; } + +/* Hide existing header + bottom-nav when worlds are active */ +.worlds-hidden { display: none !important; } + +/* ── JETZT WORLD ─────────────────────────────────────────── */ + +/* ═══════════════════════════════════════════════════════════════ + DREI WELTEN — PANORAMA-BILD + FROSTED GLASS CHIPS + ═══════════════════════════════════════════════════════════════ */ + +/* Bild auf dem Track: alle 3 Panels teilen dasselbe Panorama */ +#worlds-track { + background-size: 100% auto; + background-position: 0 40%; + background-repeat: no-repeat; +} + +/* World Panel: dunkles Overlay über dem Hintergrundbild */ +.world-panel { + background: rgba(0, 0, 0, 0.44); + display: flex; + flex-direction: column; + justify-content: space-between; /* Info oben, Chips unten */ + padding: calc(env(safe-area-inset-top, 0px) + 58px) 14px + calc(env(safe-area-inset-bottom, 0px) + 88px); + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; +} + +/* Content-Divs füllen den Panel und verteilen Top/Bottom */ +#wj-content, #wh-content, #ww-content { + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 100%; +} + +/* Oberer Bereich: Info + Reminders */ +.world-top { display: flex; flex-direction: column; gap: 10px; } + +/* Unterer Bereich: Chips (Daumen-Zone) */ +.world-bottom { display: flex; flex-direction: column; gap: 8px; } + +/* Frosted-Glass Info-Card (oben in jeder Welt) */ +.world-info-card { + background: rgba(0, 0, 0, 0.38); + backdrop-filter: blur(18px) saturate(1.6); + -webkit-backdrop-filter: blur(18px) saturate(1.6); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 20px; + padding: 16px 18px; + color: white; + flex-shrink: 0; +} +.world-info-title { + font-size: var(--text-xl); + font-weight: 800; + line-height: 1.1; + color: white; +} +.world-info-sub { + font-size: var(--text-xs); + color: rgba(255, 255, 255, 0.65); + margin-top: 4px; +} + +/* Frosted-Glass Reminder-Card (für Streak, Alerts) */ +.world-reminder { + background: rgba(0, 0, 0, 0.32); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 16px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + color: white; + flex-shrink: 0; +} +.world-reminder:active { background: rgba(0,0,0,0.55); } + +/* Chip-Grid: GLEICH auf allen drei Welten */ +.world-chips-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-auto-rows: 80px; /* alle Chips gleich hoch */ + gap: 8px; + margin-top: auto; +} + +/* Einzelner Chip: Frosted Glass */ +.world-chip { + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 16px; + padding: 14px 6px 11px; + text-align: center; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + color: white; + transition: background 0.12s, transform 0.1s; + -webkit-tap-highlight-color: transparent; + user-select: none; +} +.world-chip:active { + background: rgba(0, 0, 0, 0.6); + transform: scale(0.93); +} +.world-chip svg { color: white; } +.world-chip-label { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + line-height: 1.2; +} + +/* Chip-Umrandung je Welt */ +#wp-jetzt .world-chip { border: 1px solid rgba(196, 132, 58, 0.55); } +#wp-hund .world-chip { border: 1px solid rgba(196, 132, 58, 0.65); } +#wp-welt .world-chip { border: 1px solid rgba(99, 130, 220, 0.55); } + +/* Sektion-Label über Chip-Gruppen */ +.world-section-label { + font-size: 9px; + font-weight: 800; + letter-spacing: 0.10em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + padding: 4px 2px 0; +} + +/* Footer-Links (Impressum / Die 100 / Datenschutz) */ +.world-footer-links { + text-align: center; + padding: 10px 0 2px; +} +.world-footer-links span { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + cursor: pointer; + letter-spacing: 0.04em; + transition: color 0.15s; +} +.world-footer-links span:hover, +.world-footer-links span:active { + color: rgba(255, 255, 255, 0.65); +} + +/* ── KEYFRAMES ───────────────────────────────────────────── */ +@keyframes pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 0.25; } +} diff --git a/backend/static/index.html b/backend/static/index.html index 0cc63ca..5917bde 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -95,7 +95,7 @@ - + @@ -566,7 +566,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 5ae66ae..683e098 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '641'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '642'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 3b96a26..154289f 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -253,7 +253,7 @@ window.Worlds = (() => { { icon:'handshake', label:'Playdate', page:'playdate' }, { icon:'chat-circle-dots', label:'Nachrichten', page:'chat' }, { icon:'sun', label:'Wetter', page:'wetter' }, - { icon:'gear', label:'Profil', page:'settings', pinned:true }, + { icon:'book-open', label:'Tagebuch', page:'diary' }, { icon:'heartbeat', label:'Gesundheit', page:'health' }, { icon:'target', label:'Übungen', page:'uebungen' }, @@ -723,6 +723,9 @@ window.Worlds = (() => {
    ${features.map(f => _chip(f.icon, f.label, f.page)).join('')}
    +
    `; el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); @@ -830,6 +833,9 @@ window.Worlds = (() => {
    ${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
    +
    `; @@ -921,6 +927,9 @@ window.Worlds = (() => {
    ${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
    +
    `; el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); diff --git a/backend/static/sw.js b/backend/static/sw.js index 030a8be..c6a9de3 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v641'; +const CACHE_VERSION = 'by-v642'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 860de6d2a7c12e2f18a383be45585251f27451cc Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:33:49 +0200 Subject: [PATCH 54/63] =?UTF-8?q?Fix:=20Welten=20Footer-Links=20dezent=20h?= =?UTF-8?q?ervorgehoben=20=E2=80=94=20heller,=20Underline,=20Text-Shadow,?= =?UTF-8?q?=20SW=20by-v643?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 12 ++++++++---- backend/static/index.html | 4 ++-- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 1d2190b..307c097 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7799,15 +7799,19 @@ svg.empty-state-icon { padding: 10px 0 2px; } .world-footer-links span { - font-size: 10px; - color: rgba(255, 255, 255, 0.35); + font-size: 11px; + color: rgba(255, 255, 255, 0.6); cursor: pointer; - letter-spacing: 0.04em; + letter-spacing: 0.05em; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: rgba(255, 255, 255, 0.3); transition: color 0.15s; } .world-footer-links span:hover, .world-footer-links span:active { - color: rgba(255, 255, 255, 0.65); + color: rgba(255, 255, 255, 0.9); } /* ── KEYFRAMES ───────────────────────────────────────────── */ diff --git a/backend/static/index.html b/backend/static/index.html index 5917bde..8bd06b7 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -95,7 +95,7 @@ - + @@ -566,7 +566,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 683e098..3e8a49d 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '642'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '643'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index c6a9de3..d96ca4a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v642'; +const CACHE_VERSION = 'by-v643'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 150776eab4d54b98b15d8e5ae8f93ed61292ecff Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:46:12 +0200 Subject: [PATCH 55/63] =?UTF-8?q?Feature:=20Welten-Navigation=20=E2=80=94?= =?UTF-8?q?=20Bottom-Nav+Header=20entfernt,=20Zur=C3=BCck-FAB=20(rund,=20d?= =?UTF-8?q?unkel),=20SW=20by-v644?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 36 +++++++++++++++++----------- backend/static/css/design-system.css | 4 ++-- backend/static/css/layout.css | 1 + backend/static/index.html | 13 +++++----- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 2 -- backend/static/sw.js | 2 +- 7 files changed, 33 insertions(+), 27 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 307c097..81f27db 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7635,27 +7635,35 @@ svg.empty-state-icon { } #worlds-fab:active { transform: scale(0.92); box-shadow: 0 2px 10px rgba(196,132,58,0.3); } -/* Back-Button */ +/* Header + Bottom-Nav: vollständig entfernt — Welten übernehmen Navigation */ +#app-header { display: none !important; } +#bottom-nav { display: none !important; } + +/* Zurück-FAB (gleiche Position wie worlds-fab, anderer Stil) */ #worlds-back { position: fixed; - top: 0; left: 0; right: 0; - height: calc(env(safe-area-inset-top, 0px) + 44px); - padding-top: env(safe-area-inset-top, 0px); + bottom: calc(var(--safe-bottom) + 20px); + right: 20px; + width: 54px; + height: 54px; + border-radius: 50%; + background: rgba(20, 24, 36, 0.88); + border: 1.5px solid rgba(255, 255, 255, 0.18); + color: white; + cursor: pointer; + z-index: 200; display: none; align-items: center; - gap: 8px; - padding-left: 16px; - background: var(--c-bg); - border-bottom: 1px solid var(--c-border); - z-index: 200; - cursor: pointer; - color: var(--c-primary); - font-weight: 600; - font-size: var(--text-sm); + justify-content: center; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.40); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + transition: transform 0.12s, box-shadow 0.12s; } #worlds-back.worlds-back-visible { display: flex; } +#worlds-back:active { transform: scale(0.92); box-shadow: 0 2px 8px rgba(0,0,0,0.3); } -/* Hide existing header + bottom-nav when worlds are active */ +/* worlds-hidden bleibt für eventuelle andere Verwendung */ .worlds-hidden { display: none !important; } /* ── JETZT WORLD ─────────────────────────────────────────── */ diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index 5b6f1e0..cc9c40f 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -105,9 +105,9 @@ --transition-slow: 320ms ease; /* Navigation */ - --nav-bottom-height: 64px; + --nav-bottom-height: 78px; /* Welten-Zurück-FAB: 54px + 20px bottom + 4px Abstand */ --nav-sidebar-width: 240px; - --header-height: 56px; + --header-height: 0px; /* Header entfernt — Welten-Navigation übernimmt */ /* Safe Areas (iPhone Notch/Home Indicator) */ --safe-top: env(safe-area-inset-top, 0px); diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index 399a9c7..1a0a778 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -19,6 +19,7 @@ min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */ overflow-y: auto; overflow-x: hidden; + padding-top: var(--safe-top); padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px); -webkit-overflow-scrolling: touch; } diff --git a/backend/static/index.html b/backend/static/index.html index 8bd06b7..0762159 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -551,9 +551,8 @@
    -
    - - Zurück +
    +
    @@ -566,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3e8a49d..26147b4 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '643'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '644'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 154289f..2056b6f 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -81,8 +81,6 @@ window.Worlds = (() => { ov.classList.remove('worlds-visible'); ov.style.display = 'none'; _visible = false; - document.getElementById('app-header')?.classList.remove('worlds-hidden'); - document.getElementById('bottom-nav')?.classList.remove('worlds-hidden'); document.getElementById('worlds-back')?.classList.add('worlds-back-visible'); } diff --git a/backend/static/sw.js b/backend/static/sw.js index d96ca4a..77b6b36 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v643'; +const CACHE_VERSION = 'by-v644'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 0488cde13fd8909f3e8f2d8c371ceae6839c02df Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:52:48 +0200 Subject: [PATCH 56/63] =?UTF-8?q?Fix:=20Zur=C3=BCck-Button=20prim=C3=A4r-o?= =?UTF-8?q?range,=20Map/Route=20vollbild=20(kein=20wei=C3=9Fer=20Balken),?= =?UTF-8?q?=20Map-FABs=2054px=20und=20=C3=BCber=20Back-Button,=20SW=20by-v?= =?UTF-8?q?645?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 34 +++++++++++++++---------------- backend/static/index.html | 8 ++++---- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 81f27db..30e4be6 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3078,10 +3078,10 @@ html.modal-open { ============================================================ */ .map-full-layout { position: fixed; - top: calc(var(--header-height) + var(--safe-top)); + top: var(--safe-top); left: 0; right: 0; - bottom: calc(var(--nav-bottom-height) + var(--safe-bottom)); + bottom: 0; overflow: hidden; z-index: 1; } @@ -3148,11 +3148,11 @@ html.modal-open { color: #fff; } -/* FAB-Gruppe rechts unten */ +/* FAB-Gruppe rechts unten — direkt über dem Zurück-Button */ .map-fabs { position: absolute; - bottom: var(--space-4); - right: var(--space-3); + bottom: calc(var(--safe-bottom) + 82px); /* 54px back + 20px bottom + 8px gap */ + right: 20px; z-index: 1000; display: flex; flex-direction: column; @@ -3160,8 +3160,8 @@ html.modal-open { align-items: center; } .map-fab { - width: 44px; - height: 44px; + width: 54px; + height: 54px; border-radius: 50%; background: #C4843A; color: #fff; @@ -3975,11 +3975,10 @@ html.modal-open { .rk-map-loc-input:focus { outline: none; border-color: var(--c-primary); } .rk-map-section { position: fixed; - /* Unter dem App-Header, über der Bottom-Nav */ - top: calc(var(--header-height) + var(--safe-top)); + top: var(--safe-top); left: 0; right: 0; - bottom: calc(var(--nav-bottom-height) + var(--safe-bottom)); + bottom: 0; z-index: 200; display: flex; flex-direction: column; @@ -7639,7 +7638,7 @@ svg.empty-state-icon { #app-header { display: none !important; } #bottom-nav { display: none !important; } -/* Zurück-FAB (gleiche Position wie worlds-fab, anderer Stil) */ +/* Zurück-FAB — gleiche Farbe und Größe wie Seiten-FABs */ #worlds-back { position: fixed; bottom: calc(var(--safe-bottom) + 20px); @@ -7647,21 +7646,20 @@ svg.empty-state-icon { width: 54px; height: 54px; border-radius: 50%; - background: rgba(20, 24, 36, 0.88); - border: 1.5px solid rgba(255, 255, 255, 0.18); - color: white; + background: var(--c-primary); + border: none; + color: #fff; cursor: pointer; z-index: 200; display: none; align-items: center; justify-content: center; - box-shadow: 0 4px 18px rgba(0, 0, 0, 0.40); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); + box-shadow: 0 4px 18px rgba(196, 132, 58, 0.45); transition: transform 0.12s, box-shadow 0.12s; + -webkit-tap-highlight-color: transparent; } #worlds-back.worlds-back-visible { display: flex; } -#worlds-back:active { transform: scale(0.92); box-shadow: 0 2px 8px rgba(0,0,0,0.3); } +#worlds-back:active { transform: scale(0.92); box-shadow: 0 2px 8px rgba(196,132,58,0.3); } /* worlds-hidden bleibt für eventuelle andere Verwendung */ .worlds-hidden { display: none !important; } diff --git a/backend/static/index.html b/backend/static/index.html index 0762159..c644aff 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 26147b4..5457f4f 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '644'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '645'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index 77b6b36..b3aacd8 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v644'; +const CACHE_VERSION = 'by-v645'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From cd5aa73df36af264bb65fe7732ef968772960d19 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:55:45 +0200 Subject: [PATCH 57/63] =?UTF-8?q?Fix:=20Welten-Overlay=20Gradient=20statt?= =?UTF-8?q?=20flachem=20Schwarz=20=E2=80=94=20Mitte=20leuchtet,=20SW=20by-?= =?UTF-8?q?v646?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 10 ++++++++-- backend/static/index.html | 8 ++++---- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 30e4be6..9f8b492 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7677,9 +7677,15 @@ svg.empty-state-icon { background-repeat: no-repeat; } -/* World Panel: dunkles Overlay über dem Hintergrundbild */ +/* World Panel: Gradient-Overlay — Mitte leuchtet, oben/unten gedämpft für Lesbarkeit */ .world-panel { - background: rgba(0, 0, 0, 0.44); + background: linear-gradient( + to bottom, + rgba(0,0,0,0.52) 0%, + rgba(0,0,0,0.18) 28%, + rgba(0,0,0,0.14) 55%, + rgba(0,0,0,0.52) 100% + ); display: flex; flex-direction: column; justify-content: space-between; /* Info oben, Chips unten */ diff --git a/backend/static/index.html b/backend/static/index.html index c644aff..7534992 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 5457f4f..700fccf 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '645'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '646'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index b3aacd8..3571d48 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v645'; +const CACHE_VERSION = 'by-v646'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 1d50bf14307c1604aacfd00a967aebaa0c583e21 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 10:57:39 +0200 Subject: [PATCH 58/63] =?UTF-8?q?Fix:=20Desktop-Sidebar=20entfernt=20?= =?UTF-8?q?=E2=80=94=20kein=20padding-left,=20Map/Route=20volle=20Breite,?= =?UTF-8?q?=20SW=20by-v647?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 8 ++++++++ backend/static/index.html | 8 ++++---- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 9f8b492..4f93c16 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7826,6 +7826,14 @@ svg.empty-state-icon { color: rgba(255, 255, 255, 0.9); } +/* Desktop-Sidebar entfernt — Welten übernehmen Navigation */ +@media (min-width: 768px) { + #sidebar { display: none !important; } + #page-content { padding-left: 0 !important; } + .map-full-layout { left: 0 !important; } + .rk-map-section { left: 0 !important; } +} + /* ── KEYFRAMES ───────────────────────────────────────────── */ @keyframes pulse { 0%, 100% { opacity: 0.5; } diff --git a/backend/static/index.html b/backend/static/index.html index 7534992..cab50bc 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 700fccf..d6ec7a7 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '646'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '647'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index 3571d48..27f400a 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v646'; +const CACHE_VERSION = 'by-v647'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From bb8ceaf1146b8316e703b5281598a4c5b7231205 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 11:02:57 +0200 Subject: [PATCH 59/63] =?UTF-8?q?Feature:=20JETZT-Welt=20=E2=80=94=20Gassi?= =?UTF-8?q?runde+=C3=9Cbungs-Vorschlag-Balken,=20Desktop-Zahnrad=20entfern?= =?UTF-8?q?t,=20SW=20by-v648?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 3 +- backend/static/index.html | 8 ++-- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 80 +++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 4f93c16..9415702 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7826,9 +7826,10 @@ svg.empty-state-icon { color: rgba(255, 255, 255, 0.9); } -/* Desktop-Sidebar entfernt — Welten übernehmen Navigation */ +/* Desktop-Sidebar + Zahnrad entfernt — Welten übernehmen Navigation */ @media (min-width: 768px) { #sidebar { display: none !important; } + #worlds-settings { display: none !important; } #page-content { padding-left: 0 !important; } .map-full-layout { left: 0 !important; } .rk-map-section { left: 0 !important; } diff --git a/backend/static/index.html b/backend/static/index.html index cab50bc..a1fdb4c 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d6ec7a7..3f47c07 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '647'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '648'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 2056b6f..3722220 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -715,6 +715,33 @@ window.Worlds = (() => {
    ${alertHtml} ${streakHtml} + ${user && dog ? ` +
    +
    + + +
    +
    Gassirunde
    +
    Berechne…
    +
    + + +
    +
    + + +
    +
    Übung des Tages
    +
    Lade…
    +
    + + +
    +
    ` : ''}
    @@ -727,6 +754,59 @@ window.Worlds = (() => {
    `; el.querySelectorAll('[data-wnav]').forEach(e => e.addEventListener('click', () => navigateTo(e.dataset.wnav))); + + if (user && dog) { + _loadJetztExercise(dog); + _loadJetztRoute(); + } + } + + async function _loadJetztExercise(dog) { + const valEl = document.getElementById('wj-exercise-val'); + if (!valEl) return; + try { + const res = await _cachedGet(`dash_${dog.id}`, `/dogs/${dog.id}/welcome-dashboard`); + const ex = res.data?.daily_exercise; + valEl.textContent = ex?.name || '—'; + } catch { valEl.textContent = '—'; } + } + + async function _loadJetztRoute() { + const valEl = document.getElementById('wj-route-val'); + if (!valEl) return; + + // Tages-Cache (gleicher Key wie welcome.js) + const today = new Date().toISOString().slice(0, 10); + const cacheKey = 'by_daily_route_' + today; + const cached = localStorage.getItem(cacheKey); + if (cached) { + try { _applyJetztRoute(valEl, JSON.parse(cached)); return; } catch {} + } + + let loc; + try { loc = await API.getLocation({ timeout: 5000, maximumAge: 300000 }); } + catch { valEl.textContent = 'Standort nötig'; return; } + + const dayIdx = Math.floor(Date.now() / 86400000); + const km = [2, 4, 6][dayIdx % 3]; + const seed = dayIdx % 5; + try { + const result = await API.post('/routes/suggest', { lat: loc.lat, lon: loc.lon, distance_km: km, seed }); + if (!result?.gps_track?.length) { valEl.textContent = 'Keine Route gefunden'; return; } + localStorage.setItem(cacheKey, JSON.stringify(result)); + // alte Einträge aufräumen + Object.keys(localStorage) + .filter(k => k.startsWith('by_daily_route_') && k !== cacheKey) + .forEach(k => localStorage.removeItem(k)); + _applyJetztRoute(valEl, result); + } catch { valEl.textContent = 'Route nicht verfügbar'; } + } + + function _applyJetztRoute(valEl, result) { + const durStr = result.dauer_min < 60 + ? `${result.dauer_min} min` + : `${Math.floor(result.dauer_min / 60)}h ${result.dauer_min % 60 > 0 ? ' ' + (result.dauer_min % 60) + 'min' : ''}`; + valEl.textContent = `${result.distanz_km} km · ${durStr}`; } // ── HUND WORLD ─────────────────────────────────────────────── diff --git a/backend/static/sw.js b/backend/static/sw.js index 27f400a..b70d445 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v647'; +const CACHE_VERSION = 'by-v648'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 87d3006aa72fd91d8492825e4d1c2a4c096db2dc Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 11:04:24 +0200 Subject: [PATCH 60/63] =?UTF-8?q?Fix:=20Swipe-Begrenzung=20=E2=80=94=20kei?= =?UTF-8?q?n=20=C3=9Cberziehen=20=C3=BCber=20erste/letzte=20Welt,=20SW=20b?= =?UTF-8?q?y-v649?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 8 ++++---- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 6 ++++-- backend/static/sw.js | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index a1fdb4c..9c8b3ae 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3f47c07..605d4cf 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '648'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '649'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 3722220..9ef2e6a 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -109,9 +109,11 @@ window.Worlds = (() => { if (_t.vert === null) _t.vert = Math.abs(dy) > Math.abs(dx) + 4; if (_t.vert) return; e.preventDefault(); - _t.moved = dx; + // Nicht über erste/letzte Seite hinausziehen + const cdx = _cur === 0 ? Math.min(0, dx) : _cur === 2 ? Math.max(0, dx) : dx; + _t.moved = cdx; const base = -_cur * (100 / 3); - track.style.transform = `translateX(calc(${base}% + ${dx}px))`; + track.style.transform = `translateX(calc(${base}% + ${cdx}px))`; }, { passive: false }); track.addEventListener('touchend', () => { diff --git a/backend/static/sw.js b/backend/static/sw.js index b70d445..d18bcc4 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v648'; +const CACHE_VERSION = 'by-v649'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From a84df71383e625299c26a4b6f458084b1fa93c55 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 11:07:27 +0200 Subject: [PATCH 61/63] =?UTF-8?q?Feature:=20JETZT-Welt=20=E2=80=94=20Strea?= =?UTF-8?q?k+Gassirunde+=C3=9Cbung=20als=20kompakte=203er-Chip-Zeile,=20SW?= =?UTF-8?q?=20by-v650?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/css/components.css | 42 ++++++++++++++++++++++ backend/static/index.html | 8 ++--- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 60 ++++++++++++------------------- backend/static/sw.js | 2 +- 5 files changed, 71 insertions(+), 43 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 9415702..fa7f4e7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7805,6 +7805,48 @@ svg.empty-state-icon { padding: 4px 2px 0; } +/* JETZT-Chip-Reihe: Streak | Gassirunde | Übung */ +.wj-chip-row { + display: flex; + gap: 8px; +} +.wj-chip { + flex: 1; + min-width: 0; + background: rgba(0, 0, 0, 0.32); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 14px; + padding: 10px 6px 9px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.12s; +} +.wj-chip:active { background: rgba(0, 0, 0, 0.52); } +.wj-chip-label { + font-size: 8px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.45); + line-height: 1; +} +.wj-chip-val { + font-size: 10px; + font-weight: 700; + color: white; + line-height: 1.25; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + /* Footer-Links (Impressum / Die 100 / Datenschutz) */ .world-footer-links { text-align: center; diff --git a/backend/static/index.html b/backend/static/index.html index 9c8b3ae..5e77ed9 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 605d4cf..003f1b5 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '649'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '650'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 9ef2e6a..0f404ef 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -647,25 +647,18 @@ window.Worlds = (() => { ? `${Math.round(w.temp_c)}° ${_esc(w.desc?.split(' ')[0] || '')} · ${Math.round(w.wind_kmh || 0)} km/h · ${w.precip_prob || 0}% Regen` : ''; - // Streak-Reminder - let streakHtml = ''; + // Streak für 3er-Chip-Zeile + let streakVal = '—', streakCol = 'rgba(255,255,255,0.4)'; if (user && dog) { try { const sr = await _cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`); const s = sr.data; const streak = s?.current_streak || 0; const trainedToday = s?.last_training_date === new Date().toISOString().slice(0,10); - const col = trainedToday ? 'rgba(16,185,129,0.8)' : (streak > 0 ? 'rgba(245,158,11,0.8)' : 'rgba(255,255,255,0.4)'); - const label = streak > 0 - ? (trainedToday ? `✓ ${streak} Tage Streak` : `🔥 ${streak} Tage — heute noch trainieren!`) - : (trainedToday ? '✓ Heute trainiert' : 'Noch kein Training heute'); - streakHtml = ` -
    - - - - ${label} -
    `; + streakCol = trainedToday ? '#10B981' : (streak > 0 ? '#F59E0B' : 'rgba(255,255,255,0.4)'); + streakVal = streak > 0 + ? (trainedToday ? `✓ ${streak} Tage` : `🔥 ${streak} Tage`) + : (trainedToday ? '✓ Heute' : 'Heute starten'); } catch {} } @@ -716,32 +709,25 @@ window.Worlds = (() => {
    ${alertHtml} - ${streakHtml} ${user && dog ? ` -
    -
    - - -
    -
    Gassirunde
    -
    Berechne…
    -
    - - -
    -
    - +
    +
    + -
    -
    Übung des Tages
    -
    Lade…
    -
    - - + Streak + ${streakVal} +
    +
    + + + Gassirunde + +
    +
    + + + Übung +
    ` : ''}
    diff --git a/backend/static/sw.js b/backend/static/sw.js index d18bcc4..7cb83fc 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v649'; +const CACHE_VERSION = 'by-v650'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From f4052fbb7d907c46110b0f8a358ee3d6f8abc9d1 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 11:09:14 +0200 Subject: [PATCH 62/63] Feature: Welten-Labels klickbar (JETZT|HUND|WELT), aktives Label hervorgehoben, Desktop-Tab-Optik, SW by-v651 --- backend/static/css/components.css | 11 ++++++++++- backend/static/index.html | 8 ++++---- backend/static/js/app.js | 2 +- backend/static/js/worlds.js | 9 +++++++++ backend/static/sw.js | 2 +- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index fa7f4e7..27cf0d9 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -7592,9 +7592,18 @@ svg.empty-state-icon { font-size: 9px; font-weight: 800; letter-spacing: 0.12em; - color: var(--c-text-secondary); + color: white; opacity: 0.4; text-transform: uppercase; + transition: opacity 0.18s; +} +.wlabel.active { opacity: 1; } + +@media (min-width: 768px) { + #world-labels { gap: 48px; font-size: 11px; } + .wlabel { opacity: 0.5; padding: 4px 10px; border-radius: 8px; } + .wlabel:hover { opacity: 0.8; background: rgba(255,255,255,0.08); } + .wlabel.active { opacity: 1; background: rgba(255,255,255,0.12); } } /* Settings-Button */ diff --git a/backend/static/index.html b/backend/static/index.html index 5e77ed9..cb75a8f 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -93,9 +93,9 @@ - - - + + + @@ -565,7 +565,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 003f1b5..5c94475 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '650'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '651'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index 0f404ef..0fc7723 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -145,6 +145,7 @@ window.Worlds = (() => { function _updateDots() { document.querySelectorAll('.wdot').forEach((d, i) => d.classList.toggle('active', i === _cur)); + document.querySelectorAll('.wlabel').forEach((l, i) => l.classList.toggle('active', i === _cur)); } function _updateFab() { @@ -167,6 +168,14 @@ window.Worlds = (() => { if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } }); }); + document.querySelectorAll('.wlabel').forEach((lbl, i) => { + lbl.style.pointerEvents = 'auto'; + lbl.style.cursor = 'pointer'; + lbl.addEventListener('click', () => { + _goTo(i, true); + if (i === 2 && !_weltInited) { _weltInited = true; _renderWelt(); } + }); + }); } function _openFab() { diff --git a/backend/static/sw.js b/backend/static/sw.js index 7cb83fc..e786916 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v650'; +const CACHE_VERSION = 'by-v651'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache From 747c353444b715c23ea9d0ae60388ab63d66b6f1 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 3 May 2026 11:09:39 +0200 Subject: [PATCH 63/63] =?UTF-8?q?Chore:=20Sprint32-36=20Zwischenstand=20?= =?UTF-8?q?=E2=80=94=20alle=20=C3=84nderungen=20aus=20dieser=20Session=20c?= =?UTF-8?q?ommitten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 65 ++ backend/main.py | 2 + backend/requirements.txt | 1 + backend/routes/admin.py | 6 +- backend/routes/adoption.py | 257 ++++- backend/routes/auth.py | 8 +- backend/routes/dogs.py | 12 +- backend/routes/forum.py | 2 +- backend/routes/jobs.py | 33 +- backend/routes/litters.py | 9 +- backend/routes/moderation.py | 3 + backend/routes/streak.py | 2 +- backend/routes/weather.py | 15 +- backend/routes/wiki.py | 21 +- backend/static/icons/phosphor.svg | 1357 +++++++++++++++++++++++++++ backend/static/js/api.js | 70 +- backend/static/js/pages/adoption.js | 475 ++++++++++ backend/static/js/pages/settings.js | 11 + backend/static/js/pages/wetter.js | 581 ++++++++++++ backend/weather.py | 248 +++++ 20 files changed, 3115 insertions(+), 63 deletions(-) create mode 100644 backend/static/js/pages/wetter.js diff --git a/backend/database.py b/backend/database.py index 1a70aa5..eeb1add 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1747,6 +1747,71 @@ def _migrate(conn_factory): ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS community_adoption ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + name TEXT NOT NULL, + rasse TEXT, + alter_jahre REAL, + geschlecht TEXT, + foto_url TEXT, + beschreibung TEXT NOT NULL, + gruende TEXT, + ort TEXT, + plz TEXT, + lat REAL, + lon REAL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS community_adoption_interest ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id INTEGER NOT NULL REFERENCES community_adoption(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + nachricht TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(listing_id, user_id) + ) + """) + + # ---- Wetter-Log (historische Vorhersage-Daten) ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS weather_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + logged_at TEXT NOT NULL DEFAULT (datetime('now')), + date TEXT NOT NULL, + lat_r REAL NOT NULL, + lon_r REAL NOT NULL, + temp_max REAL, + temp_min REAL, + feels_max REAL, + precip_prob INTEGER, + precip_sum REAL, + wind_kmh REAL, + wind_dir TEXT, + uv_index REAL, + weathercode INTEGER, + weatherdesc TEXT, + sunrise TEXT, + sunset TEXT, + asphalt_temp REAL, + asphalt_warn TEXT, + zecken TEXT, + pollen_erle INTEGER, + pollen_birke INTEGER, + pollen_graeser INTEGER, + pollen_beifuss INTEGER, + pollen_ambrosia INTEGER, + forecast_json TEXT, + UNIQUE(date, lat_r, lon_r) + ) + """) + # ---- Favoriten-Tierarzt + Gesundheitsdokumente ---- conn.execute(""" CREATE TABLE IF NOT EXISTS favorite_vets ( diff --git a/backend/main.py b/backend/main.py index 8b259f7..229a856 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from fastapi.middleware.gzip import GZipMiddleware +from brotli_asgi import BrotliMiddleware from contextlib import asynccontextmanager from database import init_db @@ -134,6 +135,7 @@ class MediaCacheMiddleware(BaseHTTPMiddleware): return response app.add_middleware(MediaCacheMiddleware) +app.add_middleware(BrotliMiddleware, minimum_size=1000, quality=4) app.add_middleware(GZipMiddleware, minimum_size=1000) diff --git a/backend/requirements.txt b/backend/requirements.txt index c4e830c..414ec32 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,3 +15,4 @@ odfpy==1.4.1 polyline==2.0.2 fpdf2==2.8.3 python-dateutil>=2.9 +brotli-asgi==1.4.0 diff --git a/backend/routes/admin.py b/backend/routes/admin.py index cd3fee1..c2ffebb 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -356,11 +356,15 @@ async def list_users( # ------------------------------------------------------------------ @router.patch("/users/{uid}") async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)): - # Rollenwechsel nur für Admins + # Rollenwechsel + Privileg-Flags nur für Admins if data.rolle is not None and user["rolle"] != "admin": raise HTTPException(403, "Rollenwechsel nur für Admins.") if data.rolle and data.rolle not in ("user", "moderator", "admin"): raise HTTPException(400, "Ungültige Rolle.") + if data.is_moderator is not None and user["rolle"] != "admin": + raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.") + if data.is_social_media is not None and user["rolle"] != "admin": + raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.") with db() as conn: target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone() diff --git a/backend/routes/adoption.py b/backend/routes/adoption.py index d742ccc..bde0986 100644 --- a/backend/routes/adoption.py +++ b/backend/routes/adoption.py @@ -13,10 +13,17 @@ import os import math import logging import asyncio +import uuid import httpx from datetime import datetime, timedelta -from fastapi import APIRouter, Query, BackgroundTasks +from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException +from pydantic import BaseModel +from typing import Optional from database import db +from auth import get_current_user +from routes.push import send_push_to_user + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") logger = logging.getLogger(__name__) router = APIRouter() @@ -290,3 +297,251 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)): except Exception as e: logger.warning(f"Geocode PLZ {plz}: {e}") return {"lat": None, "lon": None, "display": plz} + + +# ================================================================== +# Community Adoption — Privates Weitervermittlungs-Board +# ================================================================== + +class InterestBody(BaseModel): + nachricht: Optional[str] = None + + +# ------------------------------------------------------------------ +# GET /api/adoption/community/my — eigene Inserate +# ------------------------------------------------------------------ +@router.get("/community/my") +def community_my(user=Depends(get_current_user)): + with db() as conn: + rows = conn.execute(""" + SELECT ca.*, + u.name AS besitzer_name, + (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count + FROM community_adoption ca + JOIN users u ON u.id = ca.user_id + WHERE ca.user_id = ? AND ca.status != 'deleted' + ORDER BY ca.created_at DESC + """, (user["id"],)).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /api/adoption/community — alle aktiven Inserate (mit optionaler Nähe) +# ------------------------------------------------------------------ +@router.get("/community") +def community_list( + lat: Optional[float] = Query(None), + lon: Optional[float] = Query(None), + radius: float = Query(200.0, description="Radius in km (default 200)"), + user=Depends(get_current_user), +): + with db() as conn: + rows = conn.execute(""" + SELECT ca.*, + u.name AS besitzer_name, + (SELECT COUNT(*) FROM community_adoption_interest i WHERE i.listing_id = ca.id) AS interest_count, + (SELECT COUNT(*) FROM community_adoption_interest i2 + WHERE i2.listing_id = ca.id AND i2.user_id = ?) AS _user_interested + FROM community_adoption ca + JOIN users u ON u.id = ca.user_id + WHERE ca.status = 'active' + ORDER BY ca.created_at DESC + LIMIT 50 + """, (user["id"],)).fetchall() + + result = [] + for row in rows: + d = dict(row) + d["user_interested"] = bool(d.pop("_user_interested", 0)) + 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"]) + d["distanz_km"] = round(dist, 1) + if dist > radius: + continue + else: + d["distanz_km"] = None + result.append(d) + + if lat is not None and lon is not None: + result.sort(key=lambda x: x["distanz_km"] if x["distanz_km"] is not None else 9999) + + return result + + +# ------------------------------------------------------------------ +# POST /api/adoption/community — Inserat erstellen +# ------------------------------------------------------------------ +@router.post("/community", status_code=201) +async def community_create( + name: str = Form(...), + beschreibung: str = Form(...), + rasse: str = Form(""), + alter_jahre: Optional[float] = Form(None), + geschlecht: str = Form(""), + gruende: str = Form(""), + ort: str = Form(""), + plz: str = Form(""), + lat: Optional[float] = Form(None), + lon: Optional[float] = Form(None), + dog_id: Optional[int] = Form(None), + foto: Optional[UploadFile] = File(None), + user=Depends(get_current_user), +): + foto_url = None + + if foto and foto.filename: + MAX_SIZE = 5 * 1024 * 1024 + header = await foto.read(12) + if len(header) < 3: + raise HTTPException(400, "Ungültige Datei") + is_jpeg = header[:3] == b"\xff\xd8\xff" + is_png = header[:4] == b"\x89PNG" + is_webp = header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP" + if not (is_jpeg or is_png or is_webp): + raise HTTPException(400, "Nur JPEG, PNG oder WebP erlaubt") + rest = await foto.read(MAX_SIZE) + if len(rest) >= MAX_SIZE: + raise HTTPException(400, "Foto zu groß (max 5 MB)") + data = header + rest + + folder = os.path.join(MEDIA_DIR, "adoption") + os.makedirs(folder, exist_ok=True) + filename = f"{uuid.uuid4()}.jpg" + filepath = os.path.join(folder, filename) + with open(filepath, "wb") as f: + f.write(data) + foto_url = f"/media/adoption/{filename}" + + with db() as conn: + cur = conn.execute(""" + INSERT INTO community_adoption + (user_id, dog_id, name, rasse, alter_jahre, geschlecht, + foto_url, beschreibung, gruende, ort, plz, lat, lon) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """, ( + user["id"], dog_id, name, rasse or None, alter_jahre, + geschlecht or None, foto_url, beschreibung, + gruende or None, ort or None, plz or None, lat, lon, + )) + new_id = cur.lastrowid + row = conn.execute( + "SELECT * FROM community_adoption WHERE id = ?", (new_id,) + ).fetchone() + return dict(row) + + +# ------------------------------------------------------------------ +# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer) +# ------------------------------------------------------------------ +class _StatusBody(BaseModel): + status: str + +@router.patch("/community/{listing_id}") +def community_update_status( + listing_id: int, + body: _StatusBody, + user=Depends(get_current_user), +): + allowed = {"active", "reserved", "vermittelt"} + if body.status not in allowed: + raise HTTPException(400, f"Status muss einer von {allowed} sein") + status = body.status + with db() as conn: + cur = conn.execute(""" + UPDATE community_adoption + SET status = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ? + """, (status, listing_id, user["id"])) + if cur.rowcount == 0: + raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") + return {"ok": True} + + +# ------------------------------------------------------------------ +# DELETE /api/adoption/community/{id} — Soft-Delete (nur Besitzer) +# ------------------------------------------------------------------ +@router.delete("/community/{listing_id}") +def community_delete(listing_id: int, user=Depends(get_current_user)): + with db() as conn: + cur = conn.execute(""" + UPDATE community_adoption + SET status = 'deleted', updated_at = datetime('now') + WHERE id = ? AND user_id = ? + """, (listing_id, user["id"])) + if cur.rowcount == 0: + raise HTTPException(404, "Inserat nicht gefunden oder kein Zugriff") + return {"ok": True} + + +# ------------------------------------------------------------------ +# POST /api/adoption/community/{id}/interest — Interesse bekunden +# ------------------------------------------------------------------ +@router.post("/community/{listing_id}/interest", status_code=201) +def community_interest(listing_id: int, body: InterestBody = None, user=Depends(get_current_user)): + nachricht = (body.nachricht if body else None) or None + with db() as conn: + listing = conn.execute( + "SELECT id, name, user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", + (listing_id,) + ).fetchone() + if not listing: + raise HTTPException(404, "Inserat nicht gefunden") + if listing["user_id"] == user["id"]: + raise HTTPException(400, "Eigenes Inserat") + try: + conn.execute(""" + INSERT INTO community_adoption_interest (listing_id, user_id, nachricht) + VALUES (?, ?, ?) + """, (listing_id, user["id"], nachricht)) + except Exception: + raise HTTPException(409, "Interesse bereits bekundet") + + try: + send_push_to_user(listing["user_id"], { + "title": "Jemand interessiert sich für deinen Hund \U0001f43e", + "body": f"{user['name']} möchte mehr über {listing['name']} erfahren.", + "url": "/#adoption", + }) + except Exception as e: + logger.warning(f"Push interest: {e}") + + return {"ok": True} + + +# ------------------------------------------------------------------ +# DELETE /api/adoption/community/{id}/interest — Interesse zurückziehen +# ------------------------------------------------------------------ +@router.delete("/community/{listing_id}/interest") +def community_interest_withdraw(listing_id: int, user=Depends(get_current_user)): + with db() as conn: + cur = conn.execute(""" + DELETE FROM community_adoption_interest + WHERE listing_id = ? AND user_id = ? + """, (listing_id, user["id"])) + if cur.rowcount == 0: + raise HTTPException(404, "Kein Interesse gefunden") + return {"ok": True} + + +# ------------------------------------------------------------------ +# GET /api/adoption/community/{id}/interests — Interessenten (nur Besitzer) +# ------------------------------------------------------------------ +@router.get("/community/{listing_id}/interests") +def community_interests(listing_id: int, user=Depends(get_current_user)): + with db() as conn: + listing = conn.execute( + "SELECT user_id FROM community_adoption WHERE id = ? AND status != 'deleted'", + (listing_id,) + ).fetchone() + if not listing: + raise HTTPException(404, "Inserat nicht gefunden") + if listing["user_id"] != user["id"]: + raise HTTPException(403, "Nur der Besitzer kann Interessenten sehen") + rows = conn.execute(""" + SELECT i.id, i.nachricht, i.created_at, u.id AS user_id, u.name, u.avatar_url + FROM community_adoption_interest i + JOIN users u ON u.id = i.user_id + WHERE i.listing_id = ? + ORDER BY i.created_at ASC + """, (listing_id,)).fetchall() + return [dict(r) for r in rows] diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 13d857d..4772ae6 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -26,12 +26,14 @@ _SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_P def _send_verification_email(email: str, name: str, token: str): if not _SMTP_READY: return + import html as _html from routes.outreach import _send_smtp from mailer import email_html url = f"{_APP_URL}/api/auth/verify-email/{token}" subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" + _ename = _html.escape(name) body_html = f""" -

    Hallo {name},

    +

    Hallo {_ename},

    willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.

    @@ -306,13 +308,15 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): "UPDATE users SET password_reset_token=?, password_reset_expires=? WHERE id=?", (token, expires, user["id"]) ) + import html as _html app_url = os.getenv("APP_URL", "https://banyaro.app") url = f"{app_url}/#reset-password?token={token}" subject = "Ban Yaro — Passwort zurücksetzen" from routes.outreach import _send_smtp from mailer import email_html + _ename = _html.escape(user['name']) body_html = f""" -

    Hallo {user['name']},

    +

    Hallo {_ename},

    du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.

    diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index c7a9066..a44faa0 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -315,11 +315,13 @@ async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user values = list(fields.values()) + [dog_id, user["id"]] with db() as conn: - conn.execute( + updated = conn.execute( f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values - ) + ).rowcount + if not updated: + raise HTTPException(404, "Hund nicht gefunden.") dog = conn.execute( - "SELECT * FROM dogs WHERE id=?", (dog_id,) + "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) ).fetchone() return dict(dog) @@ -413,8 +415,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)): os.remove(path) with db() as conn: conn.execute( - "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=?", - (dog_id,) + "UPDATE dogs SET foto_url=NULL, foto_zoom=1.0, foto_offset_x=0.0, foto_offset_y=0.0 WHERE id=? AND user_id=?", + (dog_id, user["id"]) ) diff --git a/backend/routes/forum.py b/backend/routes/forum.py index fe730d5..2834ab0 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -641,7 +641,7 @@ async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_c # GET /api/forum/members/map # ------------------------------------------------------------------ @router.get("/members/map") -async def members_map(): +async def members_map(user=Depends(get_current_user)): with db() as conn: rows = conn.execute( """SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname, diff --git a/backend/routes/jobs.py b/backend/routes/jobs.py index 8714ae2..59c73c2 100644 --- a/backend/routes/jobs.py +++ b/backend/routes/jobs.py @@ -1,5 +1,6 @@ """BAN YARO — Social-Media-Job Bewerbungs-System""" +import html as _html import os import uuid from datetime import datetime, timedelta @@ -98,8 +99,9 @@ async def apply( # Bestätigungs-Mail an Bewerber try: + _name = _html.escape(name) body = f""" -

    Hallo {name},

    +

    Hallo {_name},

    deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen. Wir melden uns bald bei dir! @@ -110,7 +112,7 @@ async def apply( email, "Deine Bewerbung bei Ban Yaro 🐾", email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), - f"Hallo {name}, deine Bewerbung ist eingegangen!", + f"Hallo {_name}, deine Bewerbung ist eingegangen!", ) except Exception: pass @@ -119,16 +121,22 @@ async def apply( try: admin_email = os.getenv("ADMIN_EMAIL", "") if admin_email: + _ename = _html.escape(name) + _eemail = _html.escape(email) + _edog_name = _html.escape(dog_name) + _edog_rasse = _html.escape(dog_rasse) + _ehandle = _html.escape(social_handle) + _emotivation = _html.escape(motivation[:300]) admin_body = f"""

    Neue Job-Bewerbung eingegangen:

    - - - - + + + +
    Name{name}
    E-Mail{email}
    Hund{dog_name} ({dog_rasse})
    Social{social_handle}
    Name{_ename}
    E-Mail{_eemail}
    Hund{_edog_name} ({_edog_rasse})
    Social{_ehandle}
    Anhänge{len([f for f in files if f.filename])} Datei(en)
    -

    {motivation[:300]}{"…" if len(motivation)>300 else ""}

    """ +

    {_emotivation}{"…" if len(motivation)>300 else ""}

    """ await send_email( admin_email, f"[Banyaro Jobs] Neue Bewerbung — {name}", @@ -293,16 +301,17 @@ async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)): def _send_status_mail(email: str, name: str, status: str, note: str): import asyncio + _ename = _html.escape(name) texts = { "reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾", - f"

    Hallo {name},

    wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

    "), + f"

    Hallo {_ename},

    wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!

    "), "accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉", - f"

    Hallo {name},

    wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
    Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

    "), + f"

    Hallo {_ename},

    wir freuen uns, dir mitzuteilen: du bist unser neuer Social-Media-Manager/in für Ban Yaro!
    Du erhältst außerdem den Gründer-Status in unserer Community. Willkommen im Team!

    "), "rejected": ("Deine Bewerbung bei Ban Yaro", - f"

    Hallo {name},

    vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

    "), + f"

    Hallo {_ename},

    vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!

    "), } - subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

    Hallo {name},

    ")) - note_html = f'
    {note}
    ' if note else "" + subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"

    Hallo {_ename},

    ")) + note_html = f'
    {_html.escape(note)}
    ' if note else "" body = body_start + note_html async def _send(): diff --git a/backend/routes/litters.py b/backend/routes/litters.py index 82ba96f..ddc810c 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -265,13 +265,14 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): eltern = conn.execute( "SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,) ).fetchone() + import html as _html welfare_body = f"""

    Kritischer Tierschutz-Hinweis bestätigt

    - - - - + + + +
    Züchter{zuechter}
    Zwinger{zwinger}
    Vater{eltern['vater_name'] or '—'}
    Mutter{eltern['mutter_name'] or '—'}
    Züchter{_html.escape(zuechter)}
    Zwinger{_html.escape(zwinger)}
    Vater{_html.escape(eltern['vater_name'] or '—')}
    Mutter{_html.escape(eltern['mutter_name'] or '—')}
    Wurf-ID#{litter_id}
    """ try: diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 1357a85..fa74871 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -268,6 +268,9 @@ async def mod_poi_edit_action(edit_id: int, data: dict, raise HTTPException(409, "Korrektur wurde bereits bearbeitet.") if action == "approve": + _ALLOWED_POI_FIELDS = {"opening_hours", "phone", "website", "name"} + if edit["field"] not in _ALLOWED_POI_FIELDS: + raise HTTPException(400, f"Ungültiges Feld: {edit['field']}") conn.execute( f"UPDATE osm_pois SET {edit['field']}=?, user_edited=1 WHERE osm_id=?", (edit["new_value"], edit["osm_id"]) diff --git a/backend/routes/streak.py b/backend/routes/streak.py index ea03522..c387a68 100644 --- a/backend/routes/streak.py +++ b/backend/routes/streak.py @@ -29,7 +29,7 @@ async def get_leaderboard(user=Depends(get_current_user)): JOIN dogs d ON d.id = ts.dog_id JOIN users u ON u.id = ts.user_id WHERE ts.current_streak > 0 - AND (d.is_public = 1 OR d.user_id = ts.user_id) + AND d.is_public = 1 ORDER BY ts.current_streak DESC LIMIT 10 """).fetchall() diff --git a/backend/routes/weather.py b/backend/routes/weather.py index 319cfd2..fced719 100644 --- a/backend/routes/weather.py +++ b/backend/routes/weather.py @@ -3,8 +3,9 @@ BAN YARO — Wetter-API GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort """ -from fastapi import APIRouter, Query, HTTPException +from fastapi import APIRouter, Query, HTTPException, Depends import weather as weather_module +from auth import get_current_user router = APIRouter() @@ -18,3 +19,15 @@ async def get_weather( return await weather_module.get_weather_for_location(lat, lon) except Exception as exc: raise HTTPException(503, f'Wetter nicht verfügbar: {exc}') + + +@router.get('/forecast') +async def get_weather_forecast( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), + user=Depends(get_current_user), +): + try: + return await weather_module.get_forecast(lat, lon) + except Exception as exc: + raise HTTPException(503, f'Wettervorhersage nicht verfügbar: {exc}') diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index bf3c19c..45f5bfb 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -317,19 +317,24 @@ async def submit_foto( if not rights_confirmed: raise HTTPException(400, "Bildrechte-Bestätigung fehlt.") - # Dateiformat prüfen - ct = file.content_type or "" - if not ct.startswith("image/"): - raise HTTPException(400, "Nur Bilddateien erlaubt.") + _IMAGE_MAGIC = [ + b"\xff\xd8\xff", # JPEG + b"\x89PNG\r\n\x1a\n", # PNG + b"RIFF", # WebP (RIFF....WEBP) + b"GIF87a", b"GIF89a", # GIF + ] os.makedirs(SUBMIT_DIR, exist_ok=True) - ts = int(time.time()) - filename = f"{slug}_{user['id']}_{ts}.jpg" - path = os.path.join(SUBMIT_DIR, filename) - + ts = int(time.time()) content = await file.read() if len(content) > 8 * 1024 * 1024: raise HTTPException(400, "Datei zu groß (max. 8 MB).") + + if not any(content.startswith(magic) for magic in _IMAGE_MAGIC): + raise HTTPException(400, "Nur Bilddateien erlaubt (JPEG, PNG, WebP, GIF).") + + filename = f"{slug}_{user['id']}_{ts}.jpg" + path = os.path.join(SUBMIT_DIR, filename) with open(path, "wb") as f: f.write(content) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index a9189b9..2b8028e 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -270,4 +270,1361 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/static/js/api.js b/backend/static/js/api.js index a40b99d..c6b26da 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -6,69 +6,84 @@ const API = (() => { + // ---------------------------------------------------------- + // Request-Deduplication: gleiche GET-URL nur einmal in-flight + // ---------------------------------------------------------- + const _inflight = new Map(); + // ---------------------------------------------------------- // Interner HTTP-Kern // ---------------------------------------------------------- - async function _request(method, path, body = null, options = {}) { + async function _doRequest(method, path, body, attempt) { const config = { method, headers: { 'Content-Type': 'application/json' }, - credentials: 'include', // HttpOnly Cookie wird automatisch mitgesendet + credentials: 'include', }; if (body && !(body instanceof FormData)) { config.body = JSON.stringify(body); } else if (body instanceof FormData) { - delete config.headers['Content-Type']; // Browser setzt multipart/form-data + delete config.headers['Content-Type']; config.body = body; } - // JWT aus localStorage als Bearer (für API-Calls die das brauchen) const token = localStorage.getItem('by_token'); - if (token) { - config.headers['Authorization'] = `Bearer ${token}`; - } + if (token) config.headers['Authorization'] = `Bearer ${token}`; let response; try { response = await fetch(`/api${path}`, config); - } catch (err) { - const offlineMsg = 'Kein Internet — du bist offline.'; - if (window.UI && UI.toast) UI.toast.warning(offlineMsg, 4000); - throw new APIError(offlineMsg, 0, 'network'); + } catch { + // Netzwerkfehler: bei GET bis zu 2 Retry-Versuche + if (method === 'GET' && attempt < 2) { + await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); + return _doRequest(method, path, body, attempt + 1); + } + const msg = 'Kein Internet — du bist offline.'; + if (window.UI?.toast) UI.toast.warning(msg, 4000); + throw new APIError(msg, 0, 'network'); } - // 204 No Content if (response.status === 204) return null; let data; - try { - data = await response.json(); - } catch { - data = null; - } + try { data = await response.json(); } catch { data = null; } if (!response.ok) { const message = data?.detail || data?.message || `Fehler ${response.status}`; - // SW gibt bei Offline-Anfragen 503 + 'Offline — keine Verbindung.' zurück - const isOffline = response.status === 503 && message.startsWith('Offline'); - if (isOffline && window.UI && UI.toast) { - UI.toast.warning('Kein Internet — du bist offline.', 4000); + const isSwOffline = response.status === 503 && message.startsWith('Offline'); + + // Retry: GET auf echte 5xx (nicht SW-generierte Offline-503) + if (method === 'GET' && response.status >= 500 && !isSwOffline && attempt < 2) { + await new Promise(r => setTimeout(r, 200 * Math.pow(3, attempt))); + return _doRequest(method, path, body, attempt + 1); } - throw new APIError(message, response.status, isOffline ? 'network' : data?.code); + + if (isSwOffline && window.UI?.toast) UI.toast.warning('Kein Internet — du bist offline.', 4000); + throw new APIError(message, response.status, isSwOffline ? 'network' : data?.code); } - // SW hat die Anfrage in die Offline-Queue eingereiht if (data?._queued) { - if (typeof UI !== 'undefined' && UI.toast) { + if (typeof UI !== 'undefined' && UI.toast) UI.toast.info('Offline gespeichert — wird automatisch synchronisiert'); - } return data; } return data; } + async function _request(method, path, body = null) { + // GET-Deduplication: laufende identische Anfragen zusammenfassen + if (method === 'GET') { + if (_inflight.has(path)) return _inflight.get(path); + const promise = _doRequest('GET', path, null, 0).finally(() => _inflight.delete(path)); + _inflight.set(path, promise); + return promise; + } + return _doRequest(method, path, body, 0); + } + // ---------------------------------------------------------- // Öffentliche HTTP-Methoden // ---------------------------------------------------------- @@ -426,8 +441,9 @@ const API = (() => { // WETTER // ---------------------------------------------------------- const weather = { - alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, - get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, + alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); }, + get(lat, lon) { return get(`/weather?lat=${lat}&lon=${lon}`); }, + forecast(lat, lon) { return get(`/weather/forecast?lat=${lat}&lon=${lon}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/pages/adoption.js b/backend/static/js/pages/adoption.js index 8e1bc3a..b20682e 100644 --- a/backend/static/js/pages/adoption.js +++ b/backend/static/js/pages/adoption.js @@ -17,6 +17,8 @@ window.Page_adoption = (() => { let _activeTab = 'hunde'; let _data = null; // { animals, shelters, has_petfinder } let _loading = false; + let _communityData = null; // [] listings from /adoption/community + let _myListings = null; // [] eigene Inserate // ---------------------------------------------------------- // INIT @@ -90,6 +92,12 @@ window.Page_adoption = (() => { border-bottom:2px solid transparent;font-size:var(--text-sm)"> ${UI.icon('house-line')} Tierheime +
    @@ -213,12 +221,43 @@ window.Page_adoption = (() => { } } + async function _loadCommunity() { + const content = _container.querySelector('#adp-content'); + if (content) content.innerHTML = UI.skeleton(4); + try { + const url = _lat && _lon + ? `/adoption/community?lat=${_lat}&lon=${_lon}` + : '/adoption/community'; + _communityData = await API.get(url); + if (_appState?.user) { + try { + _myListings = await API.get('/adoption/community/my'); + } catch { + _myListings = []; + } + } + _renderCommunity(content); + } catch { + if (content) content.innerHTML = UI.emptyState({ + icon: 'warning', + title: 'Weitervermittlungs-Inserate konnten nicht geladen werden', + text: 'Bitte versuche es erneut.', + }); + } + } + // ---------------------------------------------------------- // INHALT RENDERN (je nach Tab) // ---------------------------------------------------------- function _renderContent() { const content = _container.querySelector('#adp-content'); if (!content) return; + + if (_activeTab === 'community') { + _loadCommunity(); + return; + } + if (!_data) { _showNoLocation(); return; } if (_activeTab === 'hunde') _renderHunde(content); @@ -455,6 +494,442 @@ window.Page_adoption = (() => { `; } + // ------------------------------------------------------------------ + // TAB: WEITERVERMITTLUNG (Community) + // ------------------------------------------------------------------ + function _renderCommunity(content) { + if (!content) return; + + const listings = _communityData || []; + const isLoggedIn = !!_appState?.user; + + const fabHtml = isLoggedIn ? ` + + ` : ''; + + if (!listings.length) { + content.innerHTML = ` +
    +
    🐾
    +

    Noch keine Hunde zur Weitervermittlung

    +

    + Hier können Halter Hunde privat zur Weitervermittlung anbieten — + zum Beispiel bei Umzug, Krankheit oder Allergie. +

    + ${isLoggedIn ? ` + + ` : ` +

    + Bitte anmelden, um ein Inserat zu erstellen. +

    + `} +
    + ${fabHtml} + `; + content.querySelector('#adp-empty-create')?.addEventListener('click', _openCreateModal); + content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); + return; + } + + // Eigene Inserate trennen + const myIds = new Set((_myListings || []).map(l => l.id)); + + content.innerHTML = ` +

    + ${listings.length} Inserat${listings.length !== 1 ? 'e' : ''} zur Weitervermittlung +

    +
    + ${listings.map(l => _communityCard(l)).join('')} +
    + + ${isLoggedIn && _myListings && _myListings.length ? ` +
    +

    Meine Inserate

    +
    + ${_myListings.map(l => _myListingRow(l)).join('')} +
    +
    + ` : ''} + + ${fabHtml} + `; + + // Interest-Button Events + content.querySelectorAll('[data-adp-interest]').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.adpInterest; + const interested = btn.dataset.adpInterested === 'true'; + _handleInterest(id, interested, btn); + }); + }); + + // FAB + content.querySelector('#adp-fab-create')?.addEventListener('click', _openCreateModal); + + // Meine Inserate: Status-Dropdown + Löschen + content.querySelectorAll('[data-adp-status-change]').forEach(sel => { + sel.addEventListener('change', async () => { + const id = sel.dataset.adpStatusChange; + try { + await API.patch(`/adoption/community/${id}`, { status: sel.value }); + UI.toast.success('Status aktualisiert.'); + _loadCommunity(); + } catch { + UI.toast.error('Status konnte nicht aktualisiert werden.'); + } + }); + }); + + content.querySelectorAll('[data-adp-delete]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Inserat wirklich löschen?')) return; + try { + await API.del(`/adoption/community/${btn.dataset.adpDelete}`); + UI.toast.success('Inserat gelöscht.'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch { + UI.toast.error('Löschen fehlgeschlagen.'); + } + }); + }); + } + + function _communityCard(l) { + const foto = l.foto_url + ? `${_esc(l.name)}` + : '
    🐾
    '; + + const isActive = !l.status || l.status === 'active'; + const statusLabel = l.status === 'reserved' ? 'Reserviert' + : l.status === 'adopted' ? 'Vermittelt' + : ''; + + const alterLabel = l.alter_kategorie === 'welpe' ? 'Welpe <6Mo' + : l.alter_kategorie === 'jung' ? 'Jung 6Mo–2J' + : l.alter_kategorie === 'adult' ? 'Adult 2–8J' + : l.alter_kategorie === 'senior' ? 'Senior >8J' + : ''; + + const genderIcon = l.geschlecht === 'maennlich' ? '♂' + : l.geschlecht === 'weiblich' ? '♀' + : ''; + + const distTxt = l.distanz_km != null ? `${l.distanz_km} km` : ''; + const ort = [l.plz, l.ort].filter(Boolean).join(' '); + + const interestBtn = l.user_interested + ? `` + : ``; + + return ` +
    + +
    + ${foto} + ${!isActive ? ` +
    + + ${_esc(statusLabel)} + +
    + ` : ''} +
    + +
    +
    + ${_esc(l.name)} +
    + ${l.rasse ? `
    + ${_esc(l.rasse)} +
    ` : ''} + +
    + ${alterLabel ? ` + ${_esc(alterLabel)} + ` : ''} + ${genderIcon ? ` + ${genderIcon} + ` : ''} + ${distTxt ? ` + ${_esc(distTxt)} + ` : ''} +
    + ${ort ? `
    ${_esc(ort)}
    ` : ''} + ${l.beschreibung ? `
    + ${_esc(l.beschreibung)} +
    ` : ''} + ${l.interesse_count ? `
    + ❤️ ${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''} +
    ` : ''} +
    + ${interestBtn} +
    +
    +
    + `; + } + + function _myListingRow(l) { + const statusOptions = [ + { value: 'active', label: 'Aktiv' }, + { value: 'reserved', label: 'Reserviert' }, + { value: 'adopted', label: 'Vermittelt' }, + ]; + return ` +
    +
    +
    + ${_esc(l.name)} +
    +
    + ${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''} +
    +
    + + +
    + `; + } + + // ------------------------------------------------------------------ + // INTERESSE BEKUNDEN / ZURÜCKZIEHEN + // ------------------------------------------------------------------ + async function _handleInterest(id, isInterested, btn) { + if (!_appState?.user) { + UI.toast.error('Bitte anmelden um Interesse zu bekunden.'); + return; + } + + if (isInterested) { + // Interesse zurückziehen + try { + btn.disabled = true; + await API.del(`/adoption/community/${id}/interest`); + UI.toast.success('Interesse zurückgezogen.'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch { + UI.toast.error('Fehler beim Zurückziehen des Interesses.'); + btn.disabled = false; + } + return; + } + + // Interesse bekunden — Modal mit optionaler Nachricht + const body = ` +
    +

    + Du kannst optional eine Nachricht an den Anbieter schicken. +

    +
    + + +
    +
    + `; + const footer = ` +
    + + +
    + `; + + UI.modal.open({ title: 'Interesse bekunden', body, footer }); + + document.getElementById('adp-interest-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const submitBtn = document.getElementById('adp-interest-submit'); + const fd = new FormData(e.target); + const payload = { nachricht: fd.get('nachricht') || null }; + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + try { + await API.post(`/adoption/community/${id}/interest`, payload); + UI.modal.close(); + UI.toast.success('Interesse gemeldet!'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch { + UI.toast.error('Fehler beim Melden des Interesses.'); + if (submitBtn) { submitBtn.disabled = false; submitBtn.innerHTML = `${UI.icon('heart')} Interesse bekunden`; } + } + }); + } + + // ------------------------------------------------------------------ + // INSERAT ERSTELLEN — Modal + // ------------------------------------------------------------------ + function _openCreateModal() { + if (!_appState?.user) { + UI.toast.error('Bitte anmelden um ein Inserat zu erstellen.'); + return; + } + + const body = ` +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    Mindestens 80 Zeichen
    +
    +
    + + +
    +
    + + +
    +
    + `; + + const footer = ` +
    + + +
    + `; + + UI.modal.open({ title: 'Hund zur Vermittlung anbieten', body, footer }); + + document.getElementById('adp-create-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const submitBtn = document.getElementById('adp-create-submit'); + const fd = new FormData(e.target); + + // Mindestlänge Beschreibung manuell prüfen (minlength gilt nur für text) + const beschreibung = (fd.get('beschreibung') || '').trim(); + if (beschreibung.length < 80) { + UI.toast.error('Beschreibung muss mindestens 80 Zeichen lang sein.'); + return; + } + + // FormData für multipart aufbauen + const postData = new FormData(); + postData.append('name', fd.get('name') || ''); + postData.append('rasse', fd.get('rasse') || ''); + postData.append('alter_kategorie', fd.get('alter_kategorie') || ''); + postData.append('geschlecht', fd.get('geschlecht') || ''); + postData.append('plz', fd.get('plz') || ''); + postData.append('ort', fd.get('ort') || ''); + postData.append('beschreibung', beschreibung); + postData.append('hintergrund', fd.get('hintergrund') || ''); + if (_lat) postData.append('lat', _lat); + if (_lon) postData.append('lon', _lon); + const fotoFile = document.getElementById('adp-create-foto')?.files?.[0]; + if (fotoFile) postData.append('foto', fotoFile); + + if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '…'; } + try { + await API.upload('/adoption/community', postData); + UI.modal.close(); + UI.toast.success('Inserat erstellt!'); + _communityData = null; + _myListings = null; + _loadCommunity(); + } catch (err) { + UI.toast.error(err.message || 'Inserat konnte nicht erstellt werden.'); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = `${UI.icon('plus')} Inserat erstellen`; + } + } + }); + } + // ---------------------------------------------------------- // HILFSFUNKTIONEN // ---------------------------------------------------------- diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index f8488b6..8829bb6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -263,6 +263,12 @@ window.Page_settings = (() => { Kalender abonnieren
    +