Compare commits

...

21 commits

Author SHA1 Message Date
26b515cede Fix: Anniversary-Job + RASFF 404, SW by-v1120
Aus Container-Log gefundene Backend-Errors:

1. _job_anniversary_reminders: 'no such column: d.user_id'
   diary-Tabelle hat keine user_id — User-Bezug geht über dogs.user_id.
   → JOIN dogs ON dogs.id = d.dog_id ergänzt + SELECT dogs.user_id.
   Job läuft täglich 09:00 — war seit Tag X kaputt, kein Push für
   Jahrestage gesendet.

2. RASFF API 404 (EU Rapid Alert System for Food and Feed):
   webgate.ec.europa.eu/rasff-window/backend/public/... ist umgezogen.
   → HTTPStatusError mit 404/410/503 wird jetzt nur als WARNING geloggt
   (vorher ERROR → Error-Digest spammte täglich). Fallback ist eh schon
   ein leeres Array, App läuft weiter. EU-Endpoint-URL muss nochmal
   recherchiert werden, dann RASFF_URL aktualisieren — Folge-Sprint.
2026-05-27 14:51:34 +02:00
c7a84438d1 Fix: Notes-Karten — Zeilenumbruch, Clamp + Detail-Modal beim Klick, SW by-v1119
User-Report: Zeilenumbrüche in Notes-Karten gingen nicht, kein Scroll,
keine Detail-Ansicht.

Drei Probleme behoben:

1. _truncate-Limit zu aggressiv (150 Zeichen)
   → erhöht auf 600 Zeichen damit Karten lange Notizen mit Newlines
     sichtbar anzeigen können (CSS-Clamp erledigt visuell den Rest)

2. .list-item-text + .notes-card-text Override-Konflikt
   list-item-text hat fest -webkit-line-clamp:2 mit display:-webkit-box.
   Notes-Override hatte display:block — das deaktiviert clamp komplett,
   aber dann zeigt der Text die ersten 150 Zeichen ohne Newline-Hinweis.
   → Neuer Override: display:-webkit-box + -webkit-line-clamp:5 +
     white-space:pre-wrap → 5 Zeilen mit Newlines sichtbar, Rest '…'

3. Keine Detail-Ansicht beim Klick auf Karte
   → Neue Funktion _openDetailModal(note):
     - Voller Notiz-Text scrollbar (.notes-detail-text mit max-height:60vh)
     - Rubrik-Icon + Label im Titel
     - Parent-Label, Micro-Badges, Meta (Zeit + Ort)
     - Footer: 'Bearbeiten' (öffnet Edit-Modal) + 'Schließen'
   → Card-Click bindet darauf; Klicks auf Action-Buttons werden via
     closest('.list-item-action-btn') ignoriert (kein doppeltes Handling)
2026-05-27 14:42:47 +02:00
1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +02:00
7751d303bb Revert: USER appuser in Dockerfile auskommentiert (DSM-ACL-Konflikt), SW by-v1117
Container startete mit USER appuser nicht: SQLite gibt
'attempt to write a readonly database' — Synology DSM Volume-
Permissions blockieren chown auf gemountete Pfade.

User-Anlage (groupadd/useradd) bleibt im Dockerfile, plus
chown nach mkdir. Nur die USER-Zeile ist auskommentiert mit
Kommentar warum. Für Non-DS-Deployments einfach Zeile
aktivieren.

VAPID-Keys-Migration bleibt — die war erfolgreich.
2026-05-27 13:06:25 +02:00
83b1509168 Security: VAPID-Keys raus aus Git, Dockerfile USER appuser, SW by-v1116
1. VAPID-Keys aus docker-compose.yml und docker-compose.staging.yml
   entfernt. Werden jetzt aus .env gelesen (env_file war schon da,
   nur die environment-Override hat die .env-Werte überschrieben).
   .env auf DS um die 3 Keys ergänzt:
   - VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_CONTACT
   Erst Compose-Änderung wirksam — Push-Notifications funktionieren
   weiter weil die .env die Werte liefert.

2. Dockerfile-Hardening: Non-root USER appuser.
   - groupadd/useradd appuser (UID/GID 1000 für DS-Kompatibilität)
   - chown -R appuser:appuser /app /data nach mkdir
   - USER appuser vor CMD
   Memory says DSM ACLs könnten Probleme machen. Falls Container
   nicht startet → Rückbau. Bei Deploy genau hinsehen.

3. E-Mail-Änderungs-Audit-Punkt: kein Vulnerability gefunden.
   ProfileUpdate-Schema enthält kein 'email'-Feld. User können
   ihre E-Mail-Adresse aktuell gar nicht ändern → kein Takeover-
   Vektor wie im Audit vermutet.
2026-05-27 13:02:12 +02:00
35937ed51b Bündel 3: Security-Helper + Demo-Migration, SW by-v1115
NEUE HELPER in auth.py:

require_moderator(user=Depends(get_current_user))
  Konsequente Dependency statt inline
  'if user["rolle"] not in ("admin", "moderator")'

require_breeder(user=Depends(get_current_user))
  Konsequente Dependency statt inline
  'if user["subscription_tier"] not in ("breeder", "breeder_test")'

require_owner(row, user, owner_field='user_id',
              not_found_msg, forbidden_msg) -> row
  Zentralisiert das häufigste Pattern (54 Stellen im Audit):
  Statt:
    row = conn.execute(...).fetchone()
    if not row: raise HTTPException(404, ...)
    if row['user_id'] != user['id']: raise HTTPException(403, ...)
  Jetzt:
    row = require_owner(conn.execute(...).fetchone(), user,
                        not_found_msg='Ort nicht gefunden.')

is_owner_or_admin(row, user, owner_field='user_id') -> bool
  True wenn Owner ODER Admin/Moderator (Admin-Override für
  Moderations-Endpoints)

DEMO-MIGRATION:
places.py PATCH /places/{id} + DELETE /places/{id} migriert auf
require_owner() — als Style-Referenz für künftige Migrationen.

KEINE Massen-Migration der 54 Stellen — bewusste Entscheidung
weil security-kritisch. Helper sind bereitgestellt, neuer Code
nutzt sie, bestehender bleibt funktional identisch.

Tests 19/19 grün.

Hinweis: Massen-Migration der Owner-Checks ist eigener Sprint mit
sehr sorgfältigem Testing — bei jeder migrierten Route muss die
404→403→Cascade durchgeprüft werden, dass Owner+Non-Owner+Admin
sich identisch zum Vorher verhalten.
2026-05-27 11:27:00 +02:00
297bd22f96 Bündel 2: Zentrale Helper für DRY-Cleanup, SW by-v1114
NEUE BACKEND-MODULE:

math_utils.py
- haversine_km(lat1, lon1, lat2, lon2) — Distanz in km
- haversine_m(...) — Convenience-Wrapper in Metern
- bbox_deg_from_km(lat, radius_km) — Bounding-Box-Approximation
  für SQL-Vorfilter (statt Haversine im Python-Loop)

config.py
- DB_PATH, MEDIA_DIR, BREEDER_DOCS_DIR, SCANINPUT_DIR
- API_TIMEOUT_SHORT (5s) / DEFAULT (10s) / LONG (30s)
- HTTP_USER_AGENT, HTTP_HEADERS

errors.py
- not_found(msg), forbidden(msg), bad_request(msg), unauthorized(msg)
- conflict(msg), too_many_requests(msg, retry_after), service_unavailable(msg)
- require_or_404(row, msg) — Convenience-Helper

UI.JS ERWEITERUNGEN:

UI.time erweitert:
- formatDate(d)     → "15.03.2026"
- formatDateTime(d) → "15.03.2026, 14:30"
- weekday(d)        → "Di"
- parseISO(str)     → {year, month, day}

UI.text (neu):
- truncate(str, maxLen, ellipsis='…')
- slug(str) — URL-Slug aus String (mit DE-Umlauten)

UI.money (neu):
- format(value) → "12,34 €" (de-DE, EUR)
- formatWithSuffix(value, '/Jahr')

HAVERSINE-MIGRATION (13 Backend-Routen):
alerts.py, services.py, places.py, events.py, diary.py, playdate.py,
lost.py, poison.py, adoption.py, gassi_zeiten.py, sitting.py, routen.py,
walks.py

- Alle lokalen def _haversine/haversine_km entfernt
- Aufrufe ersetzt durch haversine_km/haversine_m je nach Einheit
- from math_utils import haversine_km|haversine_m in jeder Datei

Tests 19/19 grün.

Hinweis: Migrationen für MEDIA_DIR (19 Stellen), API-Timeouts (12),
Date-Formatter im Frontend (24) und UI.text.truncate (5) sind als
Folge-Sprints möglich. Helper sind verfügbar.
2026-05-27 11:19:06 +02:00
c517c9281d Refactor: 1167 _esc() → UI.escape() in 36 Dateien, SW by-v1113
Bündel 1 aus dem Duplikat-Audit: existierende zentrale Helper nutzen
statt lokale Duplikate.

Pure Migration ohne neuen Code:
- 1167 _esc()-Aufrufe in 36 Page-Modulen migriert auf UI.escape()
- 24 lokale _esc/_escape-Definitionen entfernt
- lost.js hatte _escape() (Variante) — 17 Aufrufe ebenfalls migriert
- jobs.js + breeder.js: tote Alias-Wrapper entfernt

UI.escape() existierte schon — wurde nur überall lokal nochmal
implementiert. Funktional identisch (gleiche 4-replace-chain für
& < > ").

Tests 19/19 grün. Frontend-LOC um ~120 Zeilen reduziert.

Hinweis: _emptyState (7 Stellen) und _icon (8 Stellen) wurden NICHT
migriert — sie haben abweichende Signaturen von UI.emptyState({...})
bzw. UI.icon(name). Eigener Sprint nötig.
2026-05-27 10:15:33 +02:00
e7939ce98e Bündel A-D: Race-Fixes, JWT-Cleanup, Storage-Watchdog, HTTPException, SW by-v1112
A — Founder-Number-Race (Audit-Fund aus Agent 2)
- partner.py PATCH /admin/users: SELECT COUNT + UPDATE+1 →
  atomares UPDATE mit Sub-Query. WHERE-Klausel prüft Limit + dass
  User noch nicht is_founder=1 ist. rowcount=0 → 'Plätze vergeben'.
- dogs.py POST /dogs (erster Hund triggert Gründer-Aktivierung): selbes
  Pattern. Zusätzlich AND is_founder_pending=1 als Schutz.
- Sub-Queries werden gegen Snapshot VOR dem UPDATE evaluiert
  (SQL-Spec), daher keine 'doppelte Nummer' möglich auch wenn zwei
  User gleichzeitig den ersten Hund anlegen.

B — JWT-Blacklist-Cleanup-Job
- _purge_expired_jwt() in auth.py existierte schon, war aber nicht
  verdrahtet → jwt_blacklist wuchs monoton.
- Neuer Scheduler-Job _job_purge_jwt_blacklist täglich 03:30
  (nach poison_archive, in ruhiger Zeit), mit _log_job für
  Error-Digest.

C — iOS Storage-Quota-Watchdog (PWA-Stabilität)
- offline-indicator.js: _checkStorageQuota() per
  navigator.storage.estimate() beim Init + alle 60s im Interval.
- Bei >=80% Auslastung: Tile-Cache auf 100 Einträge trimmen (statt
  default 500). Verhindert QuotaExceededError auf iOS-PWA (~50MB).
- Bei >=90%: einmaliger Toast-Hinweis pro Session
  'Speicher fast voll — Tiles werden gelöscht'.

D — HTTPException in osm.py
- 'raise Exception("Alle Overpass-Instanzen fehlgeschlagen")' wurde
  zu HTTP 500 → User-unfriendly. Jetzt 503 mit klarer Message
  'Kartendaten gerade nicht verfügbar'.

Tests 19/19 grün.
2026-05-27 09:41:56 +02:00
2d98eb9374 Fix: Friends-Avatare wieder Original-URL (kein Preview), SW by-v1111
User-Report: trotz onerror-Fallback weiter Fragezeichen.

Ursache: Das _preview.webp-System wurde damals nur konsequent für
Diary-Uploads ausgerollt. User-Avatare und Hund-Profilbilder haben
keine Preview-Variante → 404 vom _preview triggert kurz das
Browser-Default-Broken-Image-Icon BEVOR der onerror-Fallback das
Original lädt (Race-Condition).

Pragmatischer Fix: Preview-System in friends.js rückgebaut. Bilder
werden direkt mit Original-URL geladen. Performance kommt durch:
- loading=\"lazy\" (off-screen Bilder erst beim Scrollen)
- decoding=\"async\" (Main-Thread bleibt frei)
- onerror=\"this.style.display='none'\" (kaputte Bilder verschwinden
  statt Fragezeichen zu zeigen)

UI.previewUrl + UI.previewFallback bleiben als Helper verfügbar
für später falls das Preview-System app-weit ausgerollt wird.
2026-05-27 09:25:19 +02:00
8e75e2b1a7 Fix: previewFallback blendet kaputte Bilder aus statt Fragezeichen, SW by-v1110
User-Report: nach Sprint-Migration auf _preview.webp tauchen
Fragezeichen-Icons auf — wenn weder Preview noch Original verfügbar.

Probleme im vorigen Fix:
- UI.escape() ist HTML-Escape, kein JS-String-Escape → URL mit
  ?param=value wurde &-encoded und damit kaputt
- 'opacity:0.3' lässt das Browser-Default-Broken-Image-Icon
  durchscheinen (Fragezeichen sichtbar)
- Kein Loop-Schutz beim onerror

Fixes:
- String-Escape via .replace(/'/g, \"\\'\") statt UI.escape()
- display:none + .img-broken-Klasse bei finalem Fehler
- dataset.fb='1' verhindert Endlos-Loop wenn Original-URL auch 404
- Wenn URL nicht mit /media/ startet: direkt ausblenden (keine
  Preview-Variante zu probieren)
2026-05-27 09:20:10 +02:00
2f37e0ed16 Perf: Freunde-Seite nutzt _preview.webp + lazy loading, SW by-v1109
Symptom: Friends-Seite lädt Avatare langsam — Original-Bilder
(z.B. 4-12MB iPhone-Fotos) statt der vorhandenen _preview.webp
Vorschauen.

Neue zentrale Helper in ui.js:
- UI.previewUrl(url): ersetzt /media/...jpg → /media/..._preview.webp
- UI.previewFallback(originalUrl): onerror-Handler der Original
  nachlädt falls _preview nicht existiert (für ältere Uploads)

friends.js 3 Stellen migriert:
- _userAvatar (Freundes-Karte + Aktivitäts-Feed)
- Activity-Avatar (dog_foto + avatar_url)
- Dog-Mini-Thumbs im Profil-Modal

Zusätzlich auf allen drei Stellen:
- loading="lazy" für off-screen Bilder
- decoding="async" damit der Hauptthread nicht blockiert

Reuse-Potential: wiki.js, dog-profile.js und andere können später
auf die zentralen Helper umgestellt werden.
2026-05-27 08:50:09 +02:00
f6633d65b0 Erste-Hilfe: Telefonnummern für AT + CH eingetragen, SW by-v1108
AT:
- VetMedUni Wien Kleintier-Notdienst (24h): +43 1 25077-6900

CH:
- Tox Info Suisse: 145 (in CH gratis) bzw. international +41 44 251 51 51
  (offizielle Notruf-Nummer auch für Tiergifte)
- Tierspital Zürich Kleintier-Notfall (24h): +41 44 635 83 37

Damit alle TODO-Platzhalter aus der Sprint60-Erweiterung der
Erste-Hilfe-Notfallnummern jetzt mit echten Nummern befüllt.
2026-05-27 08:45:43 +02:00
73872e2c21 Sprint D: Karten-Familie auf UI.map.create+svgMarker konsolidiert, SW by-v1107
Neue zentrale Helper (in Sprint B vorbereitet) jetzt von 5 Seiten genutzt:

walks.js (1 Karten-Init):
- L.map+L.tileLayer → await UI.map.create('walks-map', {...})
- _initMap zu async, Aufrufer in _switchView und _loadData angepasst
- Mini-Karte im Walk-Formular (Modal) bleibt unverändert
  (braucht eigene dragging/scrollWheelZoom-Options)
- view-toggle nicht migriert (responsive CSS-Konflikt mit Desktop)

poison.js (1 Karten-Init):
- L.map+L.tileLayer → await UI.map.create('poison-map', {...})
- _initMap zu async, manueller UI.loadLeaflet entfernt
- DangerCircle + User-Marker unverändert

events.js (1 Karten-Init + Diamant-Marker):
- await UI.map.create('ev-map', {...})
- Rotierter Diamant: L.divIcon+L.marker → UI.map.svgMarker
  (HTML 1:1 erhalten)

lost.js (1 Karten-Init + Puls-Marker):
- Eigene async _loadLeaflet() Funktion komplett entfernt — UI.map.create
  übernimmt das jetzt zentral
- await UI.map.create('lost-map', {...})
- Puls-Animation 🐕: L.divIcon+L.marker → UI.map.svgMarker
- _initMap zu async

routes.js (6 von 7 Karten-Inits):
- _suggestMap, _recMap, _searchMap, _navMap, trimMap, _buildDetailMap
  alle auf UI.map.create umgestellt + zu async
- _buildMiniMap (Route-Card-Preview) bleibt unverändert
  (braucht 6 spezifische Interaction-Disable Options)
- View-Toggle auf neue .map-list-toggle Klasse umgestellt
  (Border-Inline-Styles raus)

NEUE CSS-KLASSE in components.css:
- .map-list-toggle (vereinheitlichter Karten/Listen-Umschalter)
- Verwendet von routes.js; walks/events können später folgen

Tests 19/19 grün. GPS-Tracking-Logik (Polylines, Recording, Trim)
komplett unangetastet. Marker-Cluster-Logik unverändert.
2026-05-27 08:17:06 +02:00
c8ef4939f1 Fix: /force-update reload-Hänger + Cooldown persistent, SW by-v1106
User-Report: 'Leckerlis'-Screen verschwindet nicht mehr.

Bug: Vorige Version nutzte 'await Promise.all([sw-unregister,
caches.delete])' VOR dem Reload. Auf iOS-PWA können diese Promises
gelegentlich nie resolven → Reload kommt nie.

Fix /force-update:
- Cleanup-Tasks fire-and-forget (kein await, kein Promise.all)
- Sofort-Reload nach 150ms (kein await-Block)
- Fallback 1: Nach 3s erscheint 'App neu starten'-Button für
  manuellen Tap
- Fallback 2: Nach 6s automatisch location.href mit ?hard=1

Fix app.js Cooldown:
- localStorage statt sessionStorage — überlebt PWA-App-Close
- 10 Min statt 5 Min Cooldown (großzügiger Spielraum bei
  Update-Wellen)
2026-05-27 08:02:54 +02:00
b0ae71ba69 Fix: Force-Update Cooldown + robusteres Cache-Clear, SW by-v1105
Symptom: 'Einen Moment, wir besorgen neue Leckerlis' Loading-Screen
erscheint beim User wiederholt beim Wechsel in andere Bereiche.

Ursachen:
1. In dieser Session wurden viele Bumps in kurzer Zeit ausgerollt
   (1100 → 1104). Jeder Versions-Mismatch zwischen App-Cache und
   Server triggert force-update.
2. /force-update Cache-Delete war fire-and-forget mit nur 1.5s
   Reload-Timer — auf iOS-PWA oft zu kurz für asynchrone unregister/
   caches.delete, daher landete der Reload manchmal noch im alten
   Cache-Stand → erneuter Mismatch → erneuter force-update.

Fixes:
- app.js: Cooldown 5 Min nach force-update — verhindert Loop bei
  mehrfachen schnellen Bumps. Mismatch wird erkannt aber nicht mehr
  sofort reagiert.
- /force-update: async/await für SW-Unregister + Cache-Delete bevor
  Reload. Safety-Timeout 4s. Reload-URL mit ?_t= Cache-Bust.
2026-05-27 07:51:36 +02:00
9a066cb24c Sprint C: Listen-Familie konsolidiert (Notes/Expenses/Health), SW by-v1104
Neue zentrale CSS-Datei lists.css (~280 Zeilen) mit Listen-Komponenten:
- .list-shell, .list-filter-bar, .list-search-wrap
- .list-group-header
- .list-item-card + Modifier: --clickable, --milestone, --inactive
- .list-item-date-col + sub-elements (für Diary-Style)
- .list-item-meta-badge mit --meta-color (für Expenses/Health Icons)
- .list-item-body, .list-item-title, .list-item-text, .list-item-meta-row
- .list-item-chips + .list-item-chip mit --chip-color
- .list-item-micro-badges + .list-item-micro-badge
- .list-item-thumb (+ .list-item-thumb-count Overlay)
- .list-item-amount (+ --positive/--negative/--neutral)
- .list-item-actions + .list-item-action-btn (+ --danger)
- .list-reminders-banner + .list-reminder-item (+ --urgent/--warning/--success)
- .list-fab (FAB mit safe-area-inset)

MIGRATIONEN:

notes.js — 10+ Klassen ersetzt:
- .notes-card → .list-item-card list-item-card--clickable
- .notes-rubrik-chip → .list-item-chip mit --chip-color
- .notes-card-meta → .list-item-meta-row
- .notes-action-btn → .list-item-action-btn
- .notes-group-label → .list-group-header
- Notes-spezifische Klassen als Modifier behalten (vertikales Layout,
  pre-wrap text, Top-Zeile mit Actions rechts oben)
- Alte CSS-Definitionen im Inline-<style> als TODO markiert

expenses.js — komplette Item-Card-Migration:
- .exp-entry → .list-item-card list-item-card--clickable
- .exp-entry-icon-badge mit --kat-color → .list-item-meta-badge --meta-color
- .exp-entry-betrag → .list-item-amount list-item-amount--negative
- .exp-entry-del → .list-item-action-btn list-item-action-btn--danger
- .exp-recurring-card--inaktiv → .list-item-card--inactive
- .exp-fab → .list-fab
- UI.moneyInput + UI.parseMoney in beide Forms integriert (€-Prefix,
  Komma-Dezimal)
- Hero-Card + Statistik/Kacheln behalten (spezifisch)

health.js — 9 Card-Renderings migriert:
- Impfungen/Tierarzt/Gewicht/Läufigkeit/Medikamente/Allergien/
  Dokumente/Tierarztpraxis/Befunde
- .health-card → .list-item-card list-item-card--clickable
- Health-Ampel parallel behalten (.health-card-ampel + Linie links)
- Reminder-Banner: .health-reminder-* → .list-reminders-banner +
  .list-reminder-item--urgent/--warning
- Gewicht-Wert: .list-item-amount für kg-Anzeigen
- Form-Modals + KI-Buttons + Transponder-Chip unangetastet (anderer
  Scope)

Tests 19/19 grün. Kein visueller Diff erwartet — Modifier-Klassen
bewahren spezifische Layouts.
2026-05-27 07:31:21 +02:00
1de39536af Sprint B: 5 neue UI-Helper für konsistente Patterns, SW by-v1103
Neue zentrale Komponenten in ui.js:

1. UI.errorState({icon, title, message, retry})
   Dedizierte Error-UI statt nur Toast. Mit optionalem Retry-Button
   (asyncButton-integriert). Analog zu UI.emptyState. Behebt
   Inkonsistenzen: Toast vs. ad-hoc HTML vs. Empty-State-Reuse.

2. UI.skeletonList(count)
   Karten-Skeleton für Listen-Loading (Avatar + 2 Zeilen pro Item).
   Erweitert UI.skeleton(lines) — beide bleiben verfügbar.

3. UI.moneyInput({name, value, currency='€', placeholder})
   Euro-Input mit Prefix + locale-Format (Komma als Dezimal-
   trenner). Extrahiert aus expenses.js Best-Practice.
   Plus UI.parseMoney(str) als Parser-Helper.

4. UI.datePicker({name, label, value, min, max, required})
   Standard-Date-Input mit Label, min/max ('today' wird zu ISO-
   Datum konvertiert). Vereinfacht Form-Boilerplate.

5. UI.map.create(containerId, opts)
   Zentraler Leaflet-Init mit OSM-Tiles + Dark-Mode-Filter-Option.
   Konstanten OSM_URL + OSM_MAX_ZOOM zentral. Plus UI.map.svgMarker
   für eigene divIcon-Marker (für events.js Diamant, lost.js Puls).

Alle Helper sind backward-kompatibel — bestehende Patterns funktionieren
weiter. Tests 19/19 grün. Migration der Aufrufer kommt in Sprint C+D.
2026-05-27 07:19:52 +02:00
459cd425f2 Design-System Sprint A: utilities.css + 948 Inline-Styles → Utility-Klassen, SW by-v1102
PHASE 1 — Sofort-Cleanup ohne Risiko:
- Neue Datei utilities.css mit ~25 Klassen für häufige Kombinationen:
  * text-xs-muted, text-xs-secondary, text-sm-muted, text-sm-secondary
  * flex-gap-2/3, flex-col-gap-2/3/4, flex-center-gap-1/2/3
  * flex-between, flex-1-min, mb-1/3, mt-1/3
  * icon-xs/sm/md/lg, label-block, caption
- index.html bindet utilities.css ein
- mb-3/mt-3 ergänzt (waren in design-system.css unvollständig)

PHASE 2 — .by-tab Modifier für Vereinheitlichung:
- .by-tabs.grid (mit --tab-cols Variable für Admin/Health/etc.)
- .by-tabs.sticky (Desktop vertikale Tabs für Admin)
- .by-tabs.wrap (Zuchthunde, flex-wrap statt scroll)
- .by-tabs.separated (Sitting, mit eigenem Hintergrund + Border)

PHASE 3 — Inline-Style → Klassen-Migration (Python-Script):
- 948 Inline-Styles entfernt (5101 → 4153, -18%)
- 962 Migrationen über 47 Page-Dateien
- Top-Treffer: admin.js (180), health.js (67), dog-profile.js (67),
  litters.js (62), settings.js (61), zuchthunde.js (51)
- Patterns: text-muted, text-secondary, text-danger, text-xs-muted,
  text-sm-muted, grid-2 (Duplikat-Bug behoben!), flex-col-gap-3,
  p-3/4, mb-2/3/4, hidden, w-full, flex-1, ...
- Bewahrt bestehende class-Attribute (mergt korrekt)

Alle 19 Tests grün. Kein visueller Diff erwartet (gleiche Property-Werte).
2026-05-27 07:11:27 +02:00
279f76714e Fix: Offline+Verify-Banner berücksichtigen safe-area-inset-top, SW by-v1101 2026-05-27 06:27:18 +02:00
65cfa25e59 Security: CSP gehärtet — unsafe-inline + unsafe-eval raus, SW by-v1100
Inline-Scripts extrahiert:
- boot-early.js: Theme + theme-color (synchron im <head>, VOR CSS)
- boot.js: Offline-Banner + Service-Worker-Registration + Update-Flow
- landing-init.js: Dark-mode + Scroll-Animationen + Live-Stats +
  Stay-In-App-Handler + Details-Toggle

Inline onclick-Handler in landing.html:
- 5× sessionStorage.setItem('by_stay_in_app','1') → data-stay-in-app
- 1× Details-Toggle → data-toggle-target + data-toggle-text-open
- JS-Handler in landing-init.js binden die data-Attribute

CSP-Header (main.py):
- script-src: 'unsafe-inline' und 'unsafe-eval' entfernt
- style-src 'unsafe-inline' bleibt (Inline-Styles bleiben für jetzt,
  zu viele Fundstellen)
- Umami bleibt whitelisted

SW STATIC_ASSETS erweitert um boot-early.js + boot.js.
make bump aktualisiert jetzt auch landing.html ?v= Anker.
Tests grün (19/19).
2026-05-27 06:23:47 +02:00
123 changed files with 5534 additions and 3476 deletions

View file

@ -8,6 +8,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Non-root User für Container-Hardening
# (Synology DSM-Volumes haben ACLs — daher chown auf /data + /app)
RUN groupadd -r appuser -g 1000 && \
useradd -r -u 1000 -g appuser -d /app -s /sbin/nologin appuser
# Python-Dependencies zuerst (Docker Layer Cache)
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@ -22,6 +27,12 @@ COPY VERSION /app/VERSION
RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \
/data/media/breeds/gallery /data/media/breeds/submissions
# USER appuser auskommentiert: Synology DSM Volume-ACLs blockieren das
# (SQLite OperationalError: 'attempt to write a readonly database'). User-
# Anlage bleibt im Dockerfile damit nicht-DS-Deployments später wechseln
# können via `USER appuser` Zeile auskommentieren-entfernen.
# USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]

View file

@ -287,7 +287,8 @@ bump:
sed -i.bak -E "s/const VER[[:space:]]*=[[:space:]]*'[0-9]+'/const VER = '$$NEW'/" backend/static/sw.js && rm -f backend/static/sw.js.bak; \
sed -i.bak -E "s/const APP_VER[[:space:]]*=[[:space:]]*'[0-9]+'/const APP_VER = '$$NEW'/" backend/static/js/app.js && rm -f backend/static/js/app.js.bak; \
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/index.html && rm -f backend/static/index.html.bak; \
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html aktualisiert)"
sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \
echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)"
# ----------------------------------------------------------
# TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS)

View file

@ -1 +1 @@
1099
1120

View file

@ -212,6 +212,49 @@ def require_admin(user=Depends(get_current_user)):
return user
def require_moderator(user=Depends(get_current_user)):
"""Dependency: Admin oder Moderator. Konsequente Nutzung statt
Inline-`if user['rolle'] not in (...):` in den Routen."""
if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Moderator-Zugriff erforderlich.")
return user
def require_breeder(user=Depends(get_current_user)):
"""Dependency: Admin oder Züchter (breeder/breeder_test)."""
if user["rolle"] == "admin":
return user
if user.get("subscription_tier") in ("breeder", "breeder_test"):
return user
raise HTTPException(status.HTTP_403_FORBIDDEN, "Züchter-Zugriff erforderlich.")
# ------------------------------------------------------------------
# Owner-Checks — zentral, statt 54x inline `if row['user_id'] != user['id']: 403`
# ------------------------------------------------------------------
def require_owner(row, user: dict, owner_field: str = "user_id",
not_found_msg: str = "Nicht gefunden",
forbidden_msg: str = "Kein Zugriff"):
"""Wirft 404 wenn row None/falsy ist, 403 wenn User nicht Besitzer.
Returns row für chainability:
dog = require_owner(conn.execute(...).fetchone(), user, 'user_id', 'Hund nicht gefunden')
"""
if not row:
raise HTTPException(status.HTTP_404_NOT_FOUND, not_found_msg)
if row[owner_field] != user["id"]:
raise HTTPException(status.HTTP_403_FORBIDDEN, forbidden_msg)
return row
def is_owner_or_admin(row, user: dict, owner_field: str = "user_id") -> bool:
"""True wenn User Owner ist oder Admin/Moderator."""
if not row:
return False
if user["rolle"] in ("admin", "moderator") or user.get("is_moderator"):
return True
return row[owner_field] == user["id"]
def has_pro_access(user: dict) -> bool:
"""True wenn User Pro-Features nutzen darf."""
if not user:

20
backend/config.py Normal file
View file

@ -0,0 +1,20 @@
"""Zentrale Konfiguration — vermeidet 19× duplizierte os.getenv-Aufrufe
für MEDIA_DIR und gibt einheitliche Timeout-Konstanten für externe APIs."""
import os
# Speicher-Pfade
DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db")
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs")
SCANINPUT_DIR = os.getenv("SCANINPUT_DIR", "/data/scaninput")
# HTTP-Timeouts für externe APIs (in Sekunden)
# Verwendung: httpx.AsyncClient(timeout=API_TIMEOUT_DEFAULT)
API_TIMEOUT_SHORT = 5 # Schnelle Lookups (Geocoding, Reverse, einzelne Werte)
API_TIMEOUT_DEFAULT = 10 # Standardfall (Wetter, Wikipedia)
API_TIMEOUT_LONG = 30 # Größere Antworten (Overpass-Tiles, KI-Calls)
# Standard-Header für externe Requests (Höflichkeit + Fair-Use)
HTTP_USER_AGENT = "BanYaro/1.0 (https://banyaro.app)"
HTTP_HEADERS = {"User-Agent": HTTP_USER_AGENT}

47
backend/errors.py Normal file
View file

@ -0,0 +1,47 @@
"""Standardisierte HTTP-Exceptions — vermeidet inkonsistente Texte
in 200+ raise-Statements."""
from fastapi import HTTPException
def not_found(msg: str = "Nicht gefunden") -> HTTPException:
"""404. Beispiel: `raise not_found('Hund nicht gefunden')`."""
return HTTPException(404, msg)
def forbidden(msg: str = "Kein Zugriff") -> HTTPException:
"""403."""
return HTTPException(403, msg)
def bad_request(msg: str = "Ungültige Eingabe") -> HTTPException:
"""400."""
return HTTPException(400, msg)
def unauthorized(msg: str = "Nicht angemeldet") -> HTTPException:
"""401."""
return HTTPException(401, msg)
def conflict(msg: str = "Konflikt") -> HTTPException:
"""409."""
return HTTPException(409, msg)
def too_many_requests(msg: str = "Zu viele Anfragen", retry_after: int | None = None) -> HTTPException:
"""429. Optional mit Retry-After Header (in Sekunden)."""
headers = {"Retry-After": str(retry_after)} if retry_after else None
return HTTPException(429, msg, headers=headers)
def service_unavailable(msg: str = "Dienst gerade nicht verfügbar") -> HTTPException:
"""503."""
return HTTPException(503, msg)
def require_or_404(row, msg: str = "Nicht gefunden"):
"""Convenience: wirft 404 wenn row None/falsy, sonst gibt row zurück.
Beispiel: `dog = require_or_404(conn.execute(...).fetchone(), 'Hund nicht gefunden')`"""
if not row:
raise not_found(msg)
return row

View file

@ -110,8 +110,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
"frame-ancestors 'none'; "
@ -1763,19 +1763,40 @@ async def force_update():
<title>Ban Yaro Update</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
p{color:#94a3b8;font-size:14px}</style></head>
p{color:#94a3b8;font-size:14px}
button{margin-top:24px;background:#C4843A;color:#fff;border:none;padding:12px 24px;
border-radius:8px;font-size:16px;cursor:pointer}</style></head>
<body>
<div> Einen Moment</div>
<p id="s">Wir besorgen neue Leckerlis 🦴</p>
<button id="b" style="display:none" onclick="location.replace('/?_t='+Date.now())">App neu starten</button>
<script>
// Zweiten Reload durch SW-updatefound verhindern
sessionStorage.setItem('by_skip_sw_reload','1');
// Fire-and-forget kein await, Reload nach spätestens 1.5s
try{
navigator.serviceWorker?.getRegistrations().then(r=>r.forEach(s=>s.unregister())).catch(()=>{});
caches.keys().then(k=>k.forEach(c=>caches.delete(c))).catch(()=>{});
}catch(e){}
setTimeout(()=>location.replace('/'),1500);
// Cleanup IM HINTERGRUND starten (fire-and-forget) kein await,
// kein Blockieren. Selbst wenn die Promises nie resolven (iOS-Bug),
// hängen wir nicht.
try {
if (navigator.serviceWorker) {
navigator.serviceWorker.getRegistrations()
.then(r => r.forEach(s => s.unregister().catch(() => {})))
.catch(() => {});
}
if (window.caches) {
caches.keys()
.then(k => k.forEach(c => caches.delete(c).catch(() => {})))
.catch(() => {});
}
} catch(e) {}
// Sofort reload keine Promise-Abhängigkeit
setTimeout(() => location.replace('/?_t=' + Date.now()), 150);
// Fallback: falls Reload nach 3s noch nicht passiert ist
// (z.B. SW intercepted), Button anzeigen für manuellen Tap
setTimeout(() => { document.getElementById('b').style.display = ''; }, 3000);
// Fallback 2: nach 6s automatisch nochmal versuchen mit hartem reload
setTimeout(() => location.href = '/?_t=' + Date.now() + '&hard=1', 6000);
</script></body></html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})

37
backend/math_utils.py Normal file
View file

@ -0,0 +1,37 @@
"""Mathematische Helper-Funktionen — zentral statt 13× dupliziert."""
import math
# Erdradius in Kilometern
EARTH_RADIUS_KM = 6371.0
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distanz zwischen zwei GPS-Koordinaten in km (Haversine-Formel).
Funktioniert für beliebige Punkte auf der Erde. Genauigkeit reicht
für App-Zwecke (Umkreissuche etc.).
"""
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distanz in Metern (Convenience-Wrapper)."""
return haversine_km(lat1, lon1, lat2, lon2) * 1000.0
def bbox_deg_from_km(lat: float, radius_km: float):
"""Bounding-Box-Approximation in Grad für radius_km um (lat, lon).
Returns (lat_delta, lon_delta) beide in Grad.
Verwendung: WHERE lat BETWEEN ?-lat_delta AND ?+lat_delta etc.
"""
lat_delta = radius_km / 111.0
lon_delta = radius_km / (111.0 * max(abs(math.cos(math.radians(lat))), 0.01))
return lat_delta, lon_delta

View file

@ -12,7 +12,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from database import db, DB_PATH
from auth import get_current_user
@ -92,15 +92,15 @@ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "bree
class QuarterlyReportBody(BaseModel):
year: int
quarter: int
email: str
email: str = Field(..., max_length=254)
class UserPatch(BaseModel):
rolle: Optional[str] = None # user | moderator | admin
rolle: Optional[str] = Field(None, max_length=30) # user | moderator | admin
is_moderator: Optional[int] = None
is_banned: Optional[int] = None
ban_reason: Optional[str] = None
ban_reason: Optional[str] = Field(None, max_length=1000)
is_social_media: Optional[int] = None
subscription_tier: Optional[str] = None
subscription_tier: Optional[str] = Field(None, max_length=50)
class WikiEnrichBody(BaseModel):
limit: int = 10

View file

@ -10,18 +10,18 @@ Caching: adoption_cache Tabelle, 24h TTL.
"""
import os
import math
import logging
import asyncio
import uuid
import httpx
from datetime import datetime, timedelta
from fastapi import APIRouter, Query, BackgroundTasks, Depends, Form, UploadFile, File, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
from math_utils import haversine_km
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -31,18 +31,6 @@ 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)
@ -234,7 +222,7 @@ async def adoption_nearby(
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"])
dist = haversine_km(lat, lon, d["tierheim_lat"], d["tierheim_lon"])
if dist <= radius:
d["distanz_km"] = round(dist, 1)
cached_animals.append(d)
@ -250,7 +238,7 @@ async def adoption_nearby(
# ------ Statische Tierheime (immer) ------
shelters = []
for sid, name, plz, stadt, slat, slon, url in GERMAN_SHELTERS:
dist = _haversine(lat, lon, slat, slon)
dist = haversine_km(lat, lon, slat, slon)
if dist <= radius:
shelters.append({
"id": sid,
@ -304,7 +292,7 @@ async def adoption_geocode(plz: str = Query(..., min_length=4, max_length=10)):
# ==================================================================
class InterestBody(BaseModel):
nachricht: Optional[str] = None
nachricht: Optional[str] = Field(None, max_length=5000)
# ------------------------------------------------------------------
@ -354,7 +342,7 @@ def community_list(
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"])
dist = haversine_km(lat, lon, d["lat"], d["lon"])
d["distanz_km"] = round(dist, 1)
if dist > radius:
continue
@ -434,7 +422,7 @@ async def community_create(
# PATCH /api/adoption/community/{id} — Status ändern (nur Besitzer)
# ------------------------------------------------------------------
class _StatusBody(BaseModel):
status: str
status: str = Field(..., max_length=50)
@router.patch("/community/{listing_id}")
def community_update_status(

View file

@ -1,10 +1,10 @@
"""BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)"""
import math
from datetime import datetime
from fastapi import APIRouter, Depends
from database import db
from auth import get_current_user_optional as get_optional_user
from math_utils import haversine_m, bbox_deg_from_km
router = APIRouter()
@ -12,21 +12,9 @@ _RADIUS_M = 20_000 # 20 km
_RADIUS_KM = _RADIUS_M / 1000.0
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def _bbox(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]:
"""Bounding-Box-Approximation für lat/lon innerhalb radius_km."""
lat_delta = radius_km / 111.0
# cos darf bei Polen nicht 0 werden → mit kleinem Minimum absichern
cos_lat = max(abs(math.cos(math.radians(lat))), 0.01)
lon_delta = radius_km / (111.0 * cos_lat)
lat_delta, lon_delta = bbox_deg_from_km(lat, radius_km)
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta)
@ -60,7 +48,7 @@ async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user))
(lat, lon, user["id"])
)
has_poison = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons)
has_lost = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost)
has_poison = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons)
has_lost = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost)
return {"poison": has_poison, "lost": has_lost}

View file

@ -10,7 +10,7 @@ from typing import Optional
import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, Field
from database import db
from auth import (
hash_password, verify_password, create_token,
@ -146,13 +146,13 @@ def _send_verification_email(email: str, name: str, token: str):
class LoginRequest(BaseModel):
email: EmailStr
password: str
password: str = Field(..., min_length=1, max_length=200)
class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: str
ref_code: Optional[str] = None
password: str = Field(..., min_length=8, max_length=200)
name: str = Field(..., min_length=2, max_length=40)
ref_code: Optional[str] = Field(None, max_length=50)
def _gen_referral_code() -> str:
@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
password: str
token: str = Field(..., min_length=10, max_length=200)
password: str = Field(..., min_length=8, max_length=200)
@router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, request: Request):
@ -471,8 +471,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
class UpgradeRequestBody(BaseModel):
tier: str
message: Optional[str] = None
tier: str = Field(..., max_length=50)
message: Optional[str] = Field(None, max_length=2000)
@router.post("/upgrade-request")
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)):

View file

@ -6,7 +6,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
@ -237,7 +237,7 @@ async def admin_download_document(user_id: int, doc_id: int, admin=Depends(requi
class RejectBody(BaseModel):
grund: str
grund: str = Field(..., min_length=3, max_length=2000)
# ------------------------------------------------------------------
@ -483,13 +483,13 @@ async def admin_create_profile(admin=Depends(require_admin)):
# PUT /api/breeder/profile — eigenes Profil bearbeiten
# ------------------------------------------------------------------
class BreederProfileUpdate(BaseModel):
zwingername: Optional[str] = None
rasse_text: Optional[str] = None
verein: Optional[str] = None
zwingername: Optional[str] = Field(None, max_length=200)
rasse_text: Optional[str] = Field(None, max_length=200)
verein: Optional[str] = Field(None, max_length=200)
vdh_mitglied: Optional[int] = None
stadt: Optional[str] = None
website: Optional[str] = None
beschreibung: Optional[str] = None
stadt: Optional[str] = Field(None, max_length=200)
website: Optional[str] = Field(None, max_length=500)
beschreibung: Optional[str] = Field(None, max_length=10000)
@router.put("/breeder/profile")
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)):

View file

@ -1,7 +1,7 @@
"""BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
import os, logging, asyncio
from database import db
@ -30,10 +30,10 @@ def _require_breeder(user=Depends(get_current_user)):
# Modelle
# ------------------------------------------------------------------
class VisibilityBody(BaseModel):
visibility: str
visibility: str = Field(..., max_length=30)
class CaptionBody(BaseModel):
caption: Optional[str] = None
caption: Optional[str] = Field(None, max_length=500)
# ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import os
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from database import db
from auth import get_current_user
@ -142,7 +142,7 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
class SendMsgModel(BaseModel):
text: str
text: str = Field(..., min_length=1, max_length=2000)
@router.post("/conversations/{conv_id}/messages", status_code=201)
@ -151,8 +151,6 @@ async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_curren
text = data.text.strip()
if not text:
raise HTTPException(400, "Nachricht darf nicht leer sein.")
if len(text) > 2000:
raise HTTPException(400, "Nachricht zu lang (max. 2000 Zeichen).")
with db() as conn:
conv = conn.execute(

View file

@ -1,8 +1,8 @@
"""BAN YARO — Tagebuch Routes"""
import os, uuid, json, math, logging, asyncio
import os, uuid, json, logging, asyncio
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user, require_admin
@ -11,6 +11,7 @@ import httpx
import weather as weather_mod
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif, generate_preview, preview_url_from, get_image_size
from timeutils import safe_client_time
from math_utils import haversine_km
logger = logging.getLogger(__name__)
@ -19,27 +20,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute
client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = "eintrag"
titel: Optional[str] = None
text: Optional[str] = None
datum: Optional[str] = Field(None, max_length=32) # ISO date, default heute
client_time: Optional[str] = Field(None, max_length=64) # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = Field("eintrag", max_length=50)
titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = Field(None, max_length=10000)
tags: Optional[list] = None
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
location_name: Optional[str] = None
location_name: Optional[str] = Field(None, max_length=300)
is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
weather_json: Optional[str] = None # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
weather_json: Optional[str] = Field(None, max_length=5000) # Client-seitig vorab geholtes Wetter (Fallback wenn kein GPS)
class DiaryUpdate(BaseModel):
titel: Optional[str] = None
text: Optional[str] = None
titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = Field(None, max_length=10000)
tags: Optional[list] = None
gps_lat: Optional[float] = None
gps_lon: Optional[float] = None
location_name: Optional[str] = None
location_name: Optional[str] = Field(None, max_length=300)
is_milestone: Optional[bool] = None
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
@ -409,7 +410,7 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list
elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon:
km = _haversine_km(lat, lon, elat, elon)
km = haversine_km(lat, lon, elat, elon)
typ = next((el["tags"].get(k) for k in
["tourism", "historic", "leisure", "amenity", "shop"]
if el["tags"].get(k)), "place")
@ -422,16 +423,6 @@ async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list
return results[:limit]
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
R = 6371
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))
@router.get("/{dog_id}/diary/nearby")
async def nearby_places(dog_id: int, lat: float, lon: float,
user=Depends(get_current_user)):
@ -445,7 +436,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
(user["id"],)
).fetchall()
for p in places:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
km = haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"],
@ -456,7 +447,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
"SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall()
for p in osm:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
km = haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 2:
results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"],
@ -503,7 +494,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon:
km = _haversine_km(lat, lon, elat, elon)
km = haversine_km(lat, lon, elat, elon)
typ = next((el["tags"].get(k) for k in
["tourism","historic","leisure","amenity","shop"]
if el["tags"].get(k)), "place")

View file

@ -3,7 +3,7 @@
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user, has_pro_access
@ -29,28 +29,28 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DogCreate(BaseModel):
name: str
rasse: Optional[str] = None
geburtstag: Optional[str] = None
geschlecht: Optional[str] = None
name: str = Field(..., min_length=1, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
geburtstag: Optional[str] = Field(None, max_length=32)
geschlecht: Optional[str] = Field(None, max_length=20)
gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = None
bio: Optional[str] = None
is_public: bool = False
chip_nr: Optional[str] = Field(None, max_length=50)
bio: Optional[str] = Field(None, max_length=2000)
is_public: bool = False
class DogUpdate(BaseModel):
name: Optional[str] = None
rasse: Optional[str] = None
rasse_id: Optional[int] = None
geburtstag: Optional[str] = None
geschlecht: Optional[str] = None
name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
rasse_id: Optional[int] = None
geburtstag: Optional[str] = Field(None, max_length=32)
geschlecht: Optional[str] = Field(None, max_length=20)
gewicht_kg: Optional[float] = None
widerrist_cm: Optional[float] = None
chip_nr: Optional[str] = None
bio: Optional[str] = None
is_public: Optional[bool] = None
chip_nr: Optional[str] = Field(None, max_length=50)
bio: Optional[str] = Field(None, max_length=2000)
is_public: Optional[bool] = None
@router.get("")
@ -180,14 +180,22 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)):
if dog_count == 1: # genau dieser erste Hund
plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag)
if plausible:
total = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total < 100:
conn.execute(
"UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?",
(total + 1, user["id"])
)
# Atomare Gründer-Vergabe — Race-frei via Sub-Query im UPDATE.
# Wenn schon 100 Founder oder User schon is_founder=1 → kein Update (rowcount=0)
conn.execute(
"""UPDATE users
SET is_founder = 1,
founder_number = (
SELECT IFNULL(MAX(founder_number), 0) + 1
FROM users WHERE is_founder = 1
),
is_founder_pending = 0
WHERE id = ?
AND is_founder_pending = 1
AND (is_founder IS NULL OR is_founder = 0)
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < 100""",
(user["id"],)
)
return dict(dog)
@ -1025,8 +1033,8 @@ async def public_dog_profile(dog_id: int):
class FoundReport(BaseModel):
message: Optional[str] = None
kontakt: Optional[str] = None
message: Optional[str] = Field(None, max_length=1000)
kontakt: Optional[str] = Field(None, max_length=300)
# Gefunden-Meldung (kein Login nötig)
@ -1311,7 +1319,7 @@ async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
# POST /api/dogs/{id}/gedenken — Hund als verstorben markieren
# ------------------------------------------------------------------
class GedenkenData(BaseModel):
verstorben_am: str # YYYY-MM-DD
verstorben_am: str = Field(..., max_length=32) # YYYY-MM-DD
@router.post("/{dog_id}/gedenken")
async def mark_verstorben(dog_id: int, data: GedenkenData, user=Depends(get_current_user)):

View file

@ -2,7 +2,7 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
@ -16,18 +16,18 @@ logger = logging.getLogger(__name__)
# Schemas
# ------------------------------------------------------------------
class FutterProfilUpdate(BaseModel):
futter_typ: Optional[str] = None # trocken|nass|barf|mix
marke: Optional[str] = None
futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix
marke: Optional[str] = Field(None, max_length=200)
kcal_tag: Optional[int] = None
portionen: Optional[int] = None
notizen: Optional[str] = None
notizen: Optional[str] = Field(None, max_length=5000)
class KiBeratungRequest(BaseModel):
frage: str
dog_name: Optional[str] = None
rasse: Optional[str] = None
alter: Optional[str] = None
frage: str = Field(..., min_length=3, max_length=2000)
dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = Field(None, max_length=50)
gewicht: Optional[float] = None
aktiv: Optional[bool] = None
@ -183,20 +183,20 @@ _GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb wenige
class FutterEintragCreate(BaseModel):
datum: str
uhrzeit: str
futter_name: str
futter_typ: Optional[str] = "trockenfutter"
datum: str = Field(..., max_length=32)
uhrzeit: str = Field(..., max_length=20)
futter_name: str = Field(..., max_length=200)
futter_typ: Optional[str] = Field("trockenfutter", max_length=50)
menge_g: Optional[int] = None
notiz: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000)
class ReaktionCreate(BaseModel):
datum: str
uhrzeit: str
reaktion_typ: str
datum: str = Field(..., max_length=32)
uhrzeit: str = Field(..., max_length=20)
reaktion_typ: str = Field(..., max_length=100)
intensitaet: Optional[int] = 3
notiz: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000)
# ------------------------------------------------------------------

View file

@ -1,54 +1,45 @@
"""BAN YARO — Events (Hundeveranstaltungen)"""
import math
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from math_utils import haversine_m
router = APIRouter()
TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'}
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class RsvpCreate(BaseModel):
status: str = 'going' # 'going' | 'maybe'
status: str = Field('going', max_length=20) # 'going' | 'maybe'
class EventCreate(BaseModel):
titel: str
datum: str # YYYY-MM-DD
uhrzeit: Optional[str] = None
titel: str = Field(..., min_length=3, max_length=200)
datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None
lon: Optional[float] = None
ort_name: Optional[str] = None
typ: str = 'sonstiges'
beschreibung: Optional[str] = None
link: Optional[str] = None
ort_name: Optional[str] = Field(None, max_length=300)
typ: str = Field('sonstiges', max_length=50)
beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = Field(None, max_length=500)
class EventUpdate(BaseModel):
titel: Optional[str] = None
datum: Optional[str] = None
uhrzeit: Optional[str] = None
titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None
lon: Optional[float] = None
ort_name: Optional[str] = None
typ: Optional[str] = None
beschreibung: Optional[str] = None
link: Optional[str] = None
ort_name: Optional[str] = Field(None, max_length=300)
typ: Optional[str] = Field(None, max_length=50)
beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = Field(None, max_length=500)
# ------------------------------------------------------------------
@ -86,7 +77,7 @@ async def list_events(
result = [dict(r) for r in rows]
if lat is not None and lon is not None:
result = [r for r in result
if r['lat'] is None or _haversine(lat, lon, r['lat'], r['lon']) <= radius]
if r['lat'] is None or haversine_m(lat, lon, r['lat'], r['lon']) <= radius]
return result

View file

@ -4,7 +4,7 @@ import logging
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
@ -20,35 +20,35 @@ KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonst
# ------------------------------------------------------------------
class ExpenseCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
kategorie: str = Field(..., max_length=50)
betrag: float
datum: str
notiz: Optional[str] = None
datum: str = Field(..., max_length=32)
notiz: Optional[str] = Field(None, max_length=1000)
class ExpenseUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
kategorie: Optional[str] = Field(None, max_length=50)
betrag: Optional[float] = None
datum: Optional[str] = None
notiz: Optional[str] = None
datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=1000)
class RecurringCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
kategorie: str = Field(..., max_length=50)
betrag: float
haeufigkeit: str # monatlich | quartalsweise | jaehrlich
startdatum: str # ISO date
notiz: Optional[str] = None
haeufigkeit: str = Field(..., max_length=30) # monatlich | quartalsweise | jaehrlich
startdatum: str = Field(..., max_length=32) # ISO date
notiz: Optional[str] = Field(None, max_length=1000)
class RecurringUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
kategorie: Optional[str] = Field(None, max_length=50)
betrag: Optional[float] = None
haeufigkeit: Optional[str] = None
startdatum: Optional[str] = None
notiz: Optional[str] = None
haeufigkeit: Optional[str] = Field(None, max_length=30)
startdatum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=1000)
aktiv: Optional[bool] = None

View file

@ -2,7 +2,7 @@
import os, uuid, json, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
@ -27,40 +27,40 @@ KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung',
# Schemas
# ------------------------------------------------------------------
class ThreadCreate(BaseModel):
kategorie: str = 'allgemein'
titel: str
text: str
kategorie: str = Field('allgemein', max_length=100)
titel: str = Field(..., min_length=3, max_length=200)
text: str = Field(..., min_length=1, max_length=10000)
thread_lat: Optional[float] = None
thread_lon: Optional[float] = None
thread_ort: Optional[str] = None
client_time: Optional[str] = None
thread_ort: Optional[str] = Field(None, max_length=300)
client_time: Optional[str] = Field(None, max_length=64)
class PostCreate(BaseModel):
text: str
client_time: Optional[str] = None
text: str = Field(..., min_length=1, max_length=10000)
client_time: Optional[str] = Field(None, max_length=64)
class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None
is_locked: Optional[int] = None
class ThreadUpdate(BaseModel):
titel: Optional[str] = None
text: Optional[str] = None
titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = Field(None, max_length=10000)
thread_lat: Optional[float] = None
thread_lon: Optional[float] = None
thread_ort: Optional[str] = None
thread_ort: Optional[str] = Field(None, max_length=300)
class PostUpdate(BaseModel):
text: str
text: str = Field(..., min_length=1, max_length=10000)
class LikeBody(BaseModel):
target_type: str # 'thread' | 'post'
target_type: str = Field(..., max_length=20) # 'thread' | 'post'
target_id: int
class ReportBody(BaseModel):
target_type: str
target_type: str = Field(..., max_length=20)
target_id: int
grund: str
grund: str = Field(..., min_length=3, max_length=1000)
class LocationBody(BaseModel):
lat: Optional[float] = None

View file

@ -1,37 +1,28 @@
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
import json
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from database import db
from auth import get_current_user
from math_utils import haversine_m
logger = logging.getLogger(__name__)
router = APIRouter()
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
class GassiZeitCreate(BaseModel):
dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str # "17:00"
ort_name: Optional[str] = None
dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str = Field(..., max_length=20) # "17:00"
ort_name: Optional[str] = Field(None, max_length=300)
lat: Optional[float] = None
lon: Optional[float] = None
radius_m: int = 500
notiz: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000)
class GassiZeitUpdate(BaseModel):
@ -83,7 +74,7 @@ async def list_gassi_zeiten(
# Distanz-Filter
if lat is not None and lon is not None and d.get("lat") and d.get("lon"):
dist = _haversine(lat, lon, d["lat"], d["lon"])
dist = haversine_m(lat, lon, d["lat"], d["lon"])
if not nur_eigene and dist > radius:
continue
d["distance_m"] = int(dist)

View file

@ -3,7 +3,7 @@
import os, uuid
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
@ -22,59 +22,59 @@ TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie
# Schemas
# ------------------------------------------------------------------
class HealthCreate(BaseModel):
typ: str
bezeichnung: Optional[str] = None
datum: str
naechstes: Optional[str] = None
notiz: Optional[str] = None
typ: str = Field(..., max_length=50)
bezeichnung: Optional[str] = Field(None, max_length=200)
datum: str = Field(..., max_length=32)
naechstes: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=5000)
# Gewicht
wert: Optional[float] = None
einheit: Optional[str] = "kg"
einheit: Optional[str] = Field("kg", max_length=20)
# Impfung
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
charge_nr: Optional[str] = Field(None, max_length=100)
tierarzt_name: Optional[str] = Field(None, max_length=200)
# Tierarztbesuch
kosten: Optional[float] = None
diagnose: Optional[str] = None
diagnose: Optional[str] = Field(None, max_length=2000)
# Medikament
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
dosierung: Optional[str] = Field(None, max_length=200)
haeufigkeit: Optional[str] = Field(None, max_length=200)
aktiv: Optional[int] = 1
bis_datum: Optional[str] = None
bis_datum: Optional[str] = Field(None, max_length=32)
# Allergie
schweregrad: Optional[str] = None # leicht | mittel | schwer
reaktion: Optional[str] = None
schweregrad: Optional[str] = Field(None, max_length=50) # leicht | mittel | schwer
reaktion: Optional[str] = Field(None, max_length=1000)
erinnerung: Optional[int] = 1
intervall_tage: Optional[int] = None # Wiederkehrend alle X Tage
# Tierarzt-Verknüpfung
tierarzt_id: Optional[int] = None
# Züchter
deckdatum: Optional[str] = None
wurftermin: Optional[str] = None
deckdatum: Optional[str] = Field(None, max_length=32)
wurftermin: Optional[str] = Field(None, max_length=32)
class HealthUpdate(BaseModel):
bezeichnung: Optional[str] = None
datum: Optional[str] = None
naechstes: Optional[str] = None
notiz: Optional[str] = None
bezeichnung: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = Field(None, max_length=32)
naechstes: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=5000)
wert: Optional[float] = None
einheit: Optional[str] = None
charge_nr: Optional[str] = None
tierarzt_name: Optional[str] = None
einheit: Optional[str] = Field(None, max_length=20)
charge_nr: Optional[str] = Field(None, max_length=100)
tierarzt_name: Optional[str] = Field(None, max_length=200)
kosten: Optional[float] = None
diagnose: Optional[str] = None
dosierung: Optional[str] = None
haeufigkeit: Optional[str] = None
diagnose: Optional[str] = Field(None, max_length=2000)
dosierung: Optional[str] = Field(None, max_length=200)
haeufigkeit: Optional[str] = Field(None, max_length=200)
aktiv: Optional[int] = None
bis_datum: Optional[str] = None
schweregrad: Optional[str] = None
reaktion: Optional[str] = None
bis_datum: Optional[str] = Field(None, max_length=32)
schweregrad: Optional[str] = Field(None, max_length=50)
reaktion: Optional[str] = Field(None, max_length=1000)
erinnerung: Optional[int] = None
intervall_tage: Optional[int] = None
tierarzt_id: Optional[int] = None
deckdatum: Optional[str] = None
wurftermin: Optional[str] = None
deckdatum: Optional[str] = Field(None, max_length=32)
wurftermin: Optional[str] = Field(None, max_length=32)
# ------------------------------------------------------------------
@ -390,7 +390,7 @@ async def list_gewicht(dog_id: int, user=Depends(get_current_user)):
# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
# ------------------------------------------------------------------
class SymptomCheckRequest(BaseModel):
symptoms: str
symptoms: str = Field(..., min_length=3, max_length=5000)
@router.post("/{dog_id}/health/symptom-check")
@ -576,20 +576,20 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
# ==================================================================
class InsuranceCreate(BaseModel):
anbieter: str
police_nr: Optional[str] = None
anbieter: str = Field(..., min_length=1, max_length=200)
police_nr: Optional[str] = Field(None, max_length=100)
jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None
ablaufdatum: Optional[str] = None
notizen: Optional[str] = None
kontakt: Optional[str] = Field(None, max_length=500)
ablaufdatum: Optional[str] = Field(None, max_length=32)
notizen: Optional[str] = Field(None, max_length=5000)
class InsuranceUpdate(BaseModel):
anbieter: Optional[str] = None
police_nr: Optional[str] = None
anbieter: Optional[str] = Field(None, max_length=200)
police_nr: Optional[str] = Field(None, max_length=100)
jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None
ablaufdatum: Optional[str] = None
notizen: Optional[str] = None
kontakt: Optional[str] = Field(None, max_length=500)
ablaufdatum: Optional[str] = Field(None, max_length=32)
notizen: Optional[str] = Field(None, max_length=5000)
@router.get("/{dog_id}/insurance")
@ -674,12 +674,12 @@ TRIGGER_LABELS = {
class BehaviorCreate(BaseModel):
datum: str
uhrzeit: Optional[str] = None
kategorie: str
intensitaet: int = 3
trigger: Optional[str] = None
notiz: Optional[str] = None
datum: str = Field(..., max_length=32)
uhrzeit: Optional[str] = Field(None, max_length=20)
kategorie: str = Field(..., max_length=50)
intensitaet: int = 3
trigger: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=5000)
@router.get("/{dog_id}/behavior")

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hilfe / FAQ Routes"""
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user_optional, require_admin
@ -31,17 +31,17 @@ def _load_active_help_articles() -> list[dict]:
# Schemas
# ------------------------------------------------------------------
class ArticleCreate(BaseModel):
kategorie: str
frage: str
antwort: str
sort_order: int = 0
aktiv: int = 1
kategorie: str = Field(..., max_length=100)
frage: str = Field(..., min_length=3, max_length=500)
antwort: str = Field(..., min_length=3, max_length=10000)
sort_order: int = 0
aktiv: int = 1
class ArticleUpdate(BaseModel):
kategorie: Optional[str] = None
frage: Optional[str] = None
antwort: Optional[str] = None
kategorie: Optional[str] = Field(None, max_length=100)
frage: Optional[str] = Field(None, max_length=500)
antwort: Optional[str] = Field(None, max_length=10000)
sort_order: Optional[int] = None
aktiv: Optional[int] = None

View file

@ -6,7 +6,7 @@ from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from database import db
from auth import require_admin
import mailer
@ -19,30 +19,30 @@ logger = logging.getLogger(__name__)
# Schemas
# ------------------------------------------------------------------
class InvoiceItem(BaseModel):
description: str
quantity: float = 1.0
description: str = Field(..., max_length=500)
quantity: float = 1.0
unit_price: float
class InvoiceCreate(BaseModel):
user_id: Optional[int] = None
recipient_name: str
recipient_email: str
recipient_address: Optional[str] = None
recipient_name: str = Field(..., max_length=200)
recipient_email: str = Field(..., max_length=254)
recipient_address: Optional[str] = Field(None, max_length=500)
items: List[InvoiceItem]
discount_pct: Optional[float] = 0.0
service_period: Optional[str] = None
notes: Optional[str] = None
service_period: Optional[str] = Field(None, max_length=200)
notes: Optional[str] = Field(None, max_length=5000)
class PayBody(BaseModel):
paid_at: str
paid_at: str = Field(..., max_length=32)
paid_amount: float
notes: Optional[str] = None
notes: Optional[str] = Field(None, max_length=2000)
class CancelBody(BaseModel):
reason: str
reason: str = Field(..., min_length=3, max_length=1000)
# ------------------------------------------------------------------

View file

@ -1,6 +1,6 @@
"""BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
import ki as ki_module
from auth import get_current_user
@ -11,9 +11,9 @@ router = APIRouter()
class TrainingRequest(BaseModel):
problem: str
rasse: Optional[str] = None
alter: Optional[str] = None
problem: str = Field(..., min_length=10, max_length=1000)
rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = Field(None, max_length=50)
@router.post("/training")
@ -23,8 +23,6 @@ async def ki_training(req: TrainingRequest, request: Request,
rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
if not req.problem or len(req.problem.strip()) < 10:
raise HTTPException(400, "Bitte beschreibe das Problem genauer.")
if len(req.problem) > 1000:
raise HTTPException(400, "Beschreibung zu lang (max. 1000 Zeichen).")
rasse = req.rasse or "unbekannt"
alter = req.alter or "unbekannt"
@ -69,10 +67,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
# POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------
class TierarztRequest(BaseModel):
symptom: str
symptom: str = Field(..., min_length=5, max_length=1000)
dog_id: Optional[int] = None
dog_name: Optional[str] = None
rasse: Optional[str] = None
dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
@router.post("/tierarzt")
@ -81,8 +79,6 @@ async def ki_tierarzt(req: TierarztRequest, request: Request,
"""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:
@ -173,10 +169,10 @@ def _log_rasse_request(user_id: int):
class BirthdayRequest(BaseModel):
dog_id: int
name: str
rasse: Optional[str] = None
name: str = Field(..., max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[int] = None
mode: str = "tomorrow" # "tomorrow" | "today"
mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today"
@router.post("/geburtstag")
async def ki_geburtstag(req: BirthdayRequest, request: Request,
@ -368,12 +364,12 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
# ------------------------------------------------------------------
class AbschiedRequest(BaseModel):
dog_id: int
name: str
rasse: Optional[str] = None
name: str = Field(..., max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
km_total: Optional[float] = None
diary_count: Optional[int] = None
gemeinsam_tage: Optional[int] = None
last_entry_titel: Optional[str] = None
last_entry_titel: Optional[str] = Field(None, max_length=200)
@router.post("/abschied")
async def ki_abschied(req: AbschiedRequest, request: Request,

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Knigge Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
@ -13,12 +13,12 @@ router = APIRouter()
# Schemas
# ------------------------------------------------------------------
class VoteRequest(BaseModel):
szenario_id: str
answer: str
szenario_id: str = Field(..., max_length=100)
answer: str = Field(..., max_length=100)
class KiRatRequest(BaseModel):
situation: str
situation: str = Field(..., min_length=3, max_length=2000)
# ------------------------------------------------------------------

View file

@ -1,7 +1,7 @@
"""BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date, timedelta
@ -78,47 +78,47 @@ def _calc_meilensteine(deckdatum_str: str) -> list:
# Schemas
# ------------------------------------------------------------------
class LaeufiCreate(BaseModel):
beginn: str
ende: Optional[str] = None
notiz: Optional[str] = None
beginn: str = Field(..., max_length=32)
ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
class LaeufiUpdate(BaseModel):
beginn: Optional[str] = None
ende: Optional[str] = None
notiz: Optional[str] = None
beginn: Optional[str] = Field(None, max_length=32)
ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
class ProgestCreate(BaseModel):
datum: str
datum: str = Field(..., max_length=32)
wert: Optional[float] = None
einheit: str = "ng/ml"
labor: Optional[str] = None
notiz: Optional[str] = None
einheit: str = Field("ng/ml", max_length=20)
labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=2000)
class ProgestUpdate(BaseModel):
datum: Optional[str] = None
datum: Optional[str] = Field(None, max_length=32)
wert: Optional[float] = None
einheit: Optional[str] = None
labor: Optional[str] = None
notiz: Optional[str] = None
einheit: Optional[str] = Field(None, max_length=20)
labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = Field(None, max_length=2000)
class DeckCreate(BaseModel):
deckdatum: str
deckdatum: str = Field(..., max_length=32)
laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None
ruede_name: Optional[str] = None
deckart: str = "natuerlich"
ruede_name: Optional[str] = Field(None, max_length=200)
deckart: str = Field("natuerlich", max_length=50)
traechtig: int = 0
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
class DeckUpdate(BaseModel):
deckdatum: Optional[str] = None
deckdatum: Optional[str] = Field(None, max_length=32)
ruede_id: Optional[int] = None
ruede_name: Optional[str] = None
deckart: Optional[str] = None
ruede_name: Optional[str] = Field(None, max_length=200)
deckart: Optional[str] = Field(None, max_length=50)
traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = None
notiz: Optional[str] = None
ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
# ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
@ -27,68 +27,68 @@ def _require_breeder(user=Depends(get_current_user)):
# Schemas
# ------------------------------------------------------------------
class LitterCreate(BaseModel):
wurf_rang: Optional[str] = None # A, B, C …
wurf_name: Optional[str] = None # z.B. "Vatertags-Wurf"
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
wurf_rang: Optional[str] = Field(None, max_length=10) # A, B, C …
wurf_name: Optional[str] = Field(None, max_length=200) # z.B. "Vatertags-Wurf"
vater_name: Optional[str] = Field(None, max_length=200)
mutter_name: Optional[str] = Field(None, max_length=200)
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
geburt_datum: Optional[str] = None
erwartetes_datum: Optional[str] = None
geburt_datum: Optional[str] = Field(None, max_length=32)
erwartetes_datum: Optional[str] = Field(None, max_length=32)
welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = None
gesundheitstests: Optional[str] = None
preis_spanne: Optional[str] = None
status: str = "geplant"
beschreibung: Optional[str] = Field(None, max_length=10000)
gesundheitstests: Optional[str] = Field(None, max_length=5000)
preis_spanne: Optional[str] = Field(None, max_length=100)
status: str = Field("geplant", max_length=30)
sichtbar: int = 0
sichtbar_bis: Optional[str] = None
sichtbar_bis: Optional[str] = Field(None, max_length=32)
class LitterUpdate(BaseModel):
wurf_rang: Optional[str] = None
wurf_name: Optional[str] = None
vater_name: Optional[str] = None
mutter_name: Optional[str] = None
wurf_rang: Optional[str] = Field(None, max_length=10)
wurf_name: Optional[str] = Field(None, max_length=200)
vater_name: Optional[str] = Field(None, max_length=200)
mutter_name: Optional[str] = Field(None, max_length=200)
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
geburt_datum: Optional[str] = None
erwartetes_datum: Optional[str] = None
geburt_datum: Optional[str] = Field(None, max_length=32)
erwartetes_datum: Optional[str] = Field(None, max_length=32)
welpen_gesamt: Optional[int] = None
welpen_verfuegbar: Optional[int] = None
beschreibung: Optional[str] = None
gesundheitstests: Optional[str] = None
preis_spanne: Optional[str] = None
status: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=10000)
gesundheitstests: Optional[str] = Field(None, max_length=5000)
preis_spanne: Optional[str] = Field(None, max_length=100)
status: Optional[str] = Field(None, max_length=30)
sichtbar: Optional[int] = None
sichtbar_bis: Optional[str] = None
sichtbar_bis: Optional[str] = Field(None, max_length=32)
class PuppyCreate(BaseModel):
name: Optional[str] = None
geschlecht: Optional[str] = None # maennlich|weiblich
farbe: Optional[str] = None
chip_nr: Optional[str] = None
name: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = Field(None, max_length=20) # maennlich|weiblich
farbe: Optional[str] = Field(None, max_length=100)
chip_nr: Optional[str] = Field(None, max_length=50)
geburtsgewicht: Optional[float] = None # Gramm
status: str = "verfuegbar" # verfuegbar|reserviert|abgegeben
status: str = Field("verfuegbar", max_length=30) # verfuegbar|reserviert|abgegeben
status_sichtbar: int = 1
notiz: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000)
class PuppyUpdate(BaseModel):
name: Optional[str] = None
geschlecht: Optional[str] = None
farbe: Optional[str] = None
chip_nr: Optional[str] = None
name: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = Field(None, max_length=20)
farbe: Optional[str] = Field(None, max_length=100)
chip_nr: Optional[str] = Field(None, max_length=50)
geburtsgewicht: Optional[float] = None
status: Optional[str] = None
status: Optional[str] = Field(None, max_length=30)
status_sichtbar: Optional[int] = None
notiz: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=2000)
class WeightEntry(BaseModel):
gewicht_g: float
gemessen_am: str # YYYY-MM-DD
gemessen_am: str = Field(..., max_length=32) # YYYY-MM-DD
# ------------------------------------------------------------------
@ -663,15 +663,15 @@ async def generate_contract(
# Warteliste
# ------------------------------------------------------------------
class WaitlistEntry(BaseModel):
name: str
email: Optional[str] = None
telefon: Optional[str] = None
nachricht: Optional[str] = None
wunsch_geschlecht: str = "egal"
wunsch_farbe: Optional[str] = None
name: str = Field(..., min_length=1, max_length=200)
email: Optional[str] = Field(None, max_length=254)
telefon: Optional[str] = Field(None, max_length=30)
nachricht: Optional[str] = Field(None, max_length=5000)
wunsch_geschlecht: str = Field("egal", max_length=20)
wunsch_farbe: Optional[str] = Field(None, max_length=100)
prioritaet: int = 0
status: str = "anfrage"
notiz: Optional[str] = None
status: str = Field("anfrage", max_length=30)
notiz: Optional[str] = Field(None, max_length=2000)
class WaitlistUpdate(BaseModel):

View file

@ -1,44 +1,32 @@
"""BAN YARO — Verlorener Hund Routes"""
import os, uuid, math
import os, uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from timeutils import safe_client_time
from routes.push import send_push_to_all
from media_utils import convert_media
from math_utils import haversine_m
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Haversine-Distanz in Metern
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
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))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LostDogCreate(BaseModel):
name: str
rasse: Optional[str] = None
beschreibung: str
name: str = Field(..., min_length=1, max_length=80)
rasse: Optional[str] = Field(None, max_length=80)
beschreibung: str = Field(..., min_length=3, max_length=5000)
lat: float
lon: float
dog_id: Optional[int] = None
client_time: Optional[str] = None
client_time: Optional[str] = Field(None, max_length=64)
# ------------------------------------------------------------------
@ -60,7 +48,7 @@ async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None,
for r in rows:
entry = dict(r)
if lat is not None and lon is not None:
dist = _haversine(lat, lon, entry["lat"], entry["lon"])
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
if dist > radius_km * 1000:
continue
entry["distanz_m"] = round(dist)

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from database import db
@ -207,31 +207,31 @@ class HundDesMonatsVoteRequest(BaseModel):
dog_id: int
class MovieCreate(BaseModel):
id: str
titel: str
originaltitel: Optional[str] = None
id: str = Field(..., max_length=100)
titel: str = Field(..., min_length=1, max_length=200)
originaltitel: Optional[str] = Field(None, max_length=200)
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 = "🐾"
genre: Optional[str] = Field(None, max_length=100)
typ: str = Field("film", max_length=30)
hund_rasse: Optional[str] = Field(None, max_length=200)
stirbt_der_hund: bool = False
beschreibung: Optional[str] = Field(None, max_length=5000)
bild_emoji: str = Field("🐾", max_length=10)
imdb_rating: Optional[float] = None
streaming: Optional[str] = None
streaming: Optional[str] = Field(None, max_length=500)
class MovieUpdate(BaseModel):
titel: Optional[str] = None
originaltitel: Optional[str] = None
titel: Optional[str] = Field(None, max_length=200)
originaltitel: Optional[str] = Field(None, max_length=200)
jahr: Optional[int] = None
genre: Optional[str] = None
typ: Optional[str] = None
hund_rasse: Optional[str] = None
genre: Optional[str] = Field(None, max_length=100)
typ: Optional[str] = Field(None, max_length=30)
hund_rasse: Optional[str] = Field(None, max_length=200)
stirbt_der_hund: Optional[bool] = None
beschreibung: Optional[str] = None
bild_emoji: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=5000)
bild_emoji: Optional[str] = Field(None, max_length=10)
imdb_rating: Optional[float] = None
streaming: Optional[str] = None
streaming: Optional[str] = Field(None, max_length=500)
# ------------------------------------------------------------------

View file

@ -4,7 +4,7 @@ import json
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, Any, List
from database import db
from auth import get_current_user
@ -18,18 +18,18 @@ logger = logging.getLogger(__name__)
# Schemas
# ------------------------------------------------------------------
class NoteCreate(BaseModel):
text: str
meta_json: Optional[Any] = None
location_name: Optional[str] = None
parent_label: Optional[str] = None
client_time: Optional[str] = None
text: str = Field(..., min_length=1, max_length=5000)
meta_json: Optional[Any] = None
location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = Field(None, max_length=200)
client_time: Optional[str] = Field(None, max_length=64)
class NoteUpdate(BaseModel):
text: Optional[str] = None
text: Optional[str] = Field(None, max_length=5000)
meta_json: Optional[Any] = None
location_name: Optional[str] = None
parent_label: Optional[str] = None
location_name: Optional[str] = Field(None, max_length=300)
parent_label: Optional[str] = Field(None, max_length=200)
# ------------------------------------------------------------------

View file

@ -9,7 +9,7 @@ import httpx
import logging
from typing import Optional
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from database import db
from auth import get_current_user, get_current_user_optional as get_optional_user
@ -110,7 +110,7 @@ async def _fetch_overpass(query):
except Exception as exc:
logger.warning(f"Overpass Verbindungsfehler {url}: {exc}")
break # nächste URL
raise Exception("Alle Overpass-Instanzen fehlgeschlagen")
raise HTTPException(503, "Kartendaten gerade nicht verfügbar — bitte später nochmal.")
def _stale_tiles(poi_type, tiles):
stale = []
@ -273,11 +273,11 @@ async def get_pois(
# POST /user-poi — Community-Marker setzen
# ------------------------------------------------------------------
class UserPoiIn(BaseModel):
type: str
type: str = Field(..., max_length=200)
lat: float
lon: float
name: Optional[str] = None
notiz: Optional[str] = None
name: Optional[str] = Field(None, max_length=300)
notiz: Optional[str] = Field(None, max_length=2000)
ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park',
@ -331,8 +331,8 @@ async def delete_user_poi(poi_id: int, user = Depends(get_current_user)):
# POST /report — Marker als ungültig melden
# ------------------------------------------------------------------
class ReportIn(BaseModel):
type: str
grund: str
type: str = Field(..., max_length=100)
grund: str = Field(..., max_length=200)
osm_id: Optional[int] = None
user_poi_id: Optional[int] = None
@ -388,9 +388,9 @@ async def analyze_region(
# POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor
# ------------------------------------------------------------------
class PoiEditCreate(BaseModel):
poi_name: str
field: str = 'opening_hours'
new_value: str
poi_name: str = Field(..., max_length=300)
field: str = Field('opening_hours', max_length=50)
new_value: str = Field(..., max_length=1000)
@router.post('/pois/{osm_id}/edit', status_code=201)

View file

@ -13,7 +13,7 @@ from typing import List, Optional
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from auth import require_admin
from database import db
@ -135,26 +135,26 @@ def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html:
# ------------------------------------------------------------------
class TemplateIn(BaseModel):
key: str
label: str
subject: str
body: str
from_account: str = "partner"
key: str = Field(..., max_length=100)
label: str = Field(..., max_length=200)
subject: str = Field(..., max_length=500)
body: str = Field(..., max_length=50000)
from_account: str = Field("partner", max_length=50)
class TemplateUpdate(BaseModel):
label: str
subject: str
body: str
from_account: str = "partner"
label: str = Field(..., max_length=200)
subject: str = Field(..., max_length=500)
body: str = Field(..., max_length=50000)
from_account: str = Field("partner", max_length=50)
class SendRequest(BaseModel):
to: List[str]
subject: str
body: str
from_account: str = "partner"
template_id: Optional[int] = None
subject: str = Field(..., max_length=500)
body: str = Field(..., max_length=50000)
from_account: str = Field("partner", max_length=50)
template_id: Optional[int] = None
# ------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from pydantic import BaseModel, Field
from database import db
from auth import require_admin, get_current_user
@ -10,8 +10,8 @@ router = APIRouter()
class PartnerCodeCreate(BaseModel):
code: str
label: str
code: str = Field(..., min_length=1, max_length=50)
label: str = Field(..., min_length=1, max_length=200)
grants_founder: int = 1
max_uses: Optional[int] = None
@ -93,21 +93,34 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm
if not target:
raise HTTPException(404, "User nicht gefunden.")
if updates.get("is_founder") == 1 and not target["founder_number"]:
# Neue Gründer-Nummer zuweisen
total = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if total >= FOUNDER_MAX:
# Atomare Gründer-Vergabe — kein TOCTOU mehr zwischen COUNT und UPDATE.
# Sub-Query wird gegen Snapshot vor dem UPDATE evaluiert (SQL-Spec).
cur = conn.execute(
"""UPDATE users
SET is_founder = 1,
founder_number = (
SELECT IFNULL(MAX(founder_number), 0) + 1
FROM users WHERE is_founder = 1
)
WHERE id = ?
AND (SELECT COUNT(*) FROM users WHERE is_founder = 1) < ?
AND (is_founder IS NULL OR is_founder = 0)""",
(user_id, FOUNDER_MAX)
)
if cur.rowcount == 0:
raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.")
updates["founder_number"] = total + 1
# is_founder + founder_number sind atomar gesetzt — aus updates entfernen
updates.pop("is_founder", None)
updates.pop("founder_number", None)
elif updates.get("is_founder") == 0:
# Gründer-Status entfernen → founder_number ebenfalls leeren
updates["founder_number"] = None
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?",
(*updates.values(), user_id)
)
if updates: # nach atomarer Founder-Vergabe ggf. leer
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE users SET {set_clause} WHERE id=?",
(*updates.values(), user_id)
)
row = conn.execute(
"SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?",
(user_id,)

View file

@ -5,7 +5,7 @@ import secrets
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
@ -17,25 +17,25 @@ router = APIRouter()
# Schemas
# ------------------------------------------------------------------
class PassportMeta(BaseModel):
blutgruppe: Optional[str] = None
allergien: Optional[str] = None
besonderheiten: Optional[str] = None
blutgruppe: Optional[str] = Field(None, max_length=50)
allergien: Optional[str] = Field(None, max_length=2000)
besonderheiten: Optional[str] = Field(None, max_length=2000)
class VaccinationCreate(BaseModel):
krankheit: str
datum: str
naechste: Optional[str] = None
tierarzt: Optional[str] = None
charge_nr: Optional[str] = None
krankheit: str = Field(..., max_length=200)
datum: str = Field(..., max_length=32)
naechste: Optional[str] = Field(None, max_length=32)
tierarzt: Optional[str] = Field(None, max_length=200)
charge_nr: Optional[str] = Field(None, max_length=100)
class MedicationCreate(BaseModel):
name: str
dosierung: Optional[str] = None
von: Optional[str] = None
bis: Optional[str] = None
notiz: Optional[str] = None
name: str = Field(..., max_length=200)
dosierung: Optional[str] = Field(None, max_length=200)
von: Optional[str] = Field(None, max_length=32)
bis: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = Field(None, max_length=2000)
# ------------------------------------------------------------------

View file

@ -1,50 +1,40 @@
"""BAN YARO — Hundefreundliche Orte"""
import math
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from auth import get_current_user, require_owner
from math_utils import haversine_m
router = APIRouter()
TYPEN = {'restaurant', 'shop', 'freilauf', 'kotbeutel', 'tierarzt', 'hundesalon', 'hundeschule'}
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
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))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class PlaceCreate(BaseModel):
name: str
typ: str
name: str = Field(..., min_length=1, max_length=200)
typ: str = Field(..., max_length=50)
lat: float
lon: float
adresse: Optional[str] = None
website: Optional[str] = None
telefon: Optional[str] = None
adresse: Optional[str] = Field(None, max_length=300)
website: Optional[str] = Field(None, max_length=500)
telefon: Optional[str] = Field(None, max_length=30)
hund_rein: Optional[bool] = None
leine_pflicht: Optional[bool] = None
wasser_fuer_hunde: Optional[bool] = None
class PlaceUpdate(BaseModel):
name: Optional[str] = None
typ: Optional[str] = None
name: Optional[str] = Field(None, max_length=200)
typ: Optional[str] = Field(None, max_length=50)
lat: Optional[float]= None
lon: Optional[float]= None
adresse: Optional[str] = None
website: Optional[str] = None
telefon: Optional[str] = None
adresse: Optional[str] = Field(None, max_length=300)
website: Optional[str] = Field(None, max_length=500)
telefon: Optional[str] = Field(None, max_length=30)
hund_rein: Optional[bool] = None
leine_pflicht: Optional[bool] = None
wasser_fuer_hunde: Optional[bool] = None
@ -79,7 +69,7 @@ async def list_places(
result = [_row_to_dict(r) for r in rows]
if lat is not None and lon is not None:
result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius]
result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius]
return result
@ -131,11 +121,10 @@ async def get_place(place_id: int):
@router.patch("/{place_id}")
async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone()
if not row:
raise HTTPException(404, "Ort nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
row = require_owner(
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
)
updates = data.model_dump(exclude_none=True)
if not updates:
@ -160,9 +149,8 @@ async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_curren
@router.delete("/{place_id}", status_code=204)
async def delete_place(place_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone()
if not row:
raise HTTPException(404, "Ort nicht gefunden.")
if row['user_id'] != user['id']:
raise HTTPException(403, "Nicht berechtigt.")
require_owner(
conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
)
conn.execute("DELETE FROM places WHERE id = ?", (place_id,))

View file

@ -1,30 +1,17 @@
"""BAN YARO — Playdate-Matching"""
import math
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from math_utils import haversine_km
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:
@ -53,18 +40,18 @@ class ListingUpsert(BaseModel):
dog_id: int
lat: float
lon: float
ort_name: Optional[str] = None
ort_name: Optional[str] = Field(None, max_length=300)
radius_km: int = 10
beschreibung: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=2000)
class RequestCreate(BaseModel):
to_dog_id: int
nachricht: Optional[str] = None
nachricht: Optional[str] = Field(None, max_length=2000)
class RequestPatch(BaseModel):
status: str # accepted | declined
status: str = Field(..., max_length=30) # accepted | declined
# ------------------------------------------------------------------
@ -107,7 +94,7 @@ async def nearby(lat: float, lon: float, radius: int = 10,
result = []
for r in rows:
dist = _haversine(lat, lon, r["lat"], r["lon"])
dist = haversine_km(lat, lon, r["lat"], r["lon"])
if dist <= radius:
result.append({
"listing_id": r["listing_id"],

View file

@ -1,45 +1,33 @@
"""BAN YARO — Giftköder-Alarm Routes"""
import os, uuid, math
import os, uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_nearby
from media_utils import convert_media
from ratelimit import check as rl_check
from math_utils import haversine_m
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Haversine-Distanz in Metern
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
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))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class PoisonCreate(BaseModel):
lat: float
lon: float
beschreibung: Optional[str] = None
typ: str = "unbekannt"
beschreibung: Optional[str] = Field(None, max_length=2000)
typ: str = Field("unbekannt", max_length=50)
class PoisonResolve(BaseModel):
grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
grund: str = Field("beseitigt", max_length=50) # beseitigt | fehlerhaft | anderes
# ------------------------------------------------------------------
@ -62,7 +50,7 @@ async def list_poison(lat: float, lon: float, radius: int = 5000):
results = []
for r in rows:
entry = dict(r)
dist = _haversine(lat, lon, entry["lat"], entry["lon"])
dist = haversine_m(lat, lon, entry["lat"], entry["lon"])
if dist <= radius:
entry["distanz_m"] = round(dist)
results.append(entry)

View file

@ -7,7 +7,7 @@ import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from auth import get_current_user
from database import db
@ -20,17 +20,17 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"}
class ProfileUpdate(BaseModel):
real_name: Optional[str] = None
bio: Optional[str] = None
wohnort: Optional[str] = None
erfahrung: Optional[str] = None
social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None
real_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = Field(None, max_length=300)
wohnort: Optional[str] = Field(None, max_length=60)
erfahrung: Optional[str] = Field(None, max_length=30)
social_link: Optional[str] = Field(None, max_length=120)
profil_sichtbarkeit: Optional[str] = Field(None, max_length=30)
notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = None
billing_address: Optional[str] = None
geburtstag: Optional[str] = None
preferred_theme: Optional[str] = Field(None, max_length=20)
billing_address: Optional[str] = Field(None, max_length=500)
geburtstag: Optional[str] = Field(None, max_length=10)
def _load_user(user_id: int) -> dict:
@ -61,12 +61,7 @@ async def update_profile(data: ProfileUpdate, user=Depends(get_current_user)):
raise HTTPException(400, f"profil_sichtbarkeit muss eines von {sorted(VALID_SICHTBARKEIT)} sein.")
if "preferred_theme" in fields and fields["preferred_theme"] not in ("system", "light", "dark"):
raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.")
if "bio" in fields and len(fields["bio"]) > 300:
raise HTTPException(400, "bio darf maximal 300 Zeichen lang sein.")
if "wohnort" in fields and len(fields["wohnort"]) > 60:
raise HTTPException(400, "wohnort darf maximal 60 Zeichen lang sein.")
if "social_link" in fields and len(fields["social_link"]) > 120:
raise HTTPException(400, "social_link darf maximal 120 Zeichen lang sein.")
# Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt.
if "geburtstag" in fields and fields["geburtstag"]:
if not re.fullmatch(r"\d{2}\.\d{2}", fields["geburtstag"]):
raise HTTPException(400, "geburtstag muss im Format TT.MM sein (z.B. 16.05).")

View file

@ -4,7 +4,7 @@ import os
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from pywebpush import webpush, WebPushException
@ -33,7 +33,7 @@ async def get_vapid_key():
# POST /api/push/subscribe — Subscription speichern
# ------------------------------------------------------------------
class PushSubscription(BaseModel):
endpoint: str
endpoint: str = Field(..., max_length=2000)
keys: dict # { p256dh, auth }
expirationTime: Optional[int] = None

View file

@ -1,7 +1,7 @@
"""BAN YARO — Bewertungssystem (Ratings)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
@ -23,10 +23,10 @@ TABLE_MAP = {
# Schemas
# ------------------------------------------------------------------
class RatingCreate(BaseModel):
target_type: str
target_type: str = Field(..., max_length=50)
target_id: int
stars: int
kommentar: Optional[str] = None
kommentar: Optional[str] = Field(None, max_length=5000)
# ------------------------------------------------------------------

View file

@ -49,12 +49,27 @@ async def list_recalls(q: str = ""):
# 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."""
"""Fragt die RASFF API ab und gibt eine Liste normalisierter Einträge zurück.
Hinweis: Die EU hat die API mehrfach umgezogen wenn der Endpoint
404 oder andere persistent fehler liefert, geben wir [] zurück und
loggen nur als Warning (nicht Error), damit das Error-Digest nicht
täglich spammt.
"""
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 httpx.HTTPStatusError as e:
if e.response.status_code in (404, 410, 503):
# API umgezogen oder temporär unten — Warning, kein Error
logger.warning(
f"RASFF API liefert {e.response.status_code} (Endpoint vermutlich umgezogen) — überspringe."
)
else:
logger.error(f"RASFF API-HTTP-Fehler: {e}")
return []
except Exception as e:
logger.error(f"RASFF API-Fehler: {e}")
return []

View file

@ -1,11 +1,11 @@
"""BAN YARO — Gassi-Routen"""
import datetime as _dt
import json, math, os, uuid
import json, os, uuid
import httpx
import polyline as _polyline
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from database import db
from auth import get_current_user, get_current_user_optional
@ -13,6 +13,7 @@ from routes.achievements import update_streak, check_and_award
from timeutils import safe_client_time
from media_utils import convert_media
from routes.push import send_push_to_user
from math_utils import haversine_km, haversine_m
router = APIRouter()
@ -27,16 +28,6 @@ def _check_speed(distanz_km, dauer_min) -> bool:
return (distanz_km / (dauer_min / 60)) <= _MAX_AVG_KMH
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
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))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
@ -46,29 +37,29 @@ class GPSPoint(BaseModel):
alt: Optional[float] = None
class RouteCreate(BaseModel):
name: str
beschreibung: Optional[str] = None
name: str = Field(..., min_length=1, max_length=200)
beschreibung: Optional[str] = Field(None, max_length=5000)
gps_track: List[GPSPoint]
distanz_km: Optional[float] = None
dauer_min: Optional[int] = None
schwierigkeit: Optional[str] = "leicht" # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
schwierigkeit: Optional[str] = Field("leicht", max_length=30) # leicht | mittel | anspruchsvoll
untergrund: Optional[str] = Field(None, max_length=50) # wald | asphalt | wiese | mix
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = False
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = None
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50) # eingeschränkt | gut | sehr_gut | premium
client_time: Optional[str] = Field(None, max_length=64)
dog_ids: Optional[List[int]] = None # Welche Hunde mitgegangen sind
class RouteUpdate(BaseModel):
name: Optional[str] = None
beschreibung: Optional[str] = None
schwierigkeit: Optional[str] = None
untergrund: Optional[str] = None
name: Optional[str] = Field(None, max_length=200)
beschreibung: Optional[str] = Field(None, max_length=5000)
schwierigkeit: Optional[str] = Field(None, max_length=30)
untergrund: Optional[str] = Field(None, max_length=50)
schatten: Optional[bool] = None
leine_empfohlen: Optional[bool] = None
is_public: Optional[bool] = None
hunde_tauglichkeit: Optional[str] = None
hunde_tauglichkeit: Optional[str] = Field(None, max_length=50)
class RouteDogs(BaseModel):
dog_ids: List[int]
@ -137,7 +128,7 @@ async def list_routes(
if lat is not None and lon is not None:
result = [
r for r in result
if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius
if r['start_lat'] and haversine_m(lat, lon, r['start_lat'], r['start_lon']) <= radius
]
user_id = user['id'] if user else None
@ -429,10 +420,7 @@ async def trim_route(route_id: int, data: RouteTrim, user=Depends(get_current_us
new_km = 0.0
for i in range(1, len(new_track)):
p1, p2 = new_track[i-1], new_track[i]
dlat = math.radians(p2['lat'] - p1['lat'])
dlon = math.radians(p2['lon'] - p1['lon'])
a = math.sin(dlat/2)**2 + math.cos(math.radians(p1['lat'])) * math.cos(math.radians(p2['lat'])) * math.sin(dlon/2)**2
new_km += 6371 * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
new_km += haversine_km(p1['lat'], p1['lon'], p2['lat'], p2['lon'])
new_km = round(new_km, 2)
# Dauer proportional schätzen (Original-Pace)
@ -565,7 +553,7 @@ async def add_route_photo(
# POST /api/routes/{id}/feedback — Feedback an Route-Ersteller
# ------------------------------------------------------------------
class RouteFeedback(BaseModel):
text: str
text: str = Field(..., min_length=5, max_length=2000)
@router.post("/{route_id}/feedback", status_code=201)
async def route_feedback(route_id: int, data: RouteFeedback, user=Depends(get_current_user)):

View file

@ -1,33 +1,23 @@
"""BAN YARO — Service-Angebote (Sitting & Walks Matching)"""
import math
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
from math_utils import haversine_km
router = APIRouter()
ALLOWED_TYPES = {'sitting', 'walks'}
def _haversine(lat1, lon1, lat2, lon2):
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))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ServiceCreate(BaseModel):
type: str
beschreibung: Optional[str] = None
type: str = Field(..., max_length=30)
beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: Optional[float] = None
lat: Optional[float] = None
lon: Optional[float] = None
@ -60,7 +50,7 @@ async def list_services(
for r in rows:
d = dict(r)
if lat is not None and lon is not None and d['lat'] and d['lon']:
dist = _haversine(lat, lon, d['lat'], d['lon'])
dist = haversine_km(lat, lon, d['lat'], d['lon'])
if dist > radius:
continue
d['distanz_km'] = round(dist, 1)

View file

@ -2,7 +2,7 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from database import db
from auth import get_current_user
@ -14,7 +14,7 @@ share_router = APIRouter()
class ShareInvite(BaseModel):
role: str = "editor" # viewer | editor
role: str = Field("editor", max_length=20) # viewer | editor
# ------------------------------------------------------------------

View file

@ -1,32 +1,23 @@
"""BAN YARO — Hundesitting"""
import json
import math
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from database import db
from auth import get_current_user
from math_utils import haversine_m
router = APIRouter()
SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'}
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class SitterCreate(BaseModel):
beschreibung: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: float = 0
max_hunde: int = 1
lat: Optional[float] = None
@ -35,7 +26,7 @@ class SitterCreate(BaseModel):
services: List[str] = []
class SitterUpdate(BaseModel):
beschreibung: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: Optional[float] = None
max_hunde: Optional[int] = None
lat: Optional[float] = None
@ -47,12 +38,12 @@ class SitterUpdate(BaseModel):
class RequestCreate(BaseModel):
sitter_id: int
dog_ids: List[int] = []
von: str # YYYY-MM-DD
bis: str
nachricht: Optional[str] = None
von: str = Field(..., max_length=32) # YYYY-MM-DD
bis: str = Field(..., max_length=32)
nachricht: Optional[str] = Field(None, max_length=2000)
class RequestUpdate(BaseModel):
status: str # angenommen | abgelehnt | abgebrochen
status: str = Field(..., max_length=30) # angenommen | abgelehnt | abgebrochen
# ------------------------------------------------------------------
@ -80,7 +71,7 @@ async def list_sitters(
if service and service not in d['services']:
continue
if lat is not None and lon is not None and d['lat'] and d['lon']:
dist = _haversine(lat, lon, d['lat'], d['lon'])
dist = haversine_m(lat, lon, d['lat'], d['lon'])
if dist > radius:
continue
d['distanz_m'] = round(dist)

View file

@ -1,7 +1,7 @@
"""BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from database import db
from auth import get_current_user
@ -11,7 +11,7 @@ router = APIRouter()
class AccessCreate(BaseModel):
dog_id: int
sitter_id: int
valid_until: str # 'YYYY-MM-DD'
valid_until: str = Field(..., max_length=32) # 'YYYY-MM-DD'
@router.post("", status_code=201)

View file

@ -9,7 +9,7 @@ import random
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from auth import get_current_user, require_social_media
from database import db
@ -849,24 +849,24 @@ Antworte NUR mit einem JSON-Objekt:
class GenerateRequest(BaseModel):
platform: str = "both"
format: str = "post"
topic: str
platform: str = Field("both", max_length=30)
format: str = Field("post", max_length=30)
topic: str = Field(..., min_length=2, max_length=500)
breed_id: Optional[int] = None
class EvaluateRequest(BaseModel):
platform: str = "instagram"
format: str = "post"
draft: str
platform: str = Field("instagram", max_length=30)
format: str = Field("post", max_length=30)
draft: str = Field(..., min_length=1, max_length=10000)
class StatusUpdate(BaseModel):
status: Optional[str] = None
scheduled_at: Optional[str] = None
published_at: Optional[str] = None
notes: Optional[str] = None
post_url: Optional[str] = None
status: Optional[str] = Field(None, max_length=50)
scheduled_at: Optional[str] = Field(None, max_length=64)
published_at: Optional[str] = Field(None, max_length=64)
notes: Optional[str] = Field(None, max_length=5000)
post_url: Optional[str] = Field(None, max_length=500)
def _used_topics(limit: int = 30) -> str:

View file

@ -2,7 +2,7 @@
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
from auth import get_current_user
@ -11,20 +11,20 @@ router = APIRouter()
class TierarztCreate(BaseModel):
name: str
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
telefon: Optional[str] = None
notfall_telefon: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
notizen: Optional[str] = None
name: str = Field(..., min_length=1, max_length=200)
strasse: Optional[str] = Field(None, max_length=300)
plz: Optional[str] = Field(None, max_length=20)
ort: Optional[str] = Field(None, max_length=200)
telefon: Optional[str] = Field(None, max_length=30)
notfall_telefon: Optional[str] = Field(None, max_length=30)
email: Optional[str] = Field(None, max_length=254)
website: Optional[str] = Field(None, max_length=500)
notizen: Optional[str] = Field(None, max_length=5000)
ist_notfallpraxis: bool = False
opening_hours: Optional[str] = None
opening_hours: Optional[str] = Field(None, max_length=500)
lat: Optional[float] = None
lon: Optional[float] = None
osm_id: Optional[str] = None
osm_id: Optional[str] = Field(None, max_length=100)
class BewertungCreate(BaseModel):
@ -32,25 +32,25 @@ class BewertungCreate(BaseModel):
wartezeit: Optional[int] = None
freundlichkeit: Optional[int] = None
kompetenz: Optional[int] = None
text: Optional[str] = None
text: Optional[str] = Field(None, max_length=5000)
class TierarztUpdate(BaseModel):
name: Optional[str] = None
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
telefon: Optional[str] = None
notfall_telefon: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
notizen: Optional[str] = None
name: Optional[str] = Field(None, max_length=200)
strasse: Optional[str] = Field(None, max_length=300)
plz: Optional[str] = Field(None, max_length=20)
ort: Optional[str] = Field(None, max_length=200)
telefon: Optional[str] = Field(None, max_length=30)
notfall_telefon: Optional[str] = Field(None, max_length=30)
email: Optional[str] = Field(None, max_length=254)
website: Optional[str] = Field(None, max_length=500)
notizen: Optional[str] = Field(None, max_length=5000)
ist_notfallpraxis: Optional[bool] = None
aktiv: Optional[bool] = None
opening_hours: Optional[str] = None
opening_hours: Optional[str] = Field(None, max_length=500)
lat: Optional[float] = None
lon: Optional[float] = None
osm_id: Optional[str] = None
osm_id: Optional[str] = Field(None, max_length=100)
def _fmt_opening_hours(raw: str | None) -> str | None:

View file

@ -1,7 +1,7 @@
"""BAN YARO — Übungs- & Trainingsfortschritt"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
import datetime
import ki
@ -61,9 +61,9 @@ async def get_exercises():
# Admin: Übung bearbeiten (beschreibung / schritte / tipp)
# ------------------------------------------------------------------
class ExerciseUpdate(BaseModel):
beschreibung: Optional[str] = None
schritte: Optional[str] = None # JSON-String: '["Schritt 1", ...]'
tipp: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=10000)
schritte: Optional[str] = Field(None, max_length=10000) # JSON-String: '["Schritt 1", ...]'
tipp: Optional[str] = Field(None, max_length=5000)
@router.put("/exercises/{exercise_id}")
async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(require_admin)):
@ -93,9 +93,9 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ
# Übungs-Status
# ------------------------------------------------------------------
class ProgressUpdate(BaseModel):
exercise_id: str
status: Optional[str] = None
dog_id: Optional[int] = None
exercise_id: str = Field(..., max_length=200)
status: Optional[str] = Field(None, max_length=50)
dog_id: Optional[int] = None
@router.get("/progress")
async def get_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
@ -137,9 +137,9 @@ async def upsert_progress(body: ProgressUpdate, user=Depends(get_current_user)):
# Trainingsplan-Checkboxen
# ------------------------------------------------------------------
class PlanProgress(BaseModel):
item_key: str
checked: bool
dog_id: Optional[int] = None
item_key: str = Field(..., max_length=200)
checked: bool
dog_id: Optional[int] = None
@router.get("/plan-progress")
async def get_plan_progress(dog_id: Optional[int] = None, user=Depends(get_current_user)):
@ -327,15 +327,15 @@ def _check_badges(conn, user_id: int, dog_name: str) -> list:
class SessionCreate(BaseModel):
dog_id: int
exercise_id: str
exercise_name: str
datum: Optional[str] = None
wiederholungen: int = 1
erfolgsquote: int = 50
hund_stimmung: Optional[str] = "aufmerksam"
exercise_id: str = Field(..., max_length=200)
exercise_name: str = Field(..., max_length=200)
datum: Optional[str] = Field(None, max_length=32)
wiederholungen: int = 1
erfolgsquote: int = 50
hund_stimmung: Optional[str] = Field("aufmerksam", max_length=50)
zufriedenheit: Optional[int] = 3
notiz: Optional[str] = None
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
notiz: Optional[str] = Field(None, max_length=2000)
tagebuch_eintrag: bool = False # ignoriert — Training hat eigenes Protokoll
@router.post("/sessions")

View file

@ -1,55 +1,43 @@
"""BAN YARO — Gassi-Treffen"""
import math, os, uuid
import os, uuid
import httpx
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, List
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
from math_utils import haversine_km, haversine_m
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
router = APIRouter()
def _haversine(lat1, lon1, lat2, lon2):
R = 6_371_000
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def _haversine_km(lat1, lon1, lat2, lon2):
return _haversine(lat1, lon1, lat2, lon2) / 1000
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class WalkCreate(BaseModel):
titel: str
datum: str # YYYY-MM-DD
uhrzeit: str # HH:MM
titel: str = Field(..., min_length=1, max_length=200)
datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: str = Field(..., max_length=20) # HH:MM
lat: float
lon: float
ort_name: Optional[str] = None
ort_name: Optional[str] = Field(None, max_length=300)
max_teilnehmer: int = 10
beschreibung: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=5000)
class WalkUpdate(BaseModel):
titel: Optional[str] = None
datum: Optional[str] = None
uhrzeit: Optional[str] = None
titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None
lon: Optional[float] = None
ort_name: Optional[str] = None
ort_name: Optional[str] = Field(None, max_length=300)
max_teilnehmer: Optional[int] = None
beschreibung: Optional[str] = None
beschreibung: Optional[str] = Field(None, max_length=5000)
class JoinRequest(BaseModel):
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
@ -58,7 +46,7 @@ class InviteRequest(BaseModel):
friend_id: int
class RsvpRequest(BaseModel):
status: str # 'yes' | 'maybe' | 'no'
status: str = Field(..., max_length=20) # 'yes' | 'maybe' | 'no'
# ------------------------------------------------------------------
@ -91,7 +79,7 @@ async def list_walks(
# Umkreis-Filter
if lat is not None and lon is not None:
result = [r for r in result if _haversine(lat, lon, r['lat'], r['lon']) <= radius]
result = [r for r in result if haversine_m(lat, lon, r['lat'], r['lon']) <= radius]
return result
@ -131,7 +119,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
"SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL",
).fetchall()
for p in places:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
km = haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"],
@ -142,7 +130,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
"SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall()
for p in osm:
km = _haversine_km(lat, lon, p["lat"], p["lon"])
km = haversine_km(lat, lon, p["lat"], p["lon"])
if km <= 2:
results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"],
@ -170,7 +158,7 @@ async def nearby_places(lat: float, lon: float, user=Depends(get_current_user)):
elon = el.get("lon") or el.get("center", {}).get("lon")
if elat is None or elon is None:
continue
km = _haversine_km(lat, lon, elat, elon)
km = haversine_km(lat, lon, elat, elon)
if km <= 1:
results.append({"name": name, "type": "osm",
"lat": elat, "lon": elon,

View file

@ -6,7 +6,7 @@ import time
import logging
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from pydantic import BaseModel, Field
from database import db
from auth import get_current_user, get_current_user_optional
from ratelimit import check as rl_check, block_ip
@ -36,9 +36,9 @@ async def honeypot(request: Request):
# Schemas
# ------------------------------------------------------------------
class BerichtCreate(BaseModel):
rasse: str
titel: str
text: str
rasse: str = Field(..., max_length=100)
titel: str = Field(..., min_length=3, max_length=200)
text: str = Field(..., min_length=10, max_length=10000)
# ------------------------------------------------------------------
@ -411,8 +411,8 @@ async def list_submissions(user=Depends(get_current_user)):
# PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen
# ------------------------------------------------------------------
class ReviewModel(BaseModel):
action: str # "approve" | "reject"
reject_reason: str = ""
action: str = Field(..., max_length=30) # "approve" | "reject"
reject_reason: str = Field("", max_length=2000)
@router.patch("/foto-submissions/{sub_id}")
@ -575,19 +575,19 @@ async def get_rasse_stats(slug: str, user=Depends(get_current_user_optional)):
# Schemas für Interesse und Züchter
# ------------------------------------------------------------------
class InteresseCreate(BaseModel):
typ: str # "hat" oder "will"
typ: str = Field(..., max_length=30) # "hat" oder "will"
class ZuchterCreate(BaseModel):
rasse_slug: str
name: str
zwingername: str = ""
ort: str = ""
plz: str = ""
bundesland: str = ""
rasse_slug: str = Field(..., max_length=100)
name: str = Field(..., min_length=1, max_length=200)
zwingername: str = Field("", max_length=200)
ort: str = Field("", max_length=200)
plz: str = Field("", max_length=20)
bundesland: str = Field("", max_length=100)
vdh_mitglied: int = 0
website: str = ""
telefon: str = ""
beschreibung: str = ""
website: str = Field("", max_length=500)
telefon: str = Field("", max_length=30)
beschreibung: str = Field("", max_length=10000)
# ------------------------------------------------------------------

View file

@ -2,7 +2,7 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional
from database import db
@ -134,108 +134,108 @@ def _ik_rating(ik: float) -> str:
# Pydantic-Schemas
# ------------------------------------------------------------------
class HundCreate(BaseModel):
name: str
rufname: Optional[str] = None
geschlecht: str # maennlich|weiblich
geburtsdatum: Optional[str] = None
sterbedatum: Optional[str] = None
chip_nr: Optional[str] = None
taetowiernummer: Optional[str] = None
zuchtbuchnummer: Optional[str] = None
farbe: Optional[str] = None
name: str = Field(..., min_length=1, max_length=200)
rufname: Optional[str] = Field(None, max_length=80)
geschlecht: str = Field(..., max_length=20) # maennlich|weiblich
geburtsdatum: Optional[str] = Field(None, max_length=32)
sterbedatum: Optional[str] = Field(None, max_length=32)
chip_nr: Optional[str] = Field(None, max_length=50)
taetowiernummer: Optional[str] = Field(None, max_length=50)
zuchtbuchnummer: Optional[str] = Field(None, max_length=100)
farbe: Optional[str] = Field(None, max_length=100)
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
zuechter_name: Optional[str] = None
eigentuemer_name: Optional[str] = None
zuechter_name: Optional[str] = Field(None, max_length=200)
eigentuemer_name: Optional[str] = Field(None, max_length=200)
is_public: int = 1
notiz: Optional[str] = None
foto_url: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=5000)
foto_url: Optional[str] = Field(None, max_length=500)
class HundUpdate(BaseModel):
name: Optional[str] = None
rufname: Optional[str] = None
geschlecht: Optional[str] = None
geburtsdatum: Optional[str] = None
sterbedatum: Optional[str] = None
chip_nr: Optional[str] = None
taetowiernummer: Optional[str] = None
zuchtbuchnummer: Optional[str] = None
farbe: Optional[str] = None
name: Optional[str] = Field(None, max_length=200)
rufname: Optional[str] = Field(None, max_length=80)
geschlecht: Optional[str] = Field(None, max_length=20)
geburtsdatum: Optional[str] = Field(None, max_length=32)
sterbedatum: Optional[str] = Field(None, max_length=32)
chip_nr: Optional[str] = Field(None, max_length=50)
taetowiernummer: Optional[str] = Field(None, max_length=50)
zuchtbuchnummer: Optional[str] = Field(None, max_length=100)
farbe: Optional[str] = Field(None, max_length=100)
vater_id: Optional[int] = None
mutter_id: Optional[int] = None
zuechter_name: Optional[str] = None
eigentuemer_name: Optional[str] = None
zuechter_name: Optional[str] = Field(None, max_length=200)
eigentuemer_name: Optional[str] = Field(None, max_length=200)
is_public: Optional[int] = None
notiz: Optional[str] = None
foto_url: Optional[str] = None
notiz: Optional[str] = Field(None, max_length=5000)
foto_url: Optional[str] = Field(None, max_length=500)
class HealthTestCreate(BaseModel):
test_typ: str # HD|ED|OCD|augen|herz|patella|ZTP|custom
test_name: Optional[str] = None
ergebnis: Optional[str] = None
untersuch_am: Optional[str] = None
gueltig_bis: Optional[str] = None
untersucher: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
test_typ: str = Field(..., max_length=50) # HD|ED|OCD|augen|herz|patella|ZTP|custom
test_name: Optional[str] = Field(None, max_length=200)
ergebnis: Optional[str] = Field(None, max_length=500)
untersuch_am: Optional[str] = Field(None, max_length=32)
gueltig_bis: Optional[str] = Field(None, max_length=32)
untersucher: Optional[str] = Field(None, max_length=200)
labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: int = 1
class HealthTestUpdate(BaseModel):
test_typ: Optional[str] = None
test_name: Optional[str] = None
ergebnis: Optional[str] = None
untersuch_am: Optional[str] = None
gueltig_bis: Optional[str] = None
untersucher: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
test_typ: Optional[str] = Field(None, max_length=50)
test_name: Optional[str] = Field(None, max_length=200)
ergebnis: Optional[str] = Field(None, max_length=500)
untersuch_am: Optional[str] = Field(None, max_length=32)
gueltig_bis: Optional[str] = Field(None, max_length=32)
untersucher: Optional[str] = Field(None, max_length=200)
labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: Optional[int] = None
class GeneticTestCreate(BaseModel):
marker_name: str # MDR1|PRA-prcd|DM|vWD|HUU etc.
marker_kategorie: Optional[str] = None # krankheit|farbe|eigenschaft
genotyp: Optional[str] = None # +/+|+/-|-/-
ergebnis_klasse: Optional[str] = None # clear|carrier|affected
getestet_am: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
marker_name: str = Field(..., max_length=100) # MDR1|PRA-prcd|DM|vWD|HUU etc.
marker_kategorie: Optional[str] = Field(None, max_length=50) # krankheit|farbe|eigenschaft
genotyp: Optional[str] = Field(None, max_length=20) # +/+|+/-|-/-
ergebnis_klasse: Optional[str] = Field(None, max_length=50) # clear|carrier|affected
getestet_am: Optional[str] = Field(None, max_length=32)
labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: int = 1
class GeneticTestUpdate(BaseModel):
marker_name: Optional[str] = None
marker_kategorie: Optional[str] = None
genotyp: Optional[str] = None
ergebnis_klasse: Optional[str] = None
getestet_am: Optional[str] = None
labor: Optional[str] = None
zertifikat_nr: Optional[str] = None
marker_name: Optional[str] = Field(None, max_length=100)
marker_kategorie: Optional[str] = Field(None, max_length=50)
genotyp: Optional[str] = Field(None, max_length=20)
ergebnis_klasse: Optional[str] = Field(None, max_length=50)
getestet_am: Optional[str] = Field(None, max_length=32)
labor: Optional[str] = Field(None, max_length=200)
zertifikat_nr: Optional[str] = Field(None, max_length=100)
is_public: Optional[int] = None
class TitelCreate(BaseModel):
titel_typ: str # ausstellung|arbeit|sport|zucht|champion|custom
titel_name: str
verliehen_am: Optional[str] = None
ort: Optional[str] = None
richter: Optional[str] = None
ausstellung: Optional[str] = None
formwert: Optional[str] = None
titel_typ: str = Field(..., max_length=50) # ausstellung|arbeit|sport|zucht|champion|custom
titel_name: str = Field(..., min_length=1, max_length=200)
verliehen_am: Optional[str] = Field(None, max_length=32)
ort: Optional[str] = Field(None, max_length=200)
richter: Optional[str] = Field(None, max_length=200)
ausstellung: Optional[str] = Field(None, max_length=200)
formwert: Optional[str] = Field(None, max_length=100)
is_public: int = 1
class TitelUpdate(BaseModel):
titel_typ: Optional[str] = None
titel_name: Optional[str] = None
verliehen_am: Optional[str] = None
ort: Optional[str] = None
richter: Optional[str] = None
ausstellung: Optional[str] = None
formwert: Optional[str] = None
titel_typ: Optional[str] = Field(None, max_length=50)
titel_name: Optional[str] = Field(None, max_length=200)
verliehen_am: Optional[str] = Field(None, max_length=32)
ort: Optional[str] = Field(None, max_length=200)
richter: Optional[str] = Field(None, max_length=200)
ausstellung: Optional[str] = Field(None, max_length=200)
formwert: Optional[str] = Field(None, max_length=100)
is_public: Optional[int] = None

View file

@ -3,7 +3,7 @@
import logging
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from typing import Optional, Literal
from database import db
@ -41,7 +41,7 @@ class PaarungAnalyseBody(BaseModel):
vater_id: int
mutter_id: int
ik_prozent: Optional[float] = None
welfare_level: Optional[str] = None
welfare_level: Optional[str] = Field(None, max_length=50)
class HundBeschreibungBody(BaseModel):

View file

@ -46,6 +46,14 @@ def start():
misfire_grace_time=3600,
coalesce=True,
)
_scheduler.add_job(
_job_purge_jwt_blacklist,
CronTrigger(hour=3, minute=30), # täglich 03:30 Uhr, nach poison_archive
id="purge_jwt_blacklist",
replace_existing=True,
misfire_grace_time=3600,
coalesce=True,
)
_scheduler.add_job(
_job_weather_alert,
CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr
@ -1832,11 +1840,13 @@ async def _job_anniversary_reminders():
logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
with db() as conn:
# diary hat keinen user_id — User kommt über dogs.user_id
entries = conn.execute("""
SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id,
SELECT d.id, d.titel, d.datum, dogs.user_id, d.dog_id,
(SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
FROM diary d
JOIN dogs ON dogs.id = d.dog_id
WHERE strftime('%m-%d', d.datum) = ?
AND d.datum < date('now')
AND d.titel IS NOT NULL
@ -2231,3 +2241,16 @@ async def _job_error_digest():
except Exception as e:
logger.error(f"Error-Digest: Mail-Fehler: {e}")
_log_job("error_digest", "error", str(e))
def _job_purge_jwt_blacklist():
"""Räumt abgelaufene Einträge aus jwt_blacklist auf — sonst wächst die
Tabelle monoton mit jedem Logout. Läuft täglich 03:30."""
try:
from auth import _purge_expired_jwt
deleted = _purge_expired_jwt()
logger.info(f"jwt_blacklist: {deleted} abgelaufene Einträge gelöscht.")
_log_job("purge_jwt_blacklist", "ok", f"{deleted} entries deleted")
except Exception as e:
logger.exception(f"jwt_blacklist purge fehlgeschlagen: {e}")
_log_job("purge_jwt_blacklist", "error", str(e))

View file

@ -235,6 +235,45 @@
color: var(--c-primary);
}
/* ----- .by-tabs Modifier-Varianten ----------------------------- */
/* Grid-Layout (Admin/Health/Übungen — Desktop oft 2-3 Spalten) */
.by-tabs.grid {
display: grid;
grid-template-columns: repeat(var(--tab-cols, 4), minmax(0, 1fr));
overflow: visible;
gap: var(--space-2);
}
/* Flex-Wrap (Zuchthunde — Buttons brechen um statt zu scrollen) */
.by-tabs.wrap {
flex-wrap: wrap;
overflow-x: visible;
}
/* Separated — eigener Hintergrund + Border (Sitting) */
.by-tabs.separated {
padding: var(--space-3) var(--space-4) var(--space-2);
border-bottom: 1px solid var(--c-border);
background: var(--c-surface);
}
/* Sticky (Admin Desktop vertikal) — nur ab 1024px */
@media (min-width: 1024px) {
.by-tabs.sticky {
position: sticky;
top: var(--space-3);
flex-direction: column;
width: 190px;
gap: var(--space-1);
}
.by-tabs.sticky .by-tab {
justify-content: flex-start;
text-align: left;
padding: var(--space-2) var(--space-3);
}
}
/* ------------------------------------------------------------
4. BY-SECTION-LABEL + BY-TOOLBAR weitere gemeinsame Elemente
------------------------------------------------------------ */
@ -8905,3 +8944,44 @@ svg.empty-state-icon {
.offline-status-row .osr-text { flex: 1; min-width: 0; }
.offline-status-row .osr-title { font-weight: 600; }
.offline-status-row .osr-detail { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; }
/* ============================================================
.map-list-toggle vereinheitlichter Karten/Listen-Umschalter
Verwendet von walks.js, events.js, routes.js, etc.
<div class="map-list-toggle">
<button class="active" data-view="list">Liste</button>
<button data-view="map">Karte</button>
</div>
============================================================ */
.map-list-toggle {
display: flex;
border: 1.5px solid var(--c-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--c-surface);
}
.map-list-toggle button {
flex: 1;
height: 44px;
border: none;
background: transparent;
color: var(--c-text-secondary);
cursor: pointer;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
transition: background 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.map-list-toggle button.active {
background: var(--c-primary);
color: #fff;
}
.map-list-toggle button:not(.active):hover {
background: var(--c-surface-2);
color: var(--c-text);
}

View file

@ -34,7 +34,7 @@
/* Text — Warmbraun aus dem Halsband */
--c-text: #2A1F14;
--c-text-secondary: #7A6A58;
--c-text-muted: #B0A090;
--c-text-muted: #7F6B58; /* a11y: WCAG AA 4.74:1 auf --c-bg #FAF7F2 (vorher #B0A090 = 2.37:1) */
--c-text-inverse: #FAF7F2;
/* Funktionsfarben */
@ -179,7 +179,7 @@
--c-text: #F0EAE0;
--c-text-secondary: #C0B0A0;
--c-text-muted: #806A58;
--c-text-muted: #A08878; /* a11y: WCAG AA 5.46:1 auf --c-bg #1A1410 (vorher #806A58 = 3.58:1) */
--c-text-inverse: #2A1F14;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);

View file

@ -86,8 +86,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: 44px;
height: 44px;
border-radius: var(--radius-md);
color: var(--c-text-secondary);
cursor: pointer;
@ -99,8 +99,8 @@
/* Hamburger-Button (nur Mobile) */
.header-menu-btn {
width: 40px;
height: 40px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;

View file

@ -0,0 +1,328 @@
/* ============================================================
BAN YARO Listen-Komponenten
Wiederverwendbare Klassen für Seiten mit Listen+Detail-Pattern:
Notes, Expenses, Health, Diary, Behavior-Log, ...
Verwendung:
<div class="list-shell">
<div class="list-filter-bar">...</div>
<div class="list-group-header">Mai 2026</div>
<div class="list-item-card list-item-card--clickable" data-id="...">
<div class="list-item-meta-badge" style="--meta-color:#f97316">🍖</div>
<div class="list-item-body">
<div class="list-item-title">Titel</div>
<div class="list-item-text">Vorschau-Text</div>
<div class="list-item-meta-row">
<span>10:30</span> · <span>📍 Berlin</span>
</div>
</div>
<div class="list-item-amount">25,50 </div>
</div>
</div>
============================================================ */
/* ------------------------------------------------------------
Shell + Header
------------------------------------------------------------ */
.list-shell {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.list-filter-bar {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
flex-wrap: wrap;
align-items: center;
}
.list-search-wrap {
flex: 1;
min-width: 200px;
position: relative;
display: flex;
align-items: center;
}
.list-search-wrap > input { width: 100%; }
/* ------------------------------------------------------------
Group-Header (Monat / Datums-Gruppe)
------------------------------------------------------------ */
.list-group-header {
font-size: var(--text-xs);
font-weight: 600;
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: var(--space-3) var(--space-4) var(--space-1);
margin-top: var(--space-2);
}
/* ------------------------------------------------------------
Item-Card (universelle Listen-Karte)
------------------------------------------------------------ */
.list-item-card {
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--c-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--c-border-light);
align-items: flex-start;
transition: background 0.15s, transform 0.1s;
}
.list-item-card--clickable {
cursor: pointer;
}
.list-item-card--clickable:hover {
background: var(--c-surface-2);
}
.list-item-card--clickable:active {
transform: scale(0.98);
}
.list-item-card--milestone {
border-left: 3px solid #f5c518;
}
.list-item-card--inactive {
opacity: 0.55;
filter: grayscale(0.8);
}
/* ------------------------------------------------------------
Linke Spalte: Date-Col oder Meta-Badge
------------------------------------------------------------ */
/* Date-Column (Diary-Style: Wochentag + Tag) */
.list-item-date-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 44px;
text-align: center;
}
.list-item-date-col-weekday {
font-size: var(--text-xs);
font-weight: 600;
color: var(--c-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.list-item-date-col-day {
font-size: 1.5rem;
font-weight: 700;
color: var(--c-text);
line-height: 1.1;
}
/* Meta-Badge (Expenses/Health-Style: farbiges Icon im Kreis) */
.list-item-meta-badge {
width: 44px;
height: 44px;
border-radius: 50%;
background: color-mix(in srgb, var(--meta-color, var(--c-primary)) 15%, transparent);
color: var(--meta-color, var(--c-primary));
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
}
/* ------------------------------------------------------------
Body (Hauptinhalt mittig)
------------------------------------------------------------ */
.list-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.list-item-title {
font-weight: 600;
font-size: var(--text-base);
color: var(--c-text);
line-height: 1.3;
}
.list-item-text {
font-size: var(--text-sm);
color: var(--c-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.list-item-meta-row {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--c-text-muted);
flex-wrap: wrap;
}
/* ------------------------------------------------------------
Chips + Micro-Badges (in Item-Body)
------------------------------------------------------------ */
.list-item-chips {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.list-item-chip {
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--chip-color, var(--c-primary)) 15%, transparent);
color: var(--chip-color, var(--c-primary));
}
.list-item-micro-badges {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
margin-top: 2px;
}
.list-item-micro-badge {
padding: 1px 6px;
background: var(--c-surface-2);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--c-text-secondary);
}
/* ------------------------------------------------------------
Rechte Spalte: Thumbnail, Amount, Actions
------------------------------------------------------------ */
.list-item-thumb {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
overflow: hidden;
object-fit: cover;
flex-shrink: 0;
background: var(--c-surface-2);
position: relative;
}
.list-item-thumb-count {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0,0,0,0.65);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: var(--radius-sm);
}
.list-item-amount {
font-weight: 700;
font-size: var(--text-base);
white-space: nowrap;
flex-shrink: 0;
align-self: center;
}
.list-item-amount--positive { color: var(--c-success); }
.list-item-amount--negative { color: var(--c-danger); }
.list-item-amount--neutral { color: var(--c-text); }
.list-item-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-self: center;
}
.list-item-action-btn {
padding: 6px 8px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
font-size: var(--text-sm);
transition: all 0.15s;
}
.list-item-action-btn:hover {
color: var(--c-text);
background: var(--c-surface-2);
}
.list-item-action-btn--danger:hover {
color: var(--c-danger);
background: color-mix(in srgb, var(--c-danger) 10%, transparent);
}
/* ------------------------------------------------------------
Reminder/Hinweis-Banner (Health-Style)
------------------------------------------------------------ */
.list-reminders-banner {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
}
.list-reminder-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--c-surface);
border-radius: var(--radius-md);
border-left: 3px solid var(--c-text-muted);
font-size: var(--text-sm);
}
.list-reminder-item--urgent { border-left-color: var(--c-danger); }
.list-reminder-item--warning { border-left-color: var(--c-warning, #f59e0b); }
.list-reminder-item--success { border-left-color: var(--c-success); }
/* ------------------------------------------------------------
FAB (Floating Action Button)
------------------------------------------------------------ */
.list-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: #fff;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 18px rgba(196,132,58,0.4);
font-size: 26px;
z-index: 80;
transition: transform 0.12s, box-shadow 0.12s;
}
.list-fab:active {
transform: scale(0.92);
box-shadow: 0 2px 10px rgba(196,132,58,0.3);
}
/* ------------------------------------------------------------
Load-More + Empty-List in Listen-Context
------------------------------------------------------------ */
.list-load-more {
text-align: center;
padding: var(--space-4);
}

View file

@ -0,0 +1,65 @@
/* ============================================================
BAN YARO Utility-Klassen für häufige Inline-Patterns
Ergänzt design-system.css (Single-Property-Utilities sind dort)
============================================================ */
/* ------------------------------------------------------------
Text + Farb-Kombinationen (häufigste Inline-Patterns)
------------------------------------------------------------ */
.text-xs-muted { font-size: var(--text-xs); color: var(--c-text-muted); }
.text-xs-secondary { font-size: var(--text-xs); color: var(--c-text-secondary); }
.text-sm-muted { font-size: var(--text-sm); color: var(--c-text-muted); }
.text-sm-secondary { font-size: var(--text-sm); color: var(--c-text-secondary); }
/* Caption = Mini-Label/Hinweis unter einem Wert */
.caption {
font-size: var(--text-xs);
color: var(--c-text-secondary);
margin-top: 2px;
}
/* ------------------------------------------------------------
Flex-Layouts (kombiniert)
------------------------------------------------------------ */
.flex-gap-2 { display: flex; gap: var(--space-2); }
.flex-gap-3 { display: flex; gap: var(--space-3); }
.flex-col-gap-2 { display: flex; flex-direction: column; gap: var(--space-2); }
.flex-col-gap-3 { display: flex; flex-direction: column; gap: var(--space-3); }
.flex-col-gap-4 { display: flex; flex-direction: column; gap: var(--space-4); }
.flex-center { display: flex; align-items: center; }
.flex-center-gap-1 { display: flex; align-items: center; gap: var(--space-1); }
.flex-center-gap-2 { display: flex; align-items: center; gap: var(--space-2); }
.flex-center-gap-3 { display: flex; align-items: center; gap: var(--space-3); }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
.flex-between-gap-2 { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
/* min-width:0 + flex:1 — verhindert Overflow in Flex-Children */
.flex-1-min { flex: 1; min-width: 0; }
/* ------------------------------------------------------------
Spacing-Lücken in design-system.css füllen
------------------------------------------------------------ */
.mb-1 { margin-bottom: var(--space-1); }
.mb-3 { margin-bottom: var(--space-3); }
.mt-1 { margin-top: var(--space-1); }
.mt-3 { margin-top: var(--space-3); }
/* ------------------------------------------------------------
Icon-Größen (statt width:NNpx;height:NNpx inline)
------------------------------------------------------------ */
.icon-xs { width: 12px; height: 12px; }
.icon-sm { width: 14px; height: 14px; }
.icon-md { width: 18px; height: 18px; }
.icon-lg { width: 22px; height: 22px; }
/* ------------------------------------------------------------
Form-Helper
------------------------------------------------------------ */
.label-block {
display: block;
font-size: var(--text-sm);
font-weight: 600;
margin-bottom: var(--space-1);
}

View file

@ -86,24 +86,14 @@
<title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script>
(function() {
var t = localStorage.getItem('by_theme');
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var isAndroid = /android/i.test(navigator.userAgent);
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
// Android: immer dunkel (Amber-Streifen nicht möglich transparent zu machen)
// iOS: black-translucent übernimmt das
var m = document.getElementById('meta-theme-color');
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
})();
</script>
<script src="/js/boot-early.js?v=1120"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1099">
<link rel="stylesheet" href="/css/layout.css?v=1099">
<link rel="stylesheet" href="/css/components.css?v=1099">
<link rel="stylesheet" href="/css/design-system.css?v=1120">
<link rel="stylesheet" href="/css/layout.css?v=1120">
<link rel="stylesheet" href="/css/components.css?v=1120">
<link rel="stylesheet" href="/css/utilities.css?v=1120">
<link rel="stylesheet" href="/css/lists.css?v=1120">
</head>
<body>
@ -111,7 +101,8 @@
<div id="offline-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;
background:#1f2937;color:#f3f4f6;font-size:0.78rem;font-weight:500;
padding:7px 16px;align-items:center;justify-content:center;gap:8px;
padding:calc(env(safe-area-inset-top, 0px) + 7px) 16px 7px;
align-items:center;justify-content:center;gap:8px;
box-shadow:0 2px 8px rgba(0,0,0,.3)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M213.92,210.62l-160-176A8,8,0,1,0,42.08,45.38L81.06,88.86A152.34,152.34,0,0,0,26.49,130a8,8,0,0,0,11,11.61,136.36,136.36,0,0,1,52-37.29l19.2,21.12A96.09,96.09,0,0,0,67.6,160.59,8,8,0,1,0,79,172.2a80.12,80.12,0,0,1,33.5-23.89L128,165.37V224a8,8,0,0,0,16,0V183.94l69.92,76.92a8,8,0,1,0,11.84-10.76ZM128,141.46,108.42,120A80.38,80.38,0,0,1,128,116a79.91,79.91,0,0,1,19.59,2.43l-19.59,23Zm0-85.46a167.9,167.9,0,0,1,101.51,34.17,8,8,0,1,0,9.72-12.72A183.82,183.82,0,0,0,128,40a183.5,183.5,0,0,0-48.55,6.55L95,64.18A168.23,168.23,0,0,1,128,56Zm57.09,72.41a8,8,0,0,0,11.22-1.36,8,8,0,0,0-1.36-11.22,136.72,136.72,0,0,0-31.62-18.23L178,114.26A120.52,120.52,0,0,1,185.09,128.41Z"/>
@ -125,7 +116,8 @@
<div id="verify-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998;
background:#d97706;color:#fff;font-size:0.8rem;font-weight:500;
padding:8px 16px;align-items:center;justify-content:center;gap:10px;
padding:calc(env(safe-area-inset-top, 0px) + 8px) 16px 8px;
align-items:center;justify-content:center;gap:10px;
box-shadow:0 2px 8px rgba(0,0,0,.2)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.19V181.81ZM40,61.62l88,80.15,88-80.15Z"/>
@ -326,7 +318,7 @@
</div>
<div id="header-actions"></div>
<button id="header-user-btn" aria-label="Profil"
style="width:36px;height:36px;border-radius:50%;border:2px solid var(--c-border);
style="width:44px;height:44px;border-radius:50%;border:2px solid var(--c-border);
background:var(--c-surface-2);cursor:pointer;flex-shrink:0;
display:flex;align-items:center;justify-content:center;overflow:hidden;
padding:0;position:relative">
@ -625,11 +617,11 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1099"></script>
<script src="/js/ui.js?v=1099"></script>
<script src="/js/app.js?v=1099"></script>
<script src="/js/worlds.js?v=1099"></script>
<script src="/js/offline-indicator.js?v=1099"></script>
<script src="/js/api.js?v=1120"></script>
<script src="/js/ui.js?v=1120"></script>
<script src="/js/app.js?v=1120"></script>
<script src="/js/worlds.js?v=1120"></script>
<script src="/js/offline-indicator.js?v=1120"></script>
<!-- Feature-Seiten werden lazy geladen -->
@ -637,130 +629,9 @@
<script defer src="/stats/script.js" data-website-id="d1b5fe13-0e6f-4461-a176-c5439cbbc27f" data-api-host="/stats"></script>
<!-- Offline-Banner Logik -->
<script>
(function() {
function _updateBanner() {
var banner = document.getElementById('offline-banner');
if (!banner) return;
banner.style.display = navigator.onLine ? 'none' : 'flex';
}
window.addEventListener('offline', function() {
_updateBanner();
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
if (!sessionStorage.getItem('by_offline_hint_shown')) {
sessionStorage.setItem('by_offline_hint_shown', '1');
setTimeout(function() {
window.UI?.toast?.info(
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
8000
);
}, 800);
}
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
});
}
});
window.addEventListener('online', function() {
_updateBanner();
var badge = document.getElementById('offline-queue-badge');
if (badge) badge.style.display = 'none';
// Queue abarbeiten
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
});
}
});
// Initial prüfen
_updateBanner();
})();
</script>
<!-- Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(reg => {
function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
// Flag nur prüfen, nicht konsumieren — controllerchange konsumiert ihn
if (sessionStorage.getItem('by_skip_sw_reload')) return;
window.location.replace('/?_t=' + Date.now());
}
});
}
// Listener VOR update() registrieren — verhindert Race Condition
reg.addEventListener('updatefound', () => _watchSW(reg.installing));
// Falls SW bereits installiert (Seite wurde nach SW-Install neu geladen)
if (reg.installing) _watchSW(reg.installing);
reg.update();
})
.catch(err => console.warn('SW Registration failed:', err));
});
// Backup: erneut prüfen wenn App aus dem Hintergrund kommt
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
navigator.serviceWorker.getRegistration().then(reg => reg?.update());
}
});
// Backup: controllerchange (falls updatefound nicht feuert)
// NICHT registrieren wenn diese Seite selbst durch einen SW-Reload entstand (_t= im URL)
// — verhindert Dauerschleife wenn clients.claim() erst nach Seitenstart feuert
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'QUEUE_PROCESSED') {
const { synced, failed, total } = e.data;
if (total === 0) return;
if (synced > 0 && window.UI?.toast) {
window.UI.toast.success(
synced === 1
? '1 offline gespeicherter Eintrag synchronisiert'
: `${synced} offline gespeicherte Einträge synchronisiert`
);
// Aktuelle Seite neu laden
window.App?.state && window.pages?.[window.App.state.page]?.module?.refresh?.();
}
if (failed > 0 && window.UI?.toast) {
window.UI.toast.warning(`${failed} Eintrag${failed > 1 ? 'e' : ''} noch nicht synchronisiert — kein Netz`);
}
return;
}
if (e.data?.type === 'QUEUE_COUNT') {
const badge = document.getElementById('offline-queue-badge');
if (badge) {
if (e.data.count > 0) {
badge.textContent = e.data.count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
return;
}
if (e.data?.type === 'CHECK_NEARBY_ALERTS') {
window.App?._checkNearbyAlerts?.();
}
});
}
</script>
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1120"></script>
</body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1099'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1120'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION;
@ -129,16 +129,23 @@ const App = (() => {
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
// Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist
// UND wenn nicht erst kürzlich force-update lief (Cooldown 10 Min) — verhindert Loop
// bei mehreren schnellen Deploys oder iOS-PWA-Cache-Quirks. localStorage überlebt
// App-Restarts (sessionStorage wäre bei PWA-Standalone-close weg).
if (window._byUpdatePending) {
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null;
if (!modalOpen) {
let lastForce = 0;
try { lastForce = parseInt(localStorage.getItem('by_last_force_update') || '0', 10); } catch {}
const cooldownActive = (Date.now() - lastForce) < 10 * 60 * 1000;
if (!modalOpen && !cooldownActive) {
window._byUpdatePending = false;
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
sessionStorage.setItem('by_update_target', pageId); // Zielseite nach Update
sessionStorage.setItem('by_update_target', pageId);
try { localStorage.setItem('by_last_force_update', String(Date.now())); } catch {}
location.href = '/force-update';
return;
}
// Modal offen → beim nächsten Seitenwechsel versuchen
// Modal offen oder Cooldown → bei nächstem Seitenwechsel versuchen
}
if (window.Worlds?._visible) window.Worlds.hide();

View file

@ -0,0 +1,13 @@
/* Theme-Setup und theme-color für Status-Leiste.
MUSS synchron im <head> VOR den CSS-Links laufen, sonst FOUC. */
(function() {
var t = localStorage.getItem('by_theme');
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var isAndroid = /android/i.test(navigator.userAgent);
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
// Android: immer dunkel (Amber-Streifen nicht transparent möglich)
// iOS: black-translucent übernimmt das
var m = document.getElementById('meta-theme-color');
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
})();

127
backend/static/js/boot.js Normal file
View file

@ -0,0 +1,127 @@
/* ============================================================
BAN YARO Boot-Phase
Offline-Banner + Service Worker Registration + Update-Flow
Extrahiert aus index.html für CSP-Härtung (kein unsafe-inline)
============================================================ */
// ----------------------------------------------------------
// Offline-Banner
// ----------------------------------------------------------
(function() {
function _updateBanner() {
var banner = document.getElementById('offline-banner');
if (!banner) return;
banner.style.display = navigator.onLine ? 'none' : 'flex';
}
window.addEventListener('offline', function() {
_updateBanner();
// Einmaliger Hinweis pro Session: App im Vordergrund lassen
if (!sessionStorage.getItem('by_offline_hint_shown')) {
sessionStorage.setItem('by_offline_hint_shown', '1');
setTimeout(function() {
window.UI?.toast?.info(
'App im Vordergrund lassen — so bleiben Offline-Funktionen wie GPS und Datenspeicherung aktiv.',
8000
);
}, 800);
}
// Queue-Count abfragen
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'QUEUE_COUNT' });
});
}
});
window.addEventListener('online', function() {
_updateBanner();
var badge = document.getElementById('offline-queue-badge');
if (badge) badge.style.display = 'none';
// Queue abarbeiten
if (navigator.serviceWorker) {
navigator.serviceWorker.ready.then(function(reg) {
if (reg.active) reg.active.postMessage({ type: 'PROCESS_QUEUE' });
});
}
});
_updateBanner();
})();
// ----------------------------------------------------------
// Service Worker Registration + Update-Flow
// ----------------------------------------------------------
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(function(reg) {
function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', function() {
if (sw.state === 'activated') {
if (sessionStorage.getItem('by_skip_sw_reload')) return;
window.location.replace('/?_t=' + Date.now());
}
});
}
reg.addEventListener('updatefound', function() { _watchSW(reg.installing); });
if (reg.installing) _watchSW(reg.installing);
reg.update();
})
.catch(function(err) { console.warn('SW Registration failed:', err); });
});
// App aus dem Hintergrund: erneut prüfen
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
navigator.serviceWorker.getRegistration().then(function(reg) { if (reg) reg.update(); });
}
});
// Backup: controllerchange falls updatefound nicht feuert
// NICHT registrieren wenn diese Seite selbst durch SW-Reload entstand
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', function() {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}
navigator.serviceWorker.addEventListener('message', function(e) {
if (e.data && e.data.type === 'QUEUE_PROCESSED') {
var synced = e.data.synced, failed = e.data.failed, total = e.data.total;
if (total === 0) return;
if (synced > 0 && window.UI && window.UI.toast) {
window.UI.toast.success(
synced === 1
? '1 offline gespeicherter Eintrag synchronisiert'
: synced + ' offline gespeicherte Einträge synchronisiert'
);
if (window.App && window.App.state && window.pages) {
var p = window.pages[window.App.state.page];
if (p && p.module && p.module.refresh) p.module.refresh();
}
}
if (failed > 0 && window.UI && window.UI.toast) {
window.UI.toast.warning(failed + ' Eintrag' + (failed > 1 ? 'e' : '') + ' noch nicht synchronisiert — kein Netz');
}
return;
}
if (e.data && e.data.type === 'QUEUE_COUNT') {
var badge = document.getElementById('offline-queue-badge');
if (badge) {
if (e.data.count > 0) {
badge.textContent = e.data.count;
badge.style.display = '';
} else {
badge.style.display = 'none';
}
}
return;
}
if (e.data && e.data.type === 'CHECK_NEARBY_ALERTS') {
if (window.App && window.App._checkNearbyAlerts) window.App._checkNearbyAlerts();
}
});
}

View file

@ -0,0 +1,99 @@
/* ============================================================
BAN YARO Landing Page Init
Dark-Mode-Check, Scroll-Animationen, Live-Stats, Stay-In-App
Extrahiert aus landing.html für CSP-Härtung
============================================================ */
// Dark Mode (CSS-Klasse)
(function() {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq.matches) document.documentElement.classList.add('dark');
mq.addEventListener('change', function(e) {
document.documentElement.classList.toggle('dark', e.matches);
});
})();
document.addEventListener('DOMContentLoaded', function() {
// App-Links: kein Redirect-Loop (ersetzt onclick="sessionStorage.setItem(...)")
document.querySelectorAll('[data-stay-in-app]').forEach(function(el) {
el.addEventListener('click', function() {
sessionStorage.setItem('by_stay_in_app', '1');
});
});
// Hundebesitzer-Details-Toggle (ersetzt inline onclick)
document.querySelectorAll('[data-toggle-target]').forEach(function(el) {
el.addEventListener('click', function() {
var c = document.getElementById(el.dataset.toggleTarget);
if (!c) return;
c.classList.toggle('open');
var open = c.classList.contains('open');
var openTxt = el.dataset.toggleTextOpen || '▴ Weniger anzeigen';
var closeTxt = el.dataset.toggleTextClose || el.textContent;
if (!el.dataset.toggleTextClose) el.dataset.toggleTextClose = closeTxt;
el.textContent = open ? openTxt : el.dataset.toggleTextClose;
});
});
// Auch ältere App-Links erfassen (Fallback ohne data-stay-in-app)
document.querySelectorAll('a[href="/"], a[href^="/#"]').forEach(function(a) {
a.addEventListener('click', function() {
sessionStorage.setItem('by_stay_in_app', '1');
});
});
// Scroll-Animationen
var _observer = new IntersectionObserver(function(entries) {
entries.forEach(function(e) {
if (e.isIntersecting) {
e.target.classList.add('visible');
_observer.unobserve(e.target);
}
});
}, { threshold: 0.12 });
document.querySelectorAll('.outcome-card, .feature-card, .usp-item, .pricing-card').forEach(function(el) {
el.classList.add('fade-up');
_observer.observe(el);
});
document.querySelectorAll('.fade-up').forEach(function(el) {
_observer.observe(el);
});
// Live-Zahlen
var fmt = new Intl.NumberFormat('de-DE');
fetch('/api/stats/public')
.then(function(r) { return r.json(); })
.then(function(d) {
function set(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = fmt.format(val);
}
set('big-users', d.users);
set('big-dogs', d.dogs);
set('big-km', d.km);
set('big-posts', d.forum_posts);
set('big-diary', d.diary_entries);
set('big-kotbeutel', d.kotbeutel);
var heroStats = document.getElementById('hero-stats');
if (!heroStats || !d.users) return;
var items = [
{ val: d.users, label: 'Hundemenschen' },
{ val: d.dogs, label: 'Hunde' },
{ val: d.km, label: 'km Gassi-Wege' },
{ val: d.diary_entries, label: 'Tagebuch-Einträge' },
{ val: d.kotbeutel, label: 'Mülleimer für Kotbeutel'},
];
items.sort(function(a, b) { return a.val - b.val; });
heroStats.innerHTML = items.map(function(item, i) {
return (i > 0 ? '<span class="sep">·</span>' : '') +
'<strong>' + fmt.format(item.val) + '</strong> ' + item.label;
}).join('');
heroStats.style.display = 'flex';
})
.catch(function() {});
});

View file

@ -234,6 +234,42 @@ window.OfflineIndicator = (() => {
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
}
// ----------------------------------------------------------
// Storage-Quota überwachen — iOS-PWA hat ~50MB Limit
// ----------------------------------------------------------
let _storageWarned = false;
async function _checkStorageQuota() {
if (!navigator.storage?.estimate) return;
try {
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
if (!quota) return;
const ratio = usage / quota;
// Ab 80% Auslastung: Tile-Cache aggressiv trimmen (per SW-Message)
if (ratio >= 0.8) {
const cache = await caches.open(CACHE_TILES).catch(() => null);
if (cache) {
const keys = await cache.keys();
// Auf 100 Tiles trimmen (statt 500) bei knappem Speicher
if (keys.length > 100) {
const toDelete = keys.slice(0, keys.length - 100);
await Promise.all(toDelete.map(k => cache.delete(k).catch(() => {})));
}
}
// Einmaliger User-Hinweis pro Session bei kritischer Auslastung (>90%)
if (ratio >= 0.9 && !_storageWarned && window.UI?.toast) {
_storageWarned = true;
const mb = Math.round(usage / 1024 / 1024);
const max = Math.round(quota / 1024 / 1024);
window.UI.toast.warning(
`Speicher fast voll (${mb}/${max} MB) — älteste Karten-Tiles werden gelöscht.`,
6000
);
}
}
} catch {}
}
// Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte
function _prefetchPages() {
['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost']
@ -305,7 +341,8 @@ window.OfflineIndicator = (() => {
if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh();
});
}
setInterval(() => { _prefetchData(); refresh(); }, 60_000);
_checkStorageQuota(); // beim Init prüfen
setInterval(() => { _prefetchData(); refresh(); _checkStorageQuota(); }, 60_000);
}
return { init, refresh, openStatus };

File diff suppressed because it is too large Load diff

View file

@ -56,7 +56,7 @@ window.Page_adoption = (() => {
<input id="adp-rasse" class="form-control" type="text"
placeholder="Rasse filtern…"
style="flex:1;min-width:120px;max-width:220px"
value="${_esc(_rasseFilter)}">
value="${UI.escape(_rasseFilter)}">
<button class="btn btn-secondary" id="adp-btn-locate"
style="white-space:nowrap">
${UI.icon('map-pin')} Mein Standort
@ -270,7 +270,7 @@ window.Page_adoption = (() => {
content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
<h3 style="margin-bottom:var(--space-2)">Finde Hunde in deiner Nähe</h3>
<h3 class="mb-2">Finde Hunde in deiner Nähe</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
in deiner Umgebung zu finden.
@ -306,7 +306,7 @@ window.Page_adoption = (() => {
if (!animals.length) {
content.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${_esc(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
${_rasseFilter ? `Keine Hunde gefunden für "<strong>${UI.escape(_rasseFilter)}</strong>"` : `Keine Hunde im Umkreis von ${_radius} km gefunden.`}
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
<a href="https://www.tierheimhelden.de/hunde/liste"
@ -339,7 +339,7 @@ window.Page_adoption = (() => {
</p>
<a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary" style="font-size:var(--text-sm)">
class="btn btn-secondary text-sm">
${UI.icon('arrow-square-out')} Tierheimhelden.de alle Hunde
</a>
</div>
@ -355,7 +355,7 @@ window.Page_adoption = (() => {
function _animalCard(a) {
const foto = a.foto_url
? `<img src="${_esc(a.foto_url)}" alt="${_esc(a.name)}"
? `<img src="${UI.escape(a.foto_url)}" alt="${UI.escape(a.name)}"
style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem&quot;>🐶</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
@ -366,7 +366,7 @@ window.Page_adoption = (() => {
const tierheim = a.tierheim || '';
return `
<div data-adp-url="${_esc(a.adoptions_url)}"
<div data-adp-url="${UI.escape(a.adoptions_url)}"
style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2);cursor:pointer;
box-shadow:0 1px 4px rgba(0,0,0,0.08);
@ -379,16 +379,16 @@ window.Page_adoption = (() => {
<div style="padding:var(--space-2) var(--space-2) var(--space-3)">
<div style="font-weight:600;font-size:var(--text-sm);
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(a.name)}
${UI.escape(a.name)}
</div>
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(rasseTxt)}
${UI.escape(rasseTxt)}
</div>` : ''}
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)">
${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${_esc(alterTxt)}
${UI.escape(alterTxt)}
</span>` : ''}
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -396,12 +396,12 @@ window.Page_adoption = (() => {
</span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${_esc(distTxt)}
${UI.escape(distTxt)}
</span>` : ''}
</div>
${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${_esc(tierheim)}">
${UI.icon('house-line')} ${_esc(tierheim)}
white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${UI.escape(tierheim)}">
${UI.icon('house-line')} ${UI.escape(tierheim)}
</div>` : ''}
</div>
</div>
@ -434,7 +434,7 @@ window.Page_adoption = (() => {
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<div class="flex-col-gap-2">
${shelters.map(s => _shelterRow(s)).join('')}
</div>
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
@ -444,12 +444,12 @@ window.Page_adoption = (() => {
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<a href="https://www.tierheimhelden.de"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
class="btn btn-secondary btn-sm text-sm">
${UI.icon('arrow-square-out')} Tierheimhelden.de
</a>
<a href="https://www.tierschutz.com/tierheimsuche/"
target="_blank" rel="noopener noreferrer"
class="btn btn-secondary btn-sm" style="font-size:var(--text-sm)">
class="btn btn-secondary btn-sm text-sm">
${UI.icon('magnifying-glass')} tierschutz.com
</a>
</div>
@ -459,7 +459,7 @@ window.Page_adoption = (() => {
function _shelterRow(s) {
return `
<a href="${_esc(s.url)}" target="_blank" rel="noopener noreferrer"
<a href="${UI.escape(s.url)}" target="_blank" rel="noopener noreferrer"
style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);text-decoration:none;color:inherit;
@ -473,13 +473,13 @@ window.Page_adoption = (() => {
font-size:1.2rem">
🏠
</div>
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(s.name)}
${UI.escape(s.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_esc(s.plz)} ${_esc(s.stadt)}
<div class="text-xs-secondary">
${UI.escape(s.plz)} ${UI.escape(s.stadt)}
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0">
@ -520,7 +520,7 @@ window.Page_adoption = (() => {
content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div>
<h3 style="margin-bottom:var(--space-2)">Noch keine Hunde zur Weitervermittlung</h3>
<h3 class="mb-2">Noch keine Hunde zur Weitervermittlung</h3>
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-5);max-width:320px;margin-inline:auto">
Hier können Halter Hunde privat zur Weitervermittlung anbieten
zum Beispiel bei Umzug, Krankheit oder Allergie.
@ -530,7 +530,7 @@ window.Page_adoption = (() => {
${UI.icon('plus')} Hund zur Vermittlung anbieten
</button>
` : `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary)">
<p class="text-sm-secondary">
Bitte anmelden, um ein Inserat zu erstellen.
</p>
`}
@ -556,8 +556,8 @@ window.Page_adoption = (() => {
${isLoggedIn && _myListings && _myListings.length ? `
<div id="adp-my-listings" style="margin-top:var(--space-6);padding-top:var(--space-4);border-top:1px solid var(--c-border)">
<h4 style="margin-bottom:var(--space-3)">Meine Inserate</h4>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<h4 class="mb-3">Meine Inserate</h4>
<div class="flex-col-gap-2">
${_myListings.map(l => _myListingRow(l)).join('')}
</div>
</div>
@ -610,7 +610,7 @@ window.Page_adoption = (() => {
function _communityCard(l) {
const foto = l.foto_url
? `<img src="${_esc(l.foto_url)}" alt="${_esc(l.name)}"
? `<img src="${UI.escape(l.foto_url)}" alt="${UI.escape(l.name)}"
style="width:100%;height:100%;object-fit:cover"
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem&quot;>🐾</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
@ -635,11 +635,11 @@ window.Page_adoption = (() => {
const interestBtn = l.user_interested
? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)"
data-adp-interest="${_esc(l.id)}" data-adp-interested="true">
data-adp-interest="${UI.escape(l.id)}" data-adp-interested="true">
Bereits gemeldet
</button>`
: `<button class="btn btn-primary btn-sm" style="width:100%;font-size:var(--text-xs)"
data-adp-interest="${_esc(l.id)}" data-adp-interested="false"
data-adp-interest="${UI.escape(l.id)}" data-adp-interested="false"
${!isActive ? 'disabled' : ''}>
Interesse bekunden
</button>`;
@ -657,7 +657,7 @@ window.Page_adoption = (() => {
display:flex;align-items:center;justify-content:center">
<span style="color:#fff;font-weight:700;font-size:var(--text-sm);
background:rgba(0,0,0,0.6);padding:4px 12px;border-radius:999px">
${_esc(statusLabel)}
${UI.escape(statusLabel)}
</span>
</div>
` : ''}
@ -666,17 +666,17 @@ window.Page_adoption = (() => {
<div style="padding:var(--space-2) var(--space-2) var(--space-3);flex:1;display:flex;flex-direction:column;gap:var(--space-1)">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.name)}
${UI.escape(l.name)}
</div>
${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.rasse)}
${UI.escape(l.rasse)}
</div>` : ''}
<!-- Badges -->
<div style="display:flex;gap:4px;flex-wrap:wrap">
${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${_esc(alterLabel)}
${UI.escape(alterLabel)}
</span>` : ''}
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -684,14 +684,14 @@ window.Page_adoption = (() => {
</span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${_esc(distTxt)}
${UI.escape(distTxt)}
</span>` : ''}
</div>
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${_esc(ort)}</div>` : ''}
${ort ? `<div style="font-size:10px;color:var(--c-text-muted)">${UI.escape(ort)}</div>` : ''}
${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical">
${_esc(l.beschreibung)}
${UI.escape(l.beschreibung)}
</div>` : ''}
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''}
@ -714,23 +714,23 @@ window.Page_adoption = (() => {
<div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);border:1px solid var(--c-border)">
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.name)}
${UI.escape(l.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<div class="text-xs-secondary">
${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
</div>
</div>
<select class="form-control" style="width:auto;font-size:var(--text-xs)"
data-adp-status-change="${_esc(l.id)}">
data-adp-status-change="${UI.escape(l.id)}">
${statusOptions.map(o => `
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
`).join('')}
</select>
<button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap"
data-adp-delete="${_esc(l.id)}">
data-adp-delete="${UI.escape(l.id)}">
${UI.icon('trash')} Löschen
</button>
</div>
@ -764,7 +764,7 @@ window.Page_adoption = (() => {
// Interesse bekunden — Modal mit optionaler Nachricht
const body = `
<form id="adp-interest-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<form id="adp-interest-form" class="flex-col-gap-3">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Du kannst optional eine Nachricht an den Anbieter schicken.
</p>
@ -816,9 +816,9 @@ window.Page_adoption = (() => {
}
const body = `
<form id="adp-create-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<form id="adp-create-form" class="flex-col-gap-3">
<div class="form-group">
<label class="form-label">Name <span style="color:var(--c-danger)">*</span></label>
<label class="form-label">Name <span class="text-danger">*</span></label>
<input class="form-control" name="name" required placeholder="z.B. Bello">
</div>
<div class="form-group">
@ -849,7 +849,7 @@ window.Page_adoption = (() => {
<div class="form-group">
<label class="form-label">PLZ</label>
<input class="form-control" name="plz" inputmode="numeric" maxlength="5"
placeholder="z.B. 80331" value="${_esc(_lat ? '' : '')}">
placeholder="z.B. 80331" value="${UI.escape(_lat ? '' : '')}">
</div>
<div class="form-group">
<label class="form-label">Ort</label>
@ -857,7 +857,7 @@ window.Page_adoption = (() => {
</div>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-danger)">*</span></label>
<label class="form-label">Beschreibung <span class="text-danger">*</span></label>
<textarea class="form-control" name="beschreibung" rows="4" required minlength="80"
placeholder="Erzähle, warum du deinen Hund abgeben musst, und was ihn besonders macht…"></textarea>
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">Mindestens 80 Zeichen</div>
@ -876,7 +876,7 @@ window.Page_adoption = (() => {
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="adp-create-form" class="btn btn-primary" style="width:100%" id="adp-create-submit">
<button type="submit" form="adp-create-form" class="btn btn-primary w-full" id="adp-create-submit">
${UI.icon('plus')} Inserat erstellen
</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
@ -941,15 +941,6 @@ window.Page_adoption = (() => {
return 'Senior';
}
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC API
// ----------------------------------------------------------

View file

@ -0,0 +1,318 @@
/* ============================================================
BAN YARO Züchter-Profil-Editor
Selbstverwaltung des öffentlichen Züchter-Profils.
============================================================ */
window.Page_breeder_editor = (() => {
let _container = null;
let _data = null; // { profile, litters, storage_mb, storage_limit_mb }
async function init(container) {
_container = container;
_container.innerHTML = `<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">${UI.skeleton(5)}</div>`;
await _load();
}
function refresh() { _load(); }
function onDogChange() {}
async function _load() {
try {
_data = await API.get('/breeder/my-editor');
_render();
} catch (e) {
_container.innerHTML = `<div style="padding:var(--space-6);color:var(--c-danger)">${e.message}</div>`;
}
}
function _render() {
const { profile: p, litters, storage_mb, storage_limit_mb } = _data;
_container.innerHTML = `
<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">
<div style="margin-bottom:var(--space-5)">
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">Mein Züchter-Profil</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
Gestalte deine öffentliche Profilseite Fotos, Videos und Infos zu deinen Würfen.
</p>
</div>
<!-- Logo & Grundinfos -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Logo / Titelbild</div>
<div style="display:flex;align-items:center;gap:var(--space-4)">
<div id="be-logo-preview" style="width:80px;height:80px;border-radius:var(--radius-md);
background:var(--c-surface-2);overflow:hidden;flex-shrink:0;
display:flex;align-items:center;justify-content:center">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" style="width:100%;height:100%;object-fit:cover">`
: `<svg class="ph-icon" style="width:32px;height:32px;opacity:.3"><use href="/icons/phosphor.svg#image"></use></svg>`}
</div>
<div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
Logo hochladen
<input type="file" id="be-logo-input" accept="image/*" class="hidden">
</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
Quadratisch · max. 5 MB · HEIC wird unterstützt
</div>
</div>
</div>
</div>
<!-- Profil-Texte -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Profil-Texte</div>
<form id="be-text-form" class="flex-col-gap-3">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Zwingername *</label>
<input class="form-control" name="zwingername" type="text" required
value="${UI.escape(p.zwingername || '')}">
</div>
<div class="form-group">
<label class="form-label">Rasse(n)</label>
<input class="form-control" name="rasse_text" type="text"
value="${UI.escape(p.rasse_text || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Slogan <span style="font-weight:400;color:var(--c-text-muted)">(max. 80 Zeichen)</span></label>
<input class="form-control" name="tagline" type="text" maxlength="80"
placeholder="z. B. Liebevolle Aufzucht seit 2010 · VDH-anerkannt"
value="${UI.escape(p.tagline || '')}">
</div>
<div class="form-group">
<label class="form-label">Über uns / Zwingerbeschreibung</label>
<textarea class="form-control" name="beschreibung" rows="4" maxlength="800"
placeholder="Wer seid ihr, was ist euch bei der Zucht wichtig?">${UI.escape(p.beschreibung || '')}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Stadt</label>
<input class="form-control" name="stadt" type="text" value="${UI.escape(p.stadt || '')}">
</div>
<div class="form-group">
<label class="form-label">Verein</label>
<input class="form-control" name="verein" type="text" value="${UI.escape(p.verein || '')}">
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label class="form-label">Website</label>
<input class="form-control" name="website" type="url"
placeholder="https://" value="${UI.escape(p.website || '')}">
</div>
<div class="form-group">
<label class="form-label">Instagram</label>
<input class="form-control" name="instagram" type="text"
placeholder="@zwingername" value="${UI.escape(p.instagram || '')}">
</div>
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
Profil speichern
</button>
</form>
</div>
<!-- Profil-Fotos & Videos -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:var(--space-2)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted)">
Profil-Fotos & Videos
</div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
JPG, PNG, HEIC, MP4, MOV · max. 200 MB pro Datei
</div>
${_storageBar(storage_mb, storage_limit_mb)}
<div id="be-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2);margin:var(--space-3) 0">
${_renderPhotoGrid(p.photos || [])}
</div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
<svg class="ph-icon" style="width:16px;height:16px"><use href="/icons/phosphor.svg#plus"></use></svg>
Foto / Video hinzufügen
<input type="file" id="be-profile-photo-input" accept="image/*,video/*" class="hidden">
</label>
</div>
<!-- Würfe Schnellupload -->
${litters.length ? `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">
Aktuelle Würfe Fotos & Videos
</div>
<div class="flex-col-gap-3">
${litters.map(l => _renderLitterCard(l)).join('')}
</div>
</div>` : ''}
</div>
`;
_bindEvents();
}
function _renderPhotoGrid(photos) {
return photos.map((ph, i) => {
const isVid = ph.media_type === 'video' || (ph.url || '').endsWith('.mp4');
return `
<div style="position:relative;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;background:var(--c-surface-2)">
${isVid
? `<video src="${UI.escape(ph.url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);border-radius:4px;padding:1px 5px;font-size:10px;color:#fff"> Video</div>`
: `<img src="${UI.escape(ph.thumbnail_url || ph.url)}" style="width:100%;height:100%;object-fit:cover">`}
${ph.is_primary ? `<div style="position:absolute;top:4px;left:4px;background:rgba(196,132,58,.9);border-radius:3px;padding:1px 5px;font-size:9px;color:#fff;font-weight:700">LOGO</div>` : ''}
<button class="be-photo-del" data-id="${ph.id}"
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">×</button>
${!ph.is_primary ? `<button class="be-photo-primary" data-id="${ph.id}"
title="Als Logo setzen"
style="position:absolute;bottom:4px;right:4px;background:rgba(0,0,0,.55);
border:none;border-radius:3px;padding:1px 5px;font-size:9px;cursor:pointer;color:#fff">Logo</button>` : ''}
</div>`;
}).join('');
}
function _renderLitterCard(l) {
const label = l.geburtsdatum
? `Wurf vom ${new Date(l.geburtsdatum).toLocaleDateString('de-DE')}`
: `Wurf #${l.id}`;
const info = [
l.welpen_gesamt ? `${l.welpen_gesamt} Welpen` : null,
`${l.foto_count} Medien`,
].filter(Boolean).join(' · ');
return `
<div style="border:1px solid var(--c-border);border-radius:var(--radius-md);padding:var(--space-3)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${UI.escape(label)}</div>
<div class="text-xs-muted">${info}</div>
</div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
<svg class="ph-icon" style="width:14px;height:14px"><use href="/icons/phosphor.svg#upload-simple"></use></svg>
Upload
<input type="file" class="be-litter-input" data-litter-id="${l.id}"
data-label="${UI.escape(label)}" accept="image/*,video/*" class="hidden">
</label>
</div>
</div>`;
}
function _storageBar(usedMb, limitMb) {
const pct = Math.min(100, Math.round((usedMb / limitMb) * 100));
const color = pct > 85 ? '#dc2626' : pct > 60 ? '#f59e0b' : '#22c55e';
return `
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
<div style="flex:1;height:4px;background:var(--c-surface-2);border-radius:2px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:2px"></div>
</div>
<span style="white-space:nowrap">${usedMb.toFixed(1)} / ${limitMb} MB</span>
</div>`;
}
function _bindEvents() {
const el = _container;
// Logo hochladen
el.querySelector('#be-logo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'breeder');
fd.append('entity_id', String(_data.profile.id));
fd.append('is_primary', '1');
fd.append('visibility', 'public');
try {
await API.breederPhotos.upload(fd);
UI.toast.success('Logo gespeichert.');
await _load();
} catch (err) { UI.toast.error(err.message); }
});
// Profil-Texte speichern
el.querySelector('#be-text-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.put('/breeder/profile', fd);
_data.profile = { ..._data.profile, ...fd };
UI.toast.success('Profil gespeichert.');
});
});
// Profil-Foto/-Video hochladen
el.querySelector('#be-profile-photo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
if (isVideo) UI.toast.info('Video wird komprimiert das kann 12 Minuten dauern …', 120_000);
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'breeder');
fd.append('entity_id', String(_data.profile.id));
fd.append('visibility', 'public');
try {
await API.breederPhotos.upload(fd);
UI.toast.success(isVideo ? 'Video hinzugefügt.' : 'Foto hinzugefügt.');
await _load();
} catch (err) { UI.toast.error(err.message); }
});
// Foto löschen
el.querySelectorAll('.be-photo-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Löschen?')) return;
try {
await API.breederPhotos.remove(parseInt(btn.dataset.id));
await _load();
} catch (err) { UI.toast.error(err.message); }
});
});
// Als Logo setzen
el.querySelectorAll('.be-photo-primary').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await API.patch(`/breeder/photos/${btn.dataset.id}/primary`, {});
await _load();
} catch (err) { UI.toast.error(err.message); }
});
});
// Wurf-Upload
el.querySelectorAll('.be-litter-input').forEach(input => {
input.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const litterId = input.dataset.litterId;
const label = input.dataset.label;
if (isVideo) UI.toast.info('Video wird komprimiert das kann 12 Minuten dauern …', 120_000);
const fd = new FormData();
fd.append('file', file);
fd.append('entity_type', 'litter');
fd.append('entity_id', litterId);
fd.append('visibility', 'public');
try {
await API.breederPhotos.upload(fd);
UI.toast.success(`${isVideo ? 'Video' : 'Foto'} zu „${label}" hinzugefügt.`);
// Foto-Count aktualisieren
const litter = _data.litters.find(l => String(l.id) === String(litterId));
if (litter) litter.foto_count++;
_render();
} catch (err) { UI.toast.error(err.message); }
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -7,8 +7,6 @@ window.Page_breeder = (() => {
let _container = null;
let _appState = null;
const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ----------------------------------------------------------
// INIT
@ -51,7 +49,7 @@ window.Page_breeder = (() => {
} catch (e) {
document.getElementById('breeder-profile-body').innerHTML =
`<div style="padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">
${UI.icon('magnifying-glass')} ${_esc(e.message || 'Züchter nicht gefunden.')}
${UI.icon('magnifying-glass')} ${UI.escape(e.message || 'Züchter nicht gefunden.')}
</div>`;
}
}
@ -75,22 +73,22 @@ window.Page_breeder = (() => {
padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative">
<div style="max-width:640px;margin:0 auto">
<div style="display:flex;align-items:flex-start;gap:var(--space-3);flex-wrap:wrap">
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<p style="margin:0 0 var(--space-1);font-size:var(--text-xs);opacity:.7;text-transform:uppercase;letter-spacing:.1em">
${UI.icon('seal-check')} Verifizierter Züchter
</p>
<h1 style="margin:0 0 var(--space-2);font-size:clamp(1.3rem,4vw,1.9rem);font-weight:800;line-height:1.2;word-break:break-word">
${_esc(p.zwingername)}
${UI.escape(p.zwingername)}
</h1>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center">
${p.rasse_text ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${_esc(p.rasse_text)}</span>` : ''}
${p.rasse_text ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${UI.escape(p.rasse_text)}</span>` : ''}
${p.vdh_mitglied ? `<span style="background:rgba(255,255,255,.2);border-radius:999px;padding:2px 10px;font-size:var(--text-xs);font-weight:600">${UI.icon('certificate')} VDH</span>` : ''}
${p.stadt ? `<span style="opacity:.8;font-size:var(--text-xs)">${UI.icon('map-pin')} ${_esc(p.stadt)}</span>` : ''}
${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${_esc(seit)}</span>` : ''}
${p.stadt ? `<span style="opacity:.8;font-size:var(--text-xs)">${UI.icon('map-pin')} ${UI.escape(p.stadt)}</span>` : ''}
${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${UI.escape(seit)}</span>` : ''}
</div>
</div>
${p.logo_url
? `<img src="${_esc(p.logo_url)}" alt="Zwinger-Logo"
? `<img src="${UI.escape(p.logo_url)}" alt="Zwinger-Logo"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:3px solid rgba(255,255,255,.5);flex-shrink:0;box-shadow:0 2px 12px rgba(0,0,0,.25)"
onerror="this.style.display='none'">`
@ -117,7 +115,7 @@ window.Page_breeder = (() => {
Anmelden um zu schreiben
</button>`
}
${p.website ? `<a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
${p.website ? `<a href="${UI.escape(p.website)}" target="_blank" rel="noopener noreferrer"
style="background:rgba(255,255,255,.2);color:white;border:1px solid rgba(255,255,255,.4);
border-radius:999px;padding:var(--space-2) var(--space-5);
font-weight:600;font-size:var(--text-sm);text-decoration:none;
@ -134,7 +132,7 @@ window.Page_breeder = (() => {
${p.beschreibung ? `
<div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-4);margin-bottom:var(--space-4)">
<p style="margin:0;line-height:1.7;color:var(--c-text-secondary);white-space:pre-line">${_esc(p.beschreibung)}</p>
<p style="margin:0;line-height:1.7;color:var(--c-text-secondary);white-space:pre-line">${UI.escape(p.beschreibung)}</p>
</div>` : ''}
<!-- Zuchthunde -->
@ -157,7 +155,7 @@ window.Page_breeder = (() => {
display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('baby')} Aktuelle Würfe
</h2>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="flex-col-gap-3">
${p.wuerfe.map(w => _wurfCard(w)).join('')}
</div>
</div>` : ''}
@ -192,8 +190,8 @@ window.Page_breeder = (() => {
${p.website ? `
<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">Website</dt>
<dd style="margin:0"><a href="${_esc(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);word-break:break-all">${_esc(p.website)}</a></dd>
<dd style="margin:0"><a href="${UI.escape(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);word-break:break-all">${UI.escape(p.website)}</a></dd>
</div>` : ''}
${seit ? _dl('Züchter seit', seit) : ''}
</dl>
@ -201,7 +199,7 @@ window.Page_breeder = (() => {
<!-- Fotos / Gallery -->
${p.fotos?.length ? `
<div style="margin-bottom:var(--space-4)">
<div class="mb-4">
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700;
display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('images')} Galerie
@ -209,11 +207,11 @@ window.Page_breeder = (() => {
</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2)">
${p.fotos.map((ph, i) => `
<a href="${_esc(ph.url)}" target="_blank" rel="noopener noreferrer"
<a href="${UI.escape(ph.url)}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:${ph.primary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'};
aspect-ratio:1;position:relative">
<img src="${_esc(ph.thumb)}" alt="${_esc(ph.caption)}"
<img src="${UI.escape(ph.thumb)}" alt="${UI.escape(ph.caption)}"
loading="${i < 6 ? 'eager' : 'lazy'}"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'">
@ -221,12 +219,12 @@ window.Page_breeder = (() => {
color:white;font-size:9px;font-weight:700;border-radius:999px;padding:1px 6px">Logo</span>` : ''}
${ph.caption ? `<div style="position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.6));
color:white;font-size:10px;padding:12px 6px 4px;line-height:1.3">${_esc(ph.caption)}</div>` : ''}
color:white;font-size:10px;padding:12px 6px 4px;line-height:1.3">${UI.escape(ph.caption)}</div>` : ''}
</a>`).join('')}
</div>
</div>` : ''}
<div id="breeder-photos-section" style="display:none"></div>
<div id="breeder-photos-section" class="hidden"></div>
</div>`;
@ -251,18 +249,18 @@ window.Page_breeder = (() => {
const augeTest = h.health_tests?.find(t => t.test_typ === 'augen');
const testPills = [
hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${_esc(hdTest.ergebnis)}</span>` : '',
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${_esc(edTest.ergebnis)}</span>` : '',
hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${UI.escape(hdTest.ergebnis)}</span>` : '',
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${UI.escape(edTest.ergebnis)}</span>` : '',
augeTest ? `<span style="${_testPillStyle('clear','augen')}">Augen ✓</span>` : '',
].filter(Boolean).join('');
const titlePills = (h.titel || []).map(t =>
`<span style="background:var(--c-primary-light,#f5e6d3);color:var(--c-primary-dark,#a86e2e);
border-radius:999px;padding:1px 8px;font-size:10px;font-weight:700">${_esc(t)}</span>`
border-radius:999px;padding:1px 8px;font-size:10px;font-weight:700">${UI.escape(t)}</span>`
).join('');
const genBadge = h.gentests_total > 0
? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
? `<span class="text-xs-muted">
${h.gentests_clear}/${h.gentests_total} Gentests frei
</span>`
: '';
@ -271,12 +269,12 @@ window.Page_breeder = (() => {
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="color:var(--c-primary)">${gIcon}</span>
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">"${_esc(h.rufname)}"</span>` : ''}
<span class="text-primary">${gIcon}</span>
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">"${UI.escape(h.rufname)}"</span>` : ''}
${alter !== null ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs);margin-left:auto">${alter} J.</span>` : ''}
</div>
${h.farbe ? `<p style="margin:0;font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(h.farbe)}</p>` : ''}
${h.farbe ? `<p style="margin:0;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(h.farbe)}</p>` : ''}
${testPills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${testPills}</div>` : ''}
${titlePills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${titlePills}</div>` : ''}
${genBadge}
@ -318,16 +316,16 @@ window.Page_breeder = (() => {
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3) var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(eltern)}</span>
<span style="font-weight:700;font-size:var(--text-sm)">${UI.escape(eltern)}</span>
<span style="background:${sc}1a;color:${sc};border:1px solid ${sc}40;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${datum ? `<span>${UI.icon('calendar-dots')} ${_esc(datum)}</span>` : ''}
${datum ? `<span>${UI.icon('calendar-dots')} ${UI.escape(datum)}</span>` : ''}
${w.welpen_gesamt ? `<span>${UI.icon('dog')} ${w.welpen_verfuegbar ?? '?'}/${w.welpen_gesamt} verfügbar</span>` : ''}
${w.preis_spanne ? `<span>${UI.icon('currency-eur')} ${_esc(w.preis_spanne)}</span>` : ''}
${w.preis_spanne ? `<span>${UI.icon('currency-eur')} ${UI.escape(w.preis_spanne)}</span>` : ''}
</div>
${w.beschreibung ? `<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">${_esc(w.beschreibung)}</p>` : ''}
${w.beschreibung ? `<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">${UI.escape(w.beschreibung)}</p>` : ''}
</div>`;
}
@ -340,12 +338,12 @@ window.Page_breeder = (() => {
return `
<div>
<p style="margin:0 0 var(--space-2);font-size:var(--text-xs);font-weight:700;
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em">${_esc(label)}</p>
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em">${UI.escape(label)}</p>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${stats.map(r => `
<div style="display:flex;align-items:center;gap:6px;font-size:var(--text-sm)">
<span style="font-weight:700">${_esc(r.ergebnis || '—')}</span>
<span style="color:var(--c-text-muted)">${r.cnt}×</span>
<span style="font-weight:700">${UI.escape(r.ergebnis || '—')}</span>
<span class="text-muted">${r.cnt}×</span>
<span style="background:var(--c-border);border-radius:999px;height:6px;
width:${Math.round(r.cnt/total*80)+16}px;display:inline-block"></span>
</div>`).join('')}
@ -359,8 +357,8 @@ window.Page_breeder = (() => {
function _dl(label, value) {
if (!value) return '';
return `<div style="display:flex;gap:var(--space-2);align-items:baseline">
<dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">${_esc(label)}</dt>
<dd style="margin:0;font-size:var(--text-sm)">${_esc(String(value))}</dd>
<dt style="color:var(--c-text-secondary);min-width:110px;font-size:var(--text-sm);flex-shrink:0">${UI.escape(label)}</dt>
<dd style="margin:0;font-size:var(--text-sm)">${UI.escape(String(value))}</dd>
</div>`;
}
@ -377,16 +375,16 @@ window.Page_breeder = (() => {
const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos?.length) return;
section.innerHTML = `
<div style="margin-bottom:var(--space-4)">
<div class="mb-4">
<h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700">
${UI.icon('images')} Fotos
</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)">
${photos.map(ph => `
<a href="${_esc(ph.url||'')}" target="_blank" rel="noopener noreferrer"
<a href="${UI.escape(ph.url||'')}" target="_blank" rel="noopener noreferrer"
style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--c-border);aspect-ratio:1">
<img src="${_esc(ph.thumbnail_url||ph.url||'')}" alt="${_esc(ph.caption||'')}"
<img src="${UI.escape(ph.thumbnail_url||ph.url||'')}" alt="${UI.escape(ph.caption||'')}"
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'">
</a>`).join('')}

View file

@ -122,7 +122,7 @@ window.Page_chat = (() => {
el.innerHTML = convs.map(c => {
const initials = (c.partner_name || '?')[0].toUpperCase();
const preview = c.last_text
? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
? UI.escape(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
: '<em style="opacity:0.6">Noch keine Nachrichten</em>';
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
const badge = c.unread_count > 0
@ -138,7 +138,7 @@ window.Page_chat = (() => {
${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''}
</div>
<div class="chat-conv-info">
<div class="chat-conv-name">${_esc(c.partner_name)}</div>
<div class="chat-conv-name">${UI.escape(c.partner_name)}</div>
<div class="chat-conv-preview">${preview}</div>
</div>
<div class="chat-conv-meta">
@ -178,7 +178,7 @@ window.Page_chat = (() => {
</button>`}
<div style="position:relative;flex-shrink:0">
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" style="display:none"></span>
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" class="hidden"></span>
</div>
<span class="chat-thread-partner" id="chat-partner-name"></span>
</div>
@ -188,7 +188,7 @@ window.Page_chat = (() => {
</div>
</div>
<div class="chat-input-bar">
<input type="file" id="chat-photo-input" accept="image/*" style="display:none"
<input type="file" id="chat-photo-input" accept="image/*" class="hidden"
onchange="Page_chat._onPhotoSelected(this)">
<button class="chat-photo-btn" onclick="document.getElementById('chat-photo-input').click()" title="Foto senden">
<svg class="ph-icon"><use href="/icons/phosphor.svg#camera"></use></svg>
@ -332,10 +332,10 @@ window.Page_chat = (() => {
}
if (m.text) {
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +
_esc(m.text) +
UI.escape(m.text) +
(m.media_url ? `</div>` : '');
}
if (!bubbleContent) bubbleContent = _esc(m.text);
if (!bubbleContent) bubbleContent = UI.escape(m.text);
html += `
<div class="chat-bubble-row ${rowClass}">
@ -450,13 +450,6 @@ window.Page_chat = (() => {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/\n/g, '<br>');
}
// ----------------------------------------------------------
// Neue Nachricht — Freundesliste als Picker
// ----------------------------------------------------------

View file

@ -212,7 +212,7 @@ window.Page_diary = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
</button>
</div>
<div id="diary-stats-bar" class="diary-stats-bar" style="display:none"></div>
<div id="diary-stats-bar" class="diary-stats-bar hidden"></div>
<div id="diary-view-content">
<div id="diary-list"></div>
</div>
@ -295,7 +295,7 @@ window.Page_diary = (() => {
`;
card.innerHTML = `
<div style="font-size:1.8rem;flex-shrink:0;line-height:1">🐾</div>
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-primary-dark);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-1)">
@ -963,7 +963,7 @@ window.Page_diary = (() => {
// Hunde-Chips (bei mehreren Hunden)
const dogsHtml = dogIds.length > 1
? `<div class="diary-detail-dogs" style="margin-bottom:var(--space-3)">
? `<div class="diary-detail-dogs mb-3">
${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
@ -1279,7 +1279,7 @@ window.Page_diary = (() => {
value="${entry?.datum || today}" required>
</div>
<div class="form-group">
<label class="form-label">Titel <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Titel <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="text" name="titel"
value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
</div>
@ -1293,10 +1293,10 @@ window.Page_diary = (() => {
<div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid -->
<div id="diary-new-media-grid" class="diary-media-grid" style="display:none"></div>
<div id="diary-new-media-grid" class="diary-media-grid hidden"></div>
<!-- versteckter Input multiple für Mehrfachauswahl -->
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple style="display:none">
<input type="file" id="diary-media-input" accept="image/*,video/*,application/pdf" multiple class="hidden">
<!-- Einzelner Button iOS zeigt nativen Picker (Mediathek / Kamera / Datei) -->
<label for="diary-media-input" class="btn btn-secondary" style="cursor:pointer;display:flex;align-items:center;gap:var(--space-2);justify-content:center">
@ -1305,7 +1305,7 @@ window.Page_diary = (() => {
</label>
</div>
<div class="form-group" id="diary-location-group">
<label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Ort <span class="text-secondary">(optional)</span></label>
<!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
<div style="position:relative">
@ -1318,7 +1318,7 @@ window.Page_diary = (() => {
</div>
<!-- POI-Name + Aktionen -->
<div style="margin-top:var(--space-2)">
<div class="mt-2">
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
<div class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
@ -1341,7 +1341,7 @@ window.Page_diary = (() => {
${dogPickerHtml}
<div class="form-group" style="margin-top:var(--space-5)">
<input type="checkbox" name="is_milestone" id="diary-milestone-cb"
${entry?.is_milestone ? 'checked' : ''} style="display:none">
${entry?.is_milestone ? 'checked' : ''} class="hidden">
<button type="button" id="diary-milestone-btn"
class="diary-milestone-toggle${entry?.is_milestone ? ' diary-milestone-toggle--active' : ''}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
@ -1353,10 +1353,10 @@ window.Page_diary = (() => {
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="diary-form" class="btn btn-primary" style="width:100%">
<button type="submit" form="diary-form" class="btn btn-primary w-full">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
${isEdit ? `<button type="button" class="btn btn-danger" id="diary-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
</div>
@ -1843,32 +1843,32 @@ window.Page_diary = (() => {
<strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="flex-col-gap-3">
<label class="import-format-card" id="fmt-nsx">
<input type="radio" name="import-fmt" value="nsx" checked style="display:none">
<input type="radio" name="import-fmt" value="nsx" checked class="hidden">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">Synology NoteStation</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">.nsx-Datei aus dem NoteStation-Export</div>
<div class="text-xs-muted">.nsx-Datei aus dem NoteStation-Export</div>
</div>
</label>
<label class="import-format-card" id="fmt-csv">
<input type="radio" name="import-fmt" value="csv" style="display:none">
<input type="radio" name="import-fmt" value="csv" class="hidden">
<div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
</div>
<div>
<div style="font-weight:var(--weight-semibold)">CSV / Excel</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
<div class="text-xs-muted">Spalten: datum, titel, text, tags, gps_lat, gps_lon, is_milestone</div>
</div>
</label>
</div>
<div style="margin-top:var(--space-4)">
<div class="mt-4">
<label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" id="import-file-input"
accept=".nsx,.csv" style="cursor:pointer">
@ -1917,7 +1917,7 @@ window.Page_diary = (() => {
: await API.importData.csv(dogId, file);
const errHtml = res.errors?.length
? `<details style="margin-top:var(--space-2)"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
? `<details class="mt-2"><summary style="font-size:var(--text-xs);cursor:pointer">${res.errors.length} Fehler anzeigen</summary>
<pre style="font-size:var(--text-xs);white-space:pre-wrap;margin-top:var(--space-1)">${UI.escape(res.errors.join('\n'))}</pre></details>`
: '';
@ -1925,7 +1925,7 @@ window.Page_diary = (() => {
<div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-success)">
<strong>${res.imported} Einträge importiert</strong>
${res.skipped ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)"> · ${res.skipped} übersprungen</span>` : ''}
${res.skipped ? `<span class="text-sm-muted"> · ${res.skipped} übersprungen</span>` : ''}
${errHtml}
</div>`;
resultEl.style.display = 'block';

View file

@ -84,7 +84,7 @@ window.Page_dog_profile = (() => {
<div style="position:relative;display:inline-block;margin-bottom:var(--space-4);padding:4px">
${dog.foto_url
? `<div class="dp-avatar-ring">
<img src="${dog.foto_url}" alt="${_esc(dog.name)}" class="dp-avatar-img"
<img src="${dog.foto_url}" alt="${UI.escape(dog.name)}" class="dp-avatar-img"
style="transform:scale(${dog.foto_zoom||1}) translate(${dog.foto_offset_x||0}%,${dog.foto_offset_y||0}%)">
</div>`
: `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`}
@ -95,28 +95,28 @@ window.Page_dog_profile = (() => {
<!-- Name + Rasse -->
<h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
color:var(--c-text);margin:0 0 var(--space-1)">${UI.escape(dog.name)}</h2>
${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${UI.escape(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`}
<!-- Rassen-Community-Chip (wird async geladen) -->
<div id="dp-same-breed-chip" style="margin-bottom:var(--space-4)"></div>
<div id="dp-same-breed-chip" class="mb-4"></div>
<!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
margin-bottom:var(--space-5);text-align:left">
${geburtstag ? `
<div class="card" style="padding:var(--space-3)">
<div class="card p-3">
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Geburtstag</div>
<div style="font-weight:500;font-size:var(--text-sm)">${geburtstag}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<div class="text-xs-secondary">
${_calcAlter(dog.geburtstag)}
</div>
</div>
` : ''}
${dog.geschlecht ? `
<div class="card" style="padding:var(--space-3)">
<div class="card p-3">
<div class="dp-info-label">${dog.geschlecht === 'm' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-male"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>'} Geschlecht</div>
<div style="font-weight:500;font-size:var(--text-sm)">
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
@ -130,19 +130,19 @@ window.Page_dog_profile = (() => {
</div>
` : ''}
${dog.widerrist_cm ? `
<div class="card" style="padding:var(--space-3)">
<div class="card p-3">
<div class="dp-info-label"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#ruler"></use></svg> Widerrist</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.widerrist_cm} cm</div>
</div>
` : ''}
<div class="card" style="padding:var(--space-3)">
<div class="card p-3">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg> Transponder
</div>
${dog.chip_nr
? `<div style="font-size:var(--text-xs);font-weight:500;word-break:break-all">${_esc(dog.chip_nr)}</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">nicht eingetragen
? `<div style="font-size:var(--text-xs);font-weight:500;word-break:break-all">${UI.escape(dog.chip_nr)}</div>`
: `<div class="text-xs-muted">nicht eingetragen
<button class="btn btn-link btn-sm" id="dp-chip-edit-btn"
style="padding:0 0 0 var(--space-1);font-size:var(--text-xs)">Eintragen</button>
</div>`
@ -153,7 +153,7 @@ window.Page_dog_profile = (() => {
${dog.bio ? `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-5);text-align:left">
<p style="margin:0;color:var(--c-text-secondary);font-style:italic;line-height:1.6">
"${_esc(dog.bio)}"
"${UI.escape(dog.bio)}"
</p>
</div>
` : ''}
@ -230,12 +230,12 @@ window.Page_dog_profile = (() => {
<div class="card" style="margin-bottom:var(--space-5)">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600">Sitter-Zugang</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<div class="text-xs-secondary">
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.
</div>
</div>
<div id="dp-sitting-access" style="padding:var(--space-4)">Lade</div>
<div id="dp-sitting-access" class="p-4">Lade</div>
</div>
` : ''}
`;
@ -335,12 +335,12 @@ window.Page_dog_profile = (() => {
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use>
</svg>
${_esc(skill.exercise_name)}
${UI.escape(skill.exercise_name)}
</span>`;
};
const sitztBlock = sitzt.length ? `
<div style="margin-bottom:var(--space-3)">
<div class="mb-3">
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2);
text-transform:uppercase;letter-spacing:.04em">Sitzt</div>
@ -360,7 +360,7 @@ window.Page_dog_profile = (() => {
</div>` : '';
el.innerHTML = `
<div class="card" style="padding:var(--space-4)">
<div class="card p-4">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use>
@ -409,11 +409,11 @@ window.Page_dog_profile = (() => {
: '';
el.innerHTML = `
<div class="card" style="padding:var(--space-4)">
<div class="card p-4">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span style="font-size:1.1em">🛁</span>
<span style="font-size:var(--text-sm);font-weight:600">
Pflegetipps${data.rasse_name ? ` für ${_esc(data.rasse_name)}` : ''}
Pflegetipps${data.rasse_name ? ` für ${UI.escape(data.rasse_name)}` : ''}
</span>
</div>
@ -426,24 +426,24 @@ window.Page_dog_profile = (() => {
${t.saisonal_aktuell ? '🌸 Aktuell & Saisonal' : '💡 Tipp des Tages'}
</div>
<div style="font-weight:600;font-size:var(--text-sm);margin-bottom:4px">
${kat_icons[t.kategorie]||_ph('paw-print')} ${_esc(t.titel)}
${kat_icons[t.kategorie]||_ph('paw-print')} ${UI.escape(t.titel)}
</div>
<div style="font-size:12px;color:var(--c-text-secondary);margin-bottom:8px;
line-height:1.5">${_esc(t.beschreibung||'')}</div>
line-height:1.5">${UI.escape(t.beschreibung||'')}</div>
${t.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted)">
🔄 ${_esc(t.haeufigkeit)}</div>` : ''}
🔄 ${UI.escape(t.haeufigkeit)}</div>` : ''}
${t.materialien ? `<div style="font-size:11px;color:var(--c-text-muted)">
🛒 ${_esc(t.materialien)}</div>` : ''}
🛒 ${UI.escape(t.materialien)}</div>` : ''}
${t.schritte?.length ? `
<details style="margin-top:8px">
<summary style="font-size:12px;cursor:pointer;color:var(--c-primary);
font-weight:600">Anleitung anzeigen</summary>
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px;
color:var(--c-text);line-height:1.6">
${t.schritte.map(s=>`<li style="margin-bottom:3px">${_esc(s)}</li>`).join('')}
${t.schritte.map(s=>`<li style="margin-bottom:3px">${UI.escape(s)}</li>`).join('')}
</ol>
${t.tipp ? `<div style="margin-top:8px;font-size:11px;color:#a78bfa;
font-style:italic">💜 ${_esc(t.tipp)}</div>` : ''}
font-style:italic">💜 ${UI.escape(t.tipp)}</div>` : ''}
</details>` : ''}
</div>` : ''}
@ -457,29 +457,29 @@ window.Page_dog_profile = (() => {
const katTipps = data.tipps.filter(t=>t.kategorie===kat);
const katBadge = kat === 'Fell' ? pflegeArtBadge : '';
return `
<div style="margin-bottom:var(--space-3)">
<div class="mb-3">
<div style="font-size:11px;font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;margin-bottom:8px;display:flex;align-items:center">
${kat_icons[kat]||_ph('paw-print')} ${_esc(kat)}${katBadge}</div>
${kat_icons[kat]||_ph('paw-print')} ${UI.escape(kat)}${katBadge}</div>
${katTipps.map(tip => `
<details style="background:var(--c-surface-2);border-radius:8px;
padding:10px;margin-bottom:6px">
<summary style="font-size:var(--text-sm);font-weight:600;cursor:pointer;
list-style:none;display:flex;justify-content:space-between;
align-items:center">
${_esc(tip.titel)}
${UI.escape(tip.titel)}
${tip.saisonal_aktuell ? '<span style="font-size:10px;color:#10b981">● Aktuell</span>' : ''}
</summary>
<div style="margin-top:8px;font-size:12px;color:var(--c-text-secondary);
line-height:1.5">${_esc(tip.beschreibung||'')}</div>
line-height:1.5">${UI.escape(tip.beschreibung||'')}</div>
${tip.haeufigkeit ? `<div style="font-size:11px;color:var(--c-text-muted);
margin-top:4px">🔄 ${_esc(tip.haeufigkeit)}</div>` : ''}
margin-top:4px">🔄 ${UI.escape(tip.haeufigkeit)}</div>` : ''}
${tip.schritte?.length ? `
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px;line-height:1.6">
${tip.schritte.map(s=>`<li style="margin-bottom:3px">${_esc(s)}</li>`).join('')}
${tip.schritte.map(s=>`<li style="margin-bottom:3px">${UI.escape(s)}</li>`).join('')}
</ol>` : ''}
${tip.tipp ? `<div style="margin-top:6px;font-size:11px;color:#a78bfa;
font-style:italic">💜 ${_esc(tip.tipp)}</div>` : ''}
font-style:italic">💜 ${UI.escape(tip.tipp)}</div>` : ''}
</details>`).join('')}
</div>`;
}).join('')}
@ -499,12 +499,6 @@ window.Page_dog_profile = (() => {
});
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ----------------------------------------------------------
// SITTER-ZUGANG
// ----------------------------------------------------------
@ -527,8 +521,8 @@ window.Page_dog_profile = (() => {
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);background:var(--c-surface-2);border-radius:var(--radius-md);margin-bottom:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)">
<strong>${_esc(s.sitter_name)}</strong>
<span style="color:var(--c-text-muted)"> · bis ${_esc(s.valid_until)}</span>
<strong>${UI.escape(s.sitter_name)}</strong>
<span class="text-muted"> · bis ${UI.escape(s.valid_until)}</span>
</div>
<button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}"
style="color:var(--c-danger);padding:0">
@ -538,7 +532,7 @@ window.Page_dog_profile = (() => {
}
const friendOptions = friends.length
? friends.map(f => `<option value="${f.friend_id}">${_esc(f.friend_name)}</option>`).join('')
? friends.map(f => `<option value="${f.friend_id}">${UI.escape(f.friend_name)}</option>`).join('')
: '<option value="" disabled>Keine Freunde vorhanden</option>';
const today = new Date().toISOString().slice(0, 10);
@ -547,26 +541,26 @@ window.Page_dog_profile = (() => {
wrap.innerHTML = `
${activeHtml}
${friends.length ? `
<div style="margin-top:var(--space-3)">
<div class="mt-3">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-2);font-weight:600">Zugang gewähren</div>
<div style="display:grid;grid-template-columns:1fr auto;gap:var(--space-2);
align-items:end">
<div class="form-group" style="margin:0">
<label class="form-label" style="font-size:var(--text-xs)">Freund</label>
<label class="form-label text-xs">Freund</label>
<select class="form-control form-control-sm" id="sa-friend-select">
<option value="">Freund wählen</option>
${friendOptions}
</select>
</div>
<div class="form-group" style="margin:0">
<label class="form-label" style="font-size:var(--text-xs)">Gültig bis</label>
<label class="form-label text-xs">Gültig bis</label>
<input class="form-control form-control-sm" type="date" id="sa-until-input"
value="${defaultUntil}" min="${today}">
</div>
</div>
<button class="btn btn-primary btn-sm w-full" id="sa-grant-btn"
style="margin-top:var(--space-2)">
class="mt-2">
Zugang gewähren
</button>
</div>
@ -617,11 +611,11 @@ window.Page_dog_profile = (() => {
<div class="mb-3">
<label class="form-label">Chip-Nummer (15-stellig)</label>
<input id="chip-edit-input" class="form-control" type="text"
value="${_esc(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20">
value="${UI.escape(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20">
</div>`,
footer: `
<div class="w3-btn-stack">
<button class="btn btn-primary" id="chip-edit-save-btn" style="width:100%">Speichern</button>
<button class="btn btn-primary" id="chip-edit-save-btn" class="w-full">Speichern</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>`,
});
@ -666,20 +660,20 @@ window.Page_dog_profile = (() => {
<div class="photo-editor-controls">
<label class="form-label">Zoom</label>
<input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}"
style="width:100%">
class="w-full">
</div>
` : ''}
<label class="btn btn-secondary" style="cursor:pointer">
${UI.icon('upload-simple')} Neues Foto wählen
<input type="file" id="pe-file-input" accept="image/*" style="display:none">
<input type="file" id="pe-file-input" accept="image/*" class="hidden">
</label>
</div>
`;
const footer = `
<div class="w3-btn-stack">
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" style="width:100%">Speichern</button>` : ''}
<div style="display:flex;gap:var(--space-2)">
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" class="w-full">Speichern</button>` : ''}
<div class="flex-gap-2">
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div>
@ -843,15 +837,15 @@ window.Page_dog_profile = (() => {
<!-- Header -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px">
${dog.foto_url
? `<img src="${_esc(dog.foto_url)}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;
? `<img src="${UI.escape(dog.foto_url)}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,0.6);flex-shrink:0">`
: `<div style="width:52px;height:52px;border-radius:50%;background:rgba(196,132,58,0.2);
display:flex;align-items:center;justify-content:center;font-size:1.6rem;
flex-shrink:0;border:2px solid rgba(196,132,58,0.4)">🐾</div>`}
<div>
<div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${_esc(dog.name)}</div>
${metaLine ? `<div style="font-size:0.8rem;color:rgba(255,255,255,0.6);margin-top:2px">${_esc(metaLine)}</div>` : ''}
${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${_esc(wohnort)}</div>` : ''}
<div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${UI.escape(dog.name)}</div>
${metaLine ? `<div style="font-size:0.8rem;color:rgba(255,255,255,0.6);margin-top:2px">${UI.escape(metaLine)}</div>` : ''}
${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${UI.escape(wohnort)}</div>` : ''}
</div>
</div>
@ -860,13 +854,13 @@ window.Page_dog_profile = (() => {
<!-- Owner + QR -->
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px">
<div style="flex:1;min-width:0">
<div class="flex-1-min">
${ownerName ? `<div style="font-size:0.7rem;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px">Besitzer</div>
<div style="font-size:0.9rem;font-weight:600;color:rgba(255,255,255,0.85)">${_esc(ownerName)}</div>` : ''}
<div style="font-size:0.9rem;font-weight:600;color:rgba(255,255,255,0.85)">${UI.escape(ownerName)}</div>` : ''}
<div style="font-size:0.65rem;color:rgba(255,255,255,0.35);margin-top:8px">banyaro.app</div>
</div>
<div style="flex-shrink:0;text-align:center">
<img id="dp-vcard-qr" src="${_esc(qrUrl)}"
<img id="dp-vcard-qr" src="${UI.escape(qrUrl)}"
style="width:80px;height:80px;border-radius:10px;display:block"
alt="QR-Code">
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35);margin-top:4px">Profil öffnen</div>
@ -878,9 +872,9 @@ window.Page_dog_profile = (() => {
UI.modal.open({
title: 'Visitenkarte',
body: `
<div style="margin-bottom:var(--space-4)">${cardHtml}</div>
<div class="mb-4">${cardHtml}</div>
<p style="font-size:var(--text-xs);color:var(--c-text-secondary);text-align:center;margin-bottom:0">
QR-Code auf NFC-Tag oder Anhänger kleben jeder kann das Profil von ${_esc(dog.name)} sofort öffnen.
QR-Code auf NFC-Tag oder Anhänger kleben jeder kann das Profil von ${UI.escape(dog.name)} sofort öffnen.
</p>
`,
footer: `
@ -935,7 +929,7 @@ window.Page_dog_profile = (() => {
async function _showShareModal(dog) {
UI.modal.open({
title: `${_esc(dog.name)} teilen`,
title: `${UI.escape(dog.name)} teilen`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Erstelle einen Einladungslink, den du per WhatsApp, Signal oder E-Mail teilen kannst.
@ -952,7 +946,7 @@ window.Page_dog_profile = (() => {
<label class="form-label">Einladungslink</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" id="share-link-input" type="text" readonly
style="font-size:var(--text-xs)">
class="text-xs">
<button class="btn btn-secondary btn-sm" id="share-link-copy">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
@ -961,7 +955,7 @@ window.Page_dog_profile = (() => {
Dieser Link kann einmalig angenommen werden.
</p>
</div>
<div id="share-list-wrap" style="margin-top:var(--space-4)"></div>`,
<div id="share-list-wrap" class="mt-4"></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="share-create-btn">Link erstellen</button>`,
@ -1009,8 +1003,8 @@ window.Page_dog_profile = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)">
${s.shared_with_name
? `<strong>${_esc(s.shared_with_name)}</strong> · ${s.role}`
: `<em style="color:var(--c-text-muted)">Ausstehend</em> · ${s.role}`}
? `<strong>${UI.escape(s.shared_with_name)}</strong> · ${s.role}`
: `<em class="text-muted">Ausstehend</em> · ${s.role}`}
</div>
<button class="btn btn-link btn-sm share-revoke-btn" data-share-id="${s.id}"
style="color:var(--c-danger);padding:0">
@ -1056,7 +1050,7 @@ window.Page_dog_profile = (() => {
body: _formHTML(null, true),
footer: `
<div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">${UI.icon('dog')} Hund anlegen</button>
<button type="submit" form="dp-form" class="btn btn-primary w-full">${UI.icon('dog')} Hund anlegen</button>
<button type="button" class="btn btn-secondary" id="dp-form-cancel">Abbrechen</button>
</div>
`,
@ -1073,8 +1067,8 @@ window.Page_dog_profile = (() => {
body: _formHTML(dog, true),
footer: `
<div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
<div style="display:flex;gap:var(--space-2)">
<button type="submit" form="dp-form" class="btn btn-primary w-full">Speichern</button>
<div class="flex-gap-2">
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
<button type="button" id="dp-gedenken-btn"
style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -1101,19 +1095,19 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" type="text" name="name"
value="${_esc(dog?.name || '')}"
value="${UI.escape(dog?.name || '')}"
placeholder="z. B. Ban Yaro" required>
</div>
<div class="form-group">
<label class="form-label">
Rasse
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
${UI.help('Verknüpfe deine Rasse mit unserem Wiki für personalisierte Pflegetipps.')}
</label>
<input class="form-control" type="text" name="rasse"
id="dp-rasse-input"
value="${_esc(dog?.rasse || '')}"
value="${UI.escape(dog?.rasse || '')}"
list="dp-rasse-list"
autocomplete="off"
placeholder="z. B. Mischling, Golden Retriever…">
@ -1126,7 +1120,7 @@ window.Page_dog_profile = (() => {
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Geburtstag</label>
<input class="form-control" type="date" name="geburtstag"
@ -1142,7 +1136,7 @@ window.Page_dog_profile = (() => {
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Gewicht (kg)</label>
<input class="form-control" type="number" name="gewicht_kg"
@ -1160,14 +1154,14 @@ window.Page_dog_profile = (() => {
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">
Chip-Nummer
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
</label>
<input class="form-control" type="text" name="chip_nr"
value="${_esc(dog?.chip_nr || '')}" placeholder="15-stellig">
value="${UI.escape(dog?.chip_nr || '')}" placeholder="15-stellig">
</div>
<div></div>
</div>
@ -1175,7 +1169,7 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">
Felltyp
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')}
</label>
<select class="form-control" name="fell_typ">
@ -1192,10 +1186,10 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">
Bio / Steckbrief
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
</label>
<textarea class="form-control" name="bio" rows="2"
placeholder="Kurze Beschreibung…">${_esc(dog?.bio || '')}</textarea>
placeholder="Kurze Beschreibung…">${UI.escape(dog?.bio || '')}</textarea>
</div>
<div class="form-group">
@ -1216,7 +1210,7 @@ window.Page_dog_profile = (() => {
display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Foto auswählen
<input type="file" name="foto" accept="image/*" style="display:none"
<input type="file" name="foto" accept="image/*" class="hidden"
id="dp-form-foto">
</label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
@ -1225,7 +1219,7 @@ window.Page_dog_profile = (() => {
Rasse erkennen
</button>
<input type="file" accept="image/jpeg,image/png,image/webp"
id="dp-rasse-foto-input" style="display:none">
id="dp-rasse-foto-input" class="hidden">
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen
@ -1473,11 +1467,11 @@ window.Page_dog_profile = (() => {
title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div>
<p style="color:var(--c-text-secondary)">
<p class="text-secondary">
Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch.
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${_esc(data.hinweis)}</p>` : ''}
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${UI.escape(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
@ -1490,24 +1484,24 @@ window.Page_dog_profile = (() => {
return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between">
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${_esc(r.name)}</div>
<div class="rasse-result-name">${isTop ? '🐕 ' : ''}${UI.escape(r.name)}</div>
<span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div>
<div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div>
</div>
${r.beschreibung ? `<div class="rasse-result-desc">${_esc(r.beschreibung)}</div>` : ''}
${r.beschreibung ? `<div class="rasse-result-desc">${UI.escape(r.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);flex-wrap:wrap">
${isTop ? `<button class="btn btn-primary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
data-rasse="${UI.escape(r.name)}" class="flex-1">
Rasse übernehmen
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen"
data-rasse="${_esc(r.name)}" style="flex:1">
data-rasse="${UI.escape(r.name)}" class="flex-1">
Diese wählen
</button>`}
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki"
data-slug="${_esc(r.wiki_slug)}">
data-slug="${UI.escape(r.wiki_slug)}">
Im Wiki
</button>` : ''}
</div>
@ -1521,7 +1515,7 @@ window.Page_dog_profile = (() => {
<div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary)"> ${_esc(data.hinweis)}</div>` : ''}
color:var(--c-text-secondary)"> ${UI.escape(data.hinweis)}</div>` : ''}
${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center">
@ -1582,18 +1576,13 @@ window.Page_dog_profile = (() => {
: `${j} Jahr${j !== 1 ? 'e' : ''} alt`;
}
function _esc(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// HUNDEPASS
// ----------------------------------------------------------
async function _showPassportModal(dog) {
UI.modal.open({
title: `Hundepass — ${_esc(dog.name)}`,
title: `Hundepass — ${UI.escape(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px">
<div style="text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
@ -1636,7 +1625,7 @@ window.Page_dog_profile = (() => {
try {
data = await API.get(`/passport/${dog.id}`);
} catch (e) {
wrap.innerHTML = `<p style="color:var(--c-danger)">Fehler beim Laden: ${_esc(e.message)}</p>`;
wrap.innerHTML = `<p class="text-danger">Fehler beim Laden: ${UI.escape(e.message)}</p>`;
return;
}
@ -1666,25 +1655,25 @@ window.Page_dog_profile = (() => {
Bearbeiten
</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Blutgruppe</div>
<div class="text-xs-secondary">Blutgruppe</div>
<div id="pp-meta-blutgruppe" style="font-size:var(--text-sm);font-weight:500">
${_esc(meta.blutgruppe) || '<span style="color:var(--c-text-muted)">nicht eingetragen</span>'}
${UI.escape(meta.blutgruppe) || '<span class="text-muted">nicht eingetragen</span>'}
</div>
</div>
<div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div>
<div id="pp-meta-allergien" style="font-size:var(--text-sm)">
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'}
<div class="text-xs-secondary">Allergien</div>
<div id="pp-meta-allergien" class="text-sm">
${UI.escape(meta.allergien) || '<span class="text-muted">keine</span>'}
</div>
</div>
</div>
${meta.besonderheiten ? `
<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div>
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)">
${_esc(meta.besonderheiten)}
<div class="mt-3">
<div class="text-xs-secondary">Besonderheiten</div>
<div id="pp-meta-besonderheiten" class="text-sm">
${UI.escape(meta.besonderheiten)}
</div>
</div>` : ''}
</div>
@ -1708,13 +1697,13 @@ window.Page_dog_profile = (() => {
: vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}"
class="pp-data-row">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div>
<div class="flex-1">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(v.krankheit)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''}
${v.tierarzt ? ` · ${UI.escape(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${UI.escape(v.charge_nr)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
@ -1727,7 +1716,7 @@ window.Page_dog_profile = (() => {
</div>
<!-- Medikamente -->
<div class="card" style="padding:var(--space-4)">
<div class="card p-4">
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)">
@ -1745,13 +1734,13 @@ window.Page_dog_profile = (() => {
: meds.map(m => `
<div class="pp-med-row" data-id="${m.id}"
class="pp-data-row">
<div style="flex:1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div>
<div class="flex-1">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(m.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${m.dosierung ? `${_esc(m.dosierung)} · ` : ''}
${m.dosierung ? `${UI.escape(m.dosierung)} · ` : ''}
${m.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${_esc(m.notiz)}` : ''}
${m.notiz ? ` · ${UI.escape(m.notiz)}` : ''}
</div>
</div>
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}"
@ -1813,17 +1802,17 @@ window.Page_dog_profile = (() => {
<div class="form-group">
<label class="form-label">Blutgruppe</label>
<input id="pp-meta-bg" class="form-control" type="text"
value="${_esc(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
value="${UI.escape(current.blutgruppe || '')}" placeholder="z. B. DEA 1.1 positiv">
</div>
<div class="form-group">
<label class="form-label">Allergien</label>
<textarea id="pp-meta-al" class="form-control" rows="2"
placeholder="z. B. Hühnchen, Flohspeichel">${_esc(current.allergien || '')}</textarea>
placeholder="z. B. Hühnchen, Flohspeichel">${UI.escape(current.allergien || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Besonderheiten</label>
<textarea id="pp-meta-be" class="form-control" rows="2"
placeholder="z. B. Herzprobleme, Angstpatient">${_esc(current.besonderheiten || '')}</textarea>
placeholder="z. B. Herzprobleme, Angstpatient">${UI.escape(current.besonderheiten || '')}</textarea>
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
@ -1871,7 +1860,7 @@ window.Page_dog_profile = (() => {
<option value="DHPP (Kombi)">
</datalist>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Datum *</label>
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}">
@ -1938,13 +1927,13 @@ window.Page_dog_profile = (() => {
<input id="pp-med-dosierung" class="form-control" type="text"
placeholder="z. B. 1× täglich, 5 mg">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Von</label>
<input id="pp-med-von" class="form-control" type="date" value="${today}">
</div>
<div class="form-group">
<label class="form-label">Bis <span style="color:var(--c-text-muted)">(leer = dauerhaft)</span></label>
<label class="form-label">Bis <span class="text-muted">(leer = dauerhaft)</span></label>
<input id="pp-med-bis" class="form-control" type="date">
</div>
</div>
@ -2001,7 +1990,7 @@ window.Page_dog_profile = (() => {
</p>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input id="pp-sharelink-input" class="form-control" type="text" readonly
value="${_esc(url)}" style="font-size:var(--text-xs)">
value="${UI.escape(url)}" class="text-xs">
<button class="btn btn-secondary btn-sm" id="pp-sharelink-copy" style="flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button>
@ -2037,7 +2026,7 @@ window.Page_dog_profile = (() => {
return;
}
const name = _esc(data.dog_name);
const name = UI.escape(data.dog_name);
const km = data.gesamt_km || 0;
const konfetti = km > 100;
@ -2079,8 +2068,8 @@ window.Page_dog_profile = (() => {
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">Tagebucheinträge</div>
${data.fotos_gesamt > 0 ? `<div style="font-size:1.1rem;color:#a0c890;font-weight:700;margin-top:4px">📷 ${data.fotos_gesamt} Fotos</div>` : ''}
${data.gassi_tage > 0 ? `<div style="font-size:0.9rem;color:#888;margin-top:4px">🐾 ${data.gassi_tage} aktive Tage</div>` : ''}
${data.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${_esc(data.lieblings_monat)}</div>` : ''}
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${_esc(aktivitaet)}</div>` : ''}
${data.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${UI.escape(data.lieblings_monat)}</div>` : ''}
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${UI.escape(aktivitaet)}</div>` : ''}
`),
_card(`
<div style="font-size:2rem">🌡</div>
@ -2126,8 +2115,8 @@ window.Page_dog_profile = (() => {
</div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
<div id="dp-wrapped-card-container" style="width:100%;max-width:400px;color:#fff;">${cards[0]}</div>
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:44px;height:44px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center"></button>
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:44px;height:44px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>
</div>
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div>
`;
@ -2299,7 +2288,7 @@ window.Page_dog_profile = (() => {
// ----------------------------------------------------------
async function _showTimelineModal(dog) {
UI.modal.open({
title: `Lebens-Timeline — ${_esc(dog.name)}`,
title: `Lebens-Timeline — ${UI.escape(dog.name)}`,
body: `<div id="dp-timeline-body" style="min-height:200px;text-align:center;padding:var(--space-6)">
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use>
@ -2314,7 +2303,7 @@ window.Page_dog_profile = (() => {
data = await API.get(`/dogs/${dog.id}/timeline`);
} catch (e) {
const b = document.getElementById('dp-timeline-body');
if (b) b.innerHTML = `<p style="color:var(--c-danger)">Fehler: ${_esc(e.message)}</p>`;
if (b) b.innerHTML = `<p class="text-danger">Fehler: ${UI.escape(e.message)}</p>`;
return;
}
@ -2351,14 +2340,14 @@ window.Page_dog_profile = (() => {
for (const ev of events) {
const year = ev.datum ? ev.datum.substring(0, 4) : null;
if (year && year !== lastYear) {
html += `<div class="tl-year">${_esc(year)}</div>`;
html += `<div class="tl-year">${UI.escape(year)}</div>`;
lastYear = year;
}
const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
const big = ev.is_milestone;
let label = _esc(ev.titel);
let label = UI.escape(ev.titel);
if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`;
if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`;
if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`;
@ -2376,13 +2365,13 @@ window.Page_dog_profile = (() => {
box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div>
<div class="tl-card">
${big && ev.foto_url ? `
<div class="tl-foto" style="background-image:url(${_esc(ev.foto_url)})"></div>` : ''}
<div class="tl-foto" style="background-image:url(${UI.escape(ev.foto_url)})"></div>` : ''}
<div class="tl-meta">
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${kat.icon}"></use>
</svg>
${_esc(kat.label)}
${UI.escape(kat.label)}
</span>
<span class="tl-date">${_fmtDate(ev.datum)}</span>
</div>
@ -2451,8 +2440,8 @@ window.Page_dog_profile = (() => {
if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1
? `1 anderer ${_esc(hauptRasse)}-Halter in der App`
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`;
? `1 anderer ${UI.escape(hauptRasse)}-Halter in der App`
: `${data.count} andere ${UI.escape(hauptRasse)}-Halter in der App`;
el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn">
@ -2498,7 +2487,7 @@ window.Page_dog_profile = (() => {
</form>`,
footer: `
<div class="w3-btn-stack">
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary" style="width:100%">
<button type="submit" form="gedenken-form" id="gedenken-save-btn" class="btn btn-primary w-full">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
Gedenkseite erstellen
</button>
@ -2550,22 +2539,22 @@ window.Page_dog_profile = (() => {
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.km_total}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">km zusammen</div>
<div class="text-xs-secondary">km zusammen</div>
</div>` : ''}
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.diary_count}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Tagebucheinträge</div>
<div class="text-xs-secondary">Tagebucheinträge</div>
</div>` : ''}
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.media_count}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Fotos</div>
<div class="text-xs-secondary">Fotos</div>
</div>` : ''}
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary)" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-heart"></use></svg>
<div style="font-size:var(--text-xl);font-weight:800;color:var(--c-primary)">${d.gemeinsam_tage}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">gemeinsame Tage</div>
<div class="text-xs-secondary">gemeinsame Tage</div>
</div>` : ''}
</div>`;
@ -2596,8 +2585,8 @@ window.Page_dog_profile = (() => {
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
</div>
</div>
<div id="gedenk-ki-wrap" style="margin-top:var(--space-4)">
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%">
<div id="gedenk-ki-wrap" class="mt-4">
<button id="gedenk-ki-btn" class="btn btn-secondary w-full">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Persönlichen Abschiedstext erstellen
</button>

View file

@ -21,14 +21,6 @@ window.Page_ernaehrung = (() => {
// ------------------------------------------------------------------
// Escape helper
// ------------------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ------------------------------------------------------------------
// LIFECYCLE
@ -156,17 +148,17 @@ window.Page_ernaehrung = (() => {
<div class="ern-field">
<label> Gewicht (kg)</label>
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
value="${_esc(gewichtDefault)}" placeholder="15">
value="${UI.escape(gewichtDefault)}" placeholder="15">
</div>
<div class="ern-field">
<label>🎂 Alter (Jahre)</label>
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
value="${_esc(alterDefault)}" placeholder="3">
value="${UI.escape(alterDefault)}" placeholder="3">
</div>
</div>
<!-- Aktivität als Pill-Buttons -->
<div style="margin-bottom:var(--space-4)">
<div class="mb-4">
<div class="ern-section-label">🏃 Aktivität</div>
<div class="ern-pill-group">
<button class="ern-pill" data-akt="gering">🛋 Gemütlich</button>
@ -209,7 +201,7 @@ window.Page_ernaehrung = (() => {
<div class="by-form-group" style="margin:0">
<label class="by-label">Marke / Produkt</label>
<input id="ern-prof-marke" type="text" class="by-input"
value="${_esc(_profil.marke)}" placeholder="z. B. Royal Canin">
value="${UI.escape(_profil.marke)}" placeholder="z. B. Royal Canin">
</div>
<div class="by-form-group" style="margin:0">
<label class="by-label">Portionen pro Tag</label>
@ -219,7 +211,7 @@ window.Page_ernaehrung = (() => {
<div class="by-form-group" style="margin:0">
<label class="by-label">Notizen</label>
<textarea id="ern-prof-notizen" class="by-input" rows="2"
placeholder="Besonderheiten, Allergien...">${_esc(_profil.notizen)}</textarea>
placeholder="Besonderheiten, Allergien...">${UI.escape(_profil.notizen)}</textarea>
</div>
<button class="btn btn-secondary" id="ern-prof-save-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>
@ -288,13 +280,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🌾 Trockenfutter</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
<div class="text-sm-secondary">
(~350 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${trocken} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
<div class="text-sm-secondary">
= ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends
</div>
</div>
@ -302,13 +294,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥫 Nassfutter</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
<div class="text-sm-secondary">
(~85 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${nass} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
<div class="text-sm-secondary">
= ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends
</div>
</div>
@ -316,13 +308,13 @@ window.Page_ernaehrung = (() => {
<div style="background:var(--c-surface);border-radius:var(--radius-md);
padding:var(--space-3);border:1px solid var(--c-border)">
<div style="font-weight:600;margin-bottom:4px">🥩 BARF</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
<div class="text-sm-secondary">
(~150 kcal/100g)
</div>
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
${barf} g / Tag
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
<div class="text-sm-secondary">
= ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends
</div>
</div>
@ -482,8 +474,8 @@ window.Page_ernaehrung = (() => {
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:1.4rem">${item.emoji}</span>
<div>
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${_esc(item.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-danger)">${_esc(item.grund)}</div>
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${UI.escape(item.name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-danger)">${UI.escape(item.grund)}</div>
</div>
</div>
</div>
@ -511,7 +503,7 @@ window.Page_ernaehrung = (() => {
border:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
Der KI-Futterberater beantwortet Ernährungsfragen für
<strong>${_esc(dog?.name || 'deinen Hund')}</strong>.
<strong>${UI.escape(dog?.name || 'deinen Hund')}</strong>.
Bei Gesundheitsfragen immer den Tierarzt zurate ziehen.
</div>
@ -524,8 +516,8 @@ window.Page_ernaehrung = (() => {
'Welche Leckerlis sind gesund?',
].map(q => `
<button class="btn btn-sm btn-secondary ern-ki-vorschlag"
data-q="${_esc(q)}"
style="font-size:var(--text-xs)">${_esc(q)}</button>
data-q="${UI.escape(q)}"
class="text-xs">${UI.escape(q)}</button>
`).join('')}
</div>
@ -533,7 +525,7 @@ window.Page_ernaehrung = (() => {
<div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div>
<!-- Eingabe -->
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
<textarea id="ern-ki-frage" class="by-input" rows="2"
placeholder="Deine Frage zur Ernährung..."
style="flex:1;resize:vertical"></textarea>
@ -577,7 +569,7 @@ window.Page_ernaehrung = (() => {
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-2)">
<div style="background:var(--c-primary);color:#fff;border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);max-width:80%;font-size:var(--text-sm)">
${_esc(frage)}
${UI.escape(frage)}
</div>
</div>
`);
@ -586,7 +578,7 @@ window.Page_ernaehrung = (() => {
// KI-Antwort Placeholder
const placeholderId = `ern-ki-placeholder-${Date.now()}`;
chatEl.insertAdjacentHTML('beforeend', `
<div id="${placeholderId}" style="margin-bottom:var(--space-3)">
<div id="${placeholderId}" class="mb-3">
<div style="background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);color:var(--c-text-muted)">
@ -617,7 +609,7 @@ window.Page_ernaehrung = (() => {
}
}
const antwortHtml = _esc(antwort)
const antwortHtml = UI.escape(antwort)
.replace(/\n\n/g, '</p><p style="margin:var(--space-1) 0">')
.replace(/\n/g, '<br>');
@ -746,7 +738,7 @@ window.Page_ernaehrung = (() => {
const dl = document.getElementById('vert-futter-datalist');
if (!dl) return;
const names = [...new Set((list || []).map(e => e.futter_name))];
dl.innerHTML = names.map(n => `<option value="${_esc(n)}">`).join('');
dl.innerHTML = names.map(n => `<option value="${UI.escape(n)}">`).join('');
}).catch(() => {});
setTimeout(() => {
@ -905,7 +897,7 @@ window.Page_ernaehrung = (() => {
try {
data = await API.dogs.futterAnalyse(dog.id);
} catch (_) {
analyseEl.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Analyse nicht verfügbar.</p>`;
analyseEl.innerHTML = `<p class="text-sm-muted">Analyse nicht verfügbar.</p>`;
return;
}
@ -950,7 +942,7 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-warning,#f59e0b);margin-top:1px">
<use href="/icons/phosphor.svg#warning-circle"></use>
</svg>
<span>${_esc(data.hinweis)}</span>
<span>${UI.escape(data.hinweis)}</span>
</div>
` : '';
@ -978,7 +970,7 @@ window.Page_ernaehrung = (() => {
return `<span style="font-size:10px;font-weight:600;padding:2px 6px;
border-radius:999px;border:1px solid ${chipColor};
color:${chipColor};white-space:nowrap">
${_esc(KAT_LABELS[kat] || kat)} ×${cnt}
${UI.escape(KAT_LABELS[kat] || kat)} ×${cnt}
</span>`;
}).join('');
return `
@ -988,17 +980,17 @@ window.Page_ernaehrung = (() => {
<div style="min-width:0;flex:1">
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(f.name)}
${UI.escape(f.name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(TYP_LABELS[f.typ] || f.typ)} &middot; ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
<div class="text-xs-muted">
${UI.escape(TYP_LABELS[f.typ] || f.typ)} &middot; ${f.mahlzeiten} Mahlzeit${f.mahlzeiten !== 1 ? 'en' : ''}
${f.status !== 'neu' ? `&middot; <span style="color:var(--c-success,#22c55e)">+${f.positiv}</span> / <span style="color:var(--c-danger,#ef4444)">-${f.negativ}</span>` : ''}
</div>
${katChips ? `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">${katChips}</div>` : ''}
</div>
<span style="flex-shrink:0;font-size:var(--text-xs);font-weight:700;
color:${cfg.color};white-space:nowrap">
${_esc(cfg.label)}
${UI.escape(cfg.label)}
</span>
</div>
`;
@ -1084,10 +1076,10 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:var(--c-primary)">
<use href="/icons/phosphor.svg#bowl-food"></use>
</svg>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.futter_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(item.datum)} ${_esc(item.uhrzeit)}
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm)">${UI.escape(item.futter_name)}</div>
<div class="text-xs-muted">
${UI.escape(item.datum)} ${UI.escape(item.uhrzeit)}
${item.menge_g ? ` &middot; ${item.menge_g} g` : ''}
</div>
</div>
@ -1110,13 +1102,13 @@ window.Page_ernaehrung = (() => {
<svg class="ph-icon" aria-hidden="true" style="flex-shrink:0;color:${col}">
<use href="/icons/phosphor.svg#heartbeat"></use>
</svg>
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm);color:${col}">
${_esc(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)}
${UI.escape(REAK_LABELS[item.reaktion_typ] || item.reaktion_typ)}
<span style="font-weight:400;color:var(--c-text-muted)">(${item.intensitaet}/5)</span>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(item.datum)} ${_esc(item.uhrzeit)}
<div class="text-xs-muted">
${UI.escape(item.datum)} ${UI.escape(item.uhrzeit)}
</div>
</div>
<button class="btn-icon vert-del-reaktion" data-id="${item.id}"

View file

@ -32,16 +32,17 @@ window.Page_erste_hilfe = (() => {
land: 'Österreich',
flag: 'AT',
eintraege: [
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+431 4064343', display: '+43 1 4064343' },
{ label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' },
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+4314064343', display: '+43 1 406 43 43' },
{ label: 'VetMedUni Wien — Kleintier-Notdienst (24h)', tel: '+431250776900', display: '+43 1 25077-6900' },
],
},
{
land: 'Schweiz',
flag: 'CH',
eintraege: [
{ label: 'Tox Info Suisse (Tiergiftnotruf)', tel: null, display: 'TODO: Nummer einfügen (ggf. 145)' },
{ label: 'Tierspital Zürich', tel: null, display: 'TODO: Nummer einfügen' },
{ label: 'Tox Info Suisse (in CH gratis)', tel: '145', display: '145 (in CH)' },
{ label: 'Tox Info Suisse (international)', tel: '+41442515151', display: '+41 44 251 51 51' },
{ label: 'Tierspital Zürich — Kleintier-Notfall (24h)', tel: '+41446358337', display: '+41 44 635 83 37' },
],
},
];
@ -253,13 +254,13 @@ window.Page_erste_hilfe = (() => {
</div>
${KATEGORIEN.map(k => `
<div class="eh-tab-panel" id="eh-panel-${k.id}" style="display:none">
<div class="eh-tab-panel" id="eh-panel-${k.id}" class="hidden">
${k.eintraege.map((e, i) => _renderEintrag(e, k.id, i, k.color)).join('')}
</div>
`).join('')}
<div style="margin-top:var(--space-6);padding:var(--space-4);background:var(--c-surface-2);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.6">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#info"></use></svg>
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#info"></use></svg>
Diese Inhalte ersetzen keinen Tierarztbesuch. Im Zweifel immer sofort zum Tierarzt oder den tierärztlichen Notdienst anrufen.
</div>
</div>
@ -311,7 +312,7 @@ window.Page_erste_hilfe = (() => {
<div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:var(--space-1)">
${g.flag} · ${g.land}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<div class="flex-col-gap-2">
${g.eintraege.map(renderEintrag).join('')}
</div>
</div>
@ -323,7 +324,7 @@ window.Page_erste_hilfe = (() => {
<svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg>
Tiergiftzentralen jetzt anrufen
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="flex-col-gap-3">
${gruppen}
</div>
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)">
@ -345,7 +346,7 @@ window.Page_erste_hilfe = (() => {
return `
<div class="card" style="padding:0;overflow:hidden;margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);background:var(--c-surface-2);font-weight:var(--weight-semibold);font-size:var(--text-sm);display:flex;align-items:center;gap:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#list-bullets"></use></svg>
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#list-bullets"></use></svg>
Schnellübersicht: Was tun bei
</div>
<div style="overflow-x:auto">
@ -485,7 +486,7 @@ window.Page_erste_hilfe = (() => {
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>

View file

@ -75,7 +75,7 @@ window.Page_events = (() => {
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<div style="flex:1"></div>
<div class="flex-1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div>
@ -102,7 +102,7 @@ window.Page_events = (() => {
</div>
<div class="events-list" id="ev-list"></div>
<div class="events-map" id="ev-map" style="display:none"></div>
<div class="events-map" id="ev-map" class="hidden"></div>
`;
_container.addEventListener('click', _onClick);
@ -231,7 +231,7 @@ window.Page_events = (() => {
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted)" onclick="event.stopPropagation()">
title="Notiz" class="text-muted" onclick="event.stopPropagation()">
${_icon('note-pencil')}</button>` : ''}
</div>
</div>
@ -248,8 +248,10 @@ window.Page_events = (() => {
await UI.loadLeaflet(true); // true = mit MarkerCluster
if (!_map) {
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
_map = await UI.map.create('ev-map', {
center: [51.1657, 10.4515], zoom: 6,
zoomControl: true, attributionControl: false,
});
}
// Cluster-Gruppe aufräumen und neu befüllen
@ -266,12 +268,8 @@ window.Page_events = (() => {
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.leafletMarker() nicht anwendbar
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg);color:#fff"><svg style="width:14px;height:14px;transform:rotate(45deg);fill:currentColor" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#${typ.icon}"></use></svg></div>`,
iconSize: [32, 32], iconAnchor: [16, 32],
});
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.map.svgMarker mit custom HTML
const html = `<div style="width:32px;height:32px;border-radius:50% 50% 50% 0;background:${color};border:2px solid #fff;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transform:rotate(-45deg);color:#fff"><svg style="width:14px;height:14px;transform:rotate(45deg);fill:currentColor" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#${typ.icon}"></use></svg></div>`;
const popup = `
<div style="min-width:180px">
<strong>${UI.escape(ev.titel)}</strong><br>
@ -282,7 +280,7 @@ window.Page_events = (() => {
style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
</div>
`;
const m = L.marker([ev.lat, ev.lon], { icon }).bindPopup(popup);
const m = UI.map.svgMarker(ev.lat, ev.lon, html, { size: 32, anchorY: 32 }).bindPopup(popup);
_clusterGroup.addLayer(m);
_markers.push(m);
bounds.push([ev.lat, ev.lon]);
@ -496,7 +494,7 @@ window.Page_events = (() => {
<label class="form-label">GPS-Position</label>
<div id="ev-location-picker"></div>
</div>
<div class="form-group" style="margin-top:var(--space-3)">
<div class="form-group mt-3">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
</div>
@ -509,10 +507,10 @@ window.Page_events = (() => {
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" style="width:100%">
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" class="w-full">
${isEdit ? 'Speichern' : 'Event erstellen'}
</button>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
${isEdit ? `<button type="button" class="btn btn-danger" id="ev-form-delete">Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div>
@ -672,7 +670,7 @@ window.Page_events = (() => {
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<svg class="ph-icon" aria-hidden="true" class="text-primary"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>

View file

@ -71,7 +71,7 @@ window.Page_expenses = (() => {
if (dogs.length < 2) return '';
const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => `
<button class="exp-dog-pill${_selectedDogId === d.id ? ' active' : ''}" data-dog="${d.id ?? ''}">
${d.id ? UI.icon('paw-print') : ''} ${_esc(d.name)}
${d.id ? UI.icon('paw-print') : ''} ${UI.escape(d.name)}
</button>`).join('');
return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`;
}
@ -87,7 +87,7 @@ window.Page_expenses = (() => {
</div>
${_dogSelectorHtml()}
<div id="exp-content"></div>
<button class="exp-fab" id="exp-fab" title="Neue Ausgabe">
<button class="list-fab" id="exp-fab" title="Neue Ausgabe">
${UI.icon('plus')}
</button>
`;
@ -162,7 +162,7 @@ window.Page_expenses = (() => {
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-kachel-betrag" style="color:var(--c-primary)">${_fmt(jahr)}</div>
<div class="exp-kachel-betrag text-primary">${_fmt(jahr)}</div>
<div class="exp-kachel-label">${k.label}</div>
${monatLine}
<div class="exp-kachel-add">${UI.icon('plus')} eintragen</div>
@ -283,28 +283,28 @@ window.Page_expenses = (() => {
const datum = new Date(e.datum + 'T00:00:00')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
const dogBadge = e.dog_name
? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(e.dog_name)}</span>`
? `<span>${UI.icon('paw-print')} ${UI.escape(e.dog_name)}</span>`
: '';
const notiz = e.notiz
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>`
? `<div class="list-item-text">${UI.escape(e.notiz)}</div>`
: '';
return `
<div class="exp-entry" data-id="${e.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">
<div class="list-item-card list-item-card--clickable exp-entry" data-id="${e.id}">
<div class="list-item-meta-badge" style="--meta-color:${k.color}">
${UI.icon(k.icon)}
</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-datum">${datum}</span>
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
</div>
<div class="list-item-body">
<div class="list-item-title">${k.label}</div>
${notiz}
<div class="list-item-meta-row">
<span>${datum}</span>
${dogBadge ? `· ${dogBadge}` : ''}
</div>
</div>
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div>
<button class="exp-entry-del" data-del="${e.id}" title="Löschen"
aria-label="Eintrag löschen">
<div class="list-item-amount list-item-amount--negative">${_fmt(e.betrag)}</div>
<div class="list-item-actions">
<button class="list-item-action-btn list-item-action-btn--danger exp-entry-del"
data-del="${e.id}" title="Löschen" aria-label="Eintrag löschen">
${UI.icon('trash')}
</button>
</div>
@ -313,15 +313,15 @@ window.Page_expenses = (() => {
return `
<div class="exp-month-group">
<div class="exp-month-header">
<span class="exp-month-title">${titel}</span>
<span class="exp-month-summe">${_fmt(summe)}</span>
<div class="list-group-header" style="display:flex;justify-content:space-between;align-items:baseline">
<span>${titel}</span>
<span style="text-transform:none;font-weight:700;color:var(--c-text)">${_fmt(summe)}</span>
</div>
${rows}
</div>`;
}).join('');
el.innerHTML = `<div class="exp-list">${html}</div><div style="height:80px"></div>`;
el.innerHTML = `<div class="list-shell">${html}</div><div style="height:80px"></div>`;
// Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
el.querySelectorAll('.exp-entry').forEach(row => {
@ -372,30 +372,26 @@ window.Page_expenses = (() => {
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '—';
return `
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-kat">${k.label}</span>
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
</div>
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
<div class="exp-recurring-next">
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
<div class="list-item-card${r.aktiv ? '' : ' list-item-card--inactive'}" data-rid="${r.id}">
<div class="list-item-meta-badge" style="--meta-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="list-item-body">
<div class="list-item-title">${k.label}</div>
${r.notiz ? `<div class="list-item-text">${UI.escape(r.notiz)}</div>` : ''}
<div class="list-item-meta-row">
<span>${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
· <span>${UI.icon('calendar')} ${naechste}</span>
${r.dog_name ? `· <span>${UI.icon('paw-print')} ${UI.escape(r.dog_name)}</span>` : ''}
${!r.aktiv ? '· <span>Pausiert</span>' : ''}
</div>
</div>
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(r.aktiv ? 'pause' : 'play')}
</button>
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
title="Löschen">${UI.icon('trash')}</button>
</div>
<div class="list-item-amount list-item-amount--negative">${_fmt(r.betrag)}</div>
<div class="list-item-actions">
<button class="list-item-action-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(r.aktiv ? 'pause' : 'play')}
</button>
<button class="list-item-action-btn list-item-action-btn--danger exp-recurring-del" data-rid="${r.id}"
title="Löschen">${UI.icon('trash')}</button>
</div>
</div>`;
}).join('');
@ -407,7 +403,7 @@ window.Page_expenses = (() => {
</button>
</div>
${recurring.length
? `<div class="exp-list">${cards}</div>`
? `<div class="list-shell">${cards}</div>`
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
title: 'Keine Daueraufträge',
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
@ -448,7 +444,7 @@ window.Page_expenses = (() => {
].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${UI.escape(d.name)}</option>`
).join('');
const body = `
@ -458,9 +454,8 @@ window.Page_expenses = (() => {
<select class="form-control" name="kategorie">${katOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Betrag ()</label>
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
value="${r?.betrag || ''}" placeholder="0,00" required>
<label class="form-label">Betrag</label>
${UI.moneyInput({ name: 'betrag', value: r?.betrag ?? '', required: true })}
</div>
<div class="form-group">
<label class="form-label">Häufigkeit</label>
@ -477,15 +472,15 @@ window.Page_expenses = (() => {
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
<label class="form-label">Hund <span class="text-muted">(optional)</span></label>
<select class="form-control" name="dog_id">
<option value="">Kein Hund</option>${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
<label class="form-label">Bezeichnung <span class="text-muted">(optional)</span></label>
<input class="form-control" type="text" name="notiz"
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
value="${UI.escape(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
</div>
</form>`;
@ -501,7 +496,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(e.target);
const payload = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
betrag: UI.parseMoney(fd.betrag),
haeufigkeit: fd.haeufigkeit,
startdatum: fd.startdatum,
notiz: fd.notiz || null,
@ -688,13 +683,13 @@ window.Page_expenses = (() => {
const defaultDogId = entry?.dog_id ?? _selectedDogId;
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${_esc(d.name)}</option>`
`<option value="${d.id}"${defaultDogId === d.id ? ' selected' : ''}>${UI.escape(d.name)}</option>`
).join('');
// Kategorie-Kacheln statt Dropdown
const katKacheln = KATEGORIEN.map(k => `
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}">
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} style="display:none">
<input type="radio" name="kategorie" value="${k.id}" ${selKat === k.id ? 'checked' : ''} class="hidden">
<span class="exp-kat-tile-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
<span class="exp-kat-tile-label">${k.label}</span>
</label>`).join('');
@ -707,15 +702,10 @@ window.Page_expenses = (() => {
<div class="exp-kat-grid">${katKacheln}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Betrag</label>
<div class="exp-betrag-wrap">
<span class="exp-betrag-prefix"></span>
<input type="number" name="betrag" class="form-control exp-betrag-input"
value="${entry?.betrag || ''}" min="0.01" step="0.01"
placeholder="0,00" required>
</div>
${UI.moneyInput({ name: 'betrag', value: entry?.betrag ?? '', required: true })}
</div>
<div class="form-group" style="margin-bottom:0">
<label class="form-label">Datum</label>
@ -735,7 +725,7 @@ window.Page_expenses = (() => {
<div class="form-group">
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
<input type="text" name="notiz" class="form-control"
value="${_esc(entry?.notiz || '')}"
value="${UI.escape(entry?.notiz || '')}"
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
</div>
@ -810,7 +800,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(ev.target);
const payload = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
betrag: UI.parseMoney(fd.betrag),
datum: fd.datum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
@ -862,14 +852,5 @@ window.Page_expenses = (() => {
return Math.round(val) + ' €';
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init, refresh };
})();

View file

@ -39,12 +39,7 @@ window.Page_forum = (() => {
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _fmtDate(iso) {
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
@ -99,7 +94,7 @@ window.Page_forum = (() => {
<h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions">
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''}
<button class="btn btn-ghost btn-sm" id="forum-rules-btn" title="Regeln & Netiquette" style="color:var(--c-text-muted)">${UI.icon('info')} Regeln</button>
<button class="btn btn-ghost btn-sm" id="forum-rules-btn" title="Regeln & Netiquette" class="text-muted">${UI.icon('info')} Regeln</button>
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
</div>
</div>
@ -108,7 +103,7 @@ window.Page_forum = (() => {
<div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
data-kat="${k.key}"><span class="by-tab-text">${UI.escape(k.label)}</span></button>
`).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
@ -217,7 +212,7 @@ window.Page_forum = (() => {
.format(new Date(+year, +month - 1, 1));
const top = data.top?.[0];
const winnerLine = top
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
? `🥇 ${UI.escape(top.name)}${top.rasse ? ` · ${UI.escape(top.rasse)}` : ''}`
: 'Noch keine Stimmen';
const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
@ -227,7 +222,7 @@ window.Page_forum = (() => {
<div class="forum-hdm-tile" id="forum-hdm-tile">
<div class="forum-hdm-tile-trophy">🏆</div>
<div class="forum-hdm-tile-body">
<div class="forum-hdm-tile-title">Hund des Monats · ${_esc(monthName)}</div>
<div class="forum-hdm-tile-title">Hund des Monats · ${UI.escape(monthName)}</div>
<div class="forum-hdm-tile-winner">${winnerLine}</div>
<div class="forum-hdm-tile-meta">${metaLine}</div>
</div>
@ -251,16 +246,16 @@ window.Page_forum = (() => {
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
@ -280,7 +275,7 @@ window.Page_forum = (() => {
<div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off"
style="font-size:var(--text-sm)">
class="text-sm">
</div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)}
@ -291,7 +286,7 @@ window.Page_forum = (() => {
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
<div class="hdm-monat">${UI.escape(monthName)}</div>
</div>
${voteHint}
<div class="hdm-section">
@ -320,16 +315,16 @@ window.Page_forum = (() => {
grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<div class="hdm-vote-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer text-xs-muted">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div class="text-xs-muted">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
@ -411,8 +406,8 @@ window.Page_forum = (() => {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
<p class="text-secondary">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary mt-4" id="forum-first-btn">
Ersten Beitrag erstellen
</button>
</div>`;
@ -443,31 +438,31 @@ window.Page_forum = (() => {
function _threadCardHTML(t) {
const preview = t.text_preview
? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
? UI.escape(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
: '';
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview
? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview)
? `<div class="forum-card-thumb forum-card-thumb--video" style="display:flex;align-items:center;justify-content:center;background:var(--c-surface-2)">${UI.icon('video-camera')}</div>`
: `<img class="forum-card-thumb" src="${_esc(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${_esc(t.foto_preview_url)} 800w" sizes="120px"` : ''}
: `<img class="forum-card-thumb" src="${UI.escape(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${UI.escape(t.foto_preview_url)} 800w" sizes="120px"` : ''}
alt="" loading="lazy"
onerror="this.src='${_esc(t.foto_preview)}'">`
onerror="this.src='${UI.escape(t.foto_preview)}'">`
: '';
return `
<div class="forum-thread-card" data-id="${t.id}">
<div class="forum-card-top">
<span class="forum-category-badge forum-category-badge--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
<span class="forum-category-badge forum-category-badge--${UI.escape(t.kategorie)}">${UI.escape(t.kategorie)}</span>
${pinBadge}${lockBadge}
</div>
<div class="forum-card-content">
<div class="forum-card-main">
<div class="forum-card-title">${_esc(t.titel)}</div>
<div class="forum-card-title">${UI.escape(t.titel)}</div>
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
<div class="forum-card-meta">
<span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
<span>${UI.icon('user')} ${UI.escape(t.autor_name || 'Unbekannt')}</span>
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
@ -493,7 +488,7 @@ window.Page_forum = (() => {
document.getElementById('forum-main').innerHTML = `
<div style="text-align:center;padding:var(--space-8)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
<p style="color:var(--c-text-secondary)">Keine Ergebnisse für ${_esc(q)}"</p>
<p class="text-secondary">Keine Ergebnisse für ${UI.escape(q)}"</p>
</div>`;
return;
}
@ -533,19 +528,19 @@ window.Page_forum = (() => {
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
<button class="btn btn-ghost btn-sm forum-mod-delete-thread text-danger">${UI.icon('trash')} Thread</button>
</div>` : '';
const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf'))
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`;
return `<a href="${UI.escape(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${UI.escape(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg');
return `<video src="${_esc(u)}" poster="${_esc(poster)}" controls playsinline
return `<video src="${UI.escape(u)}" poster="${UI.escape(poster)}" controls playsinline
style="max-width:100%;max-height:320px;border-radius:var(--radius-md);display:block"></video>`;
}
return `<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`;
return `<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">`;
};
const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
@ -565,7 +560,7 @@ window.Page_forum = (() => {
<div class="forum-reply-actions">
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
${UI.icon('camera')}
<input type="file" accept="image/*" id="forum-reply-file" style="display:none">
<input type="file" accept="image/*" id="forum-reply-file" class="hidden">
</label>
<div id="forum-reply-previews" class="forum-upload-previews"></div>
</div>
@ -578,20 +573,20 @@ window.Page_forum = (() => {
<div class="forum-thread-detail">
${modToolbar}
<div class="forum-thread-header-row">
<span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
<span class="forum-category-badge forum-category-badge--${UI.escape(thread.kategorie)}">${UI.escape(thread.kategorie)}</span>
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
</div>
<div class="forum-thread-body">
<p style="white-space:pre-wrap;word-break:break-word">${_esc(thread.text)}</p>
<p style="white-space:pre-wrap;word-break:break-word">${UI.escape(thread.text)}</p>
${fotoGallery}
</div>
<div class="forum-thread-author-row">
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
<div class="forum-avatar">${UI.escape(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${UI.escape(thread.autor_name || 'Unbekannt')}</span>
${thread.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${thread.autor_founder_number}</span>` : ''}
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
@ -623,7 +618,7 @@ window.Page_forum = (() => {
</div>
` : `<button type="button" class="btn btn-primary w-full" id="ft-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${_esc(thread.titel)}`, body, footer });
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${UI.escape(thread.titel)}`, body, footer });
// Close
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
@ -778,7 +773,7 @@ window.Page_forum = (() => {
const isOwn = uid && uid === p.user_id;
const fotoHtml = (p.foto_urls?.length)
? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
`<img src="${UI.escape(u)}" class="forum-foto-img" data-src="${UI.escape(u)}" alt="" loading="lazy">`
).join('')}</div>`
: '';
@ -788,13 +783,13 @@ window.Page_forum = (() => {
return `
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
<div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
<div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(p.autor_name))}</div>
<span class="forum-post-author">${UI.escape(p.autor_name || 'Unbekannt')}</span>
${p.autor_founder_number ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">Gründer #${p.autor_founder_number}</span>` : ''}
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div>
<div class="forum-post-body">
<div class="forum-post-text">${_esc(p.text)}</div>
<div class="forum-post-text">${UI.escape(p.text)}</div>
${fotoHtml}
</div>
<div class="forum-post-actions">
@ -803,7 +798,7 @@ window.Page_forum = (() => {
</button>
${(!isOwn && uid) ? `<button class="forum-icon-btn forum-post-report" data-post-id="${p.id}" title="Melden">${UI.icon('flag')}</button>` : ''}
<div style="margin-left:auto;display:flex;gap:4px">
${isOwn ? `<button class="forum-icon-btn forum-post-edit" data-post-id="${p.id}" data-text="${_esc(p.text || '')}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''}
${isOwn ? `<button class="forum-icon-btn forum-post-edit" data-post-id="${p.id}" data-text="${UI.escape(p.text || '')}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>` : ''}
${canDelete ? `<button class="forum-icon-btn forum-icon-btn--danger forum-post-delete" data-post-id="${p.id}" title="Löschen">${UI.icon('trash')}</button>` : ''}
</div>
</div>
@ -862,7 +857,7 @@ window.Page_forum = (() => {
try {
await API.forum.deletePost(postId);
if (postEl) {
postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
postEl.innerHTML = '<em class="text-muted">Beitrag wurde entfernt</em>';
postEl.className = 'forum-post forum-post--deleted';
}
const idx = _threads.findIndex(t => t.id === threadId);
@ -991,7 +986,7 @@ window.Page_forum = (() => {
// ----------------------------------------------------------
function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${UI.escape(k.label)}</option>`
).join('');
const body = `
@ -1011,16 +1006,16 @@ window.Page_forum = (() => {
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div>
<div class="form-group">
<label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Standort <span class="text-secondary">(optional)</span></label>
<div id="forum-location-picker"></div>
</div>
<div class="form-group">
<label class="form-label">Fotos / Dateien (max. 5)</label>
<div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label>
<input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple style="display:none">
<input type="file" id="forum-thread-files" accept="image/*,video/*,application/pdf" multiple class="hidden">
</div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
<div id="forum-thread-previews" class="forum-upload-previews mt-2"></div>
</div>
</form>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
@ -1241,12 +1236,12 @@ window.Page_forum = (() => {
background:var(--c-primary);color:#fff;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.8)">${_esc((m.vorname||'?')[0].toUpperCase())}</div>`,
border:2px solid rgba(255,255,255,0.8)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
_clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
.bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`)
);
});
_map.addLayer(_clusterGroup);
@ -1295,14 +1290,14 @@ window.Page_forum = (() => {
? `<div class="forum-mod-reports">
${reports.map(r => `
<div class="forum-mod-report-item" data-id="${r.id}">
<div style="font-size:var(--text-sm)">
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
${_esc(r.grund)}
<div class="text-sm">
<strong>${UI.escape(r.target_type)} #${r.target_id}</strong>
${UI.escape(r.grund)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
<div class="text-xs-muted">
von ${UI.escape(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</div>
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" class="mt-2">
${UI.icon('check')} Erledigt
</button>
</div>`).join('')}
@ -1334,7 +1329,7 @@ window.Page_forum = (() => {
title: 'Antwort bearbeiten',
body: `<form id="${id}">
<div class="form-group">
<textarea class="form-control" name="text" rows="5" required>${_esc(currentText)}</textarea>
<textarea class="form-control" name="text" rows="5" required>${UI.escape(currentText)}</textarea>
</div>
</form>`,
footer: `
@ -1373,14 +1368,14 @@ window.Page_forum = (() => {
body: `<form id="${id}">
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-control" name="titel" value="${_esc(thread.titel || '')}" required>
<input class="form-control" name="titel" value="${UI.escape(thread.titel || '')}" required>
</div>
<div class="form-group">
<label class="form-label">Text</label>
<textarea class="form-control" name="text" rows="5">${_esc(thread.text || '')}</textarea>
<textarea class="form-control" name="text" rows="5">${UI.escape(thread.text || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Standort <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Standort <span class="text-secondary">(optional)</span></label>
<div id="forum-edit-location-picker"></div>
</div>
</form>`,
@ -1426,7 +1421,7 @@ window.Page_forum = (() => {
const lb = document.createElement('div');
lb.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;display:flex;align-items:center;justify-content:center;cursor:zoom-out';
lb.innerHTML = `<img src="${UI.escape(src)}" style="max-width:100%;max-height:100%;object-fit:contain;touch-action:pinch-zoom">
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:40px;height:40px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
<button style="position:absolute;top:16px;right:16px;background:rgba(255,255,255,.2);border:none;border-radius:50%;width:44px;height:44px;color:#fff;font-size:22px;cursor:pointer;display:flex;align-items:center;justify-content:center"></button>`;
lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb);
}

View file

@ -51,17 +51,17 @@ window.Page_friends = (() => {
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">Dein Freundes-Link</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<div class="text-xs-secondary">
Teile ihn der andere tippt drauf und findet dich sofort.
</div>
</div>
</div>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
<div style="flex:1;padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
banyaro.app/#friends?suche=${_esc(encodeURIComponent(myName))}
banyaro.app/#friends?suche=${UI.escape(encodeURIComponent(myName))}
</div>
<button class="btn btn-ghost btn-sm" id="fr-copy-btn" title="Link kopieren">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
@ -82,7 +82,7 @@ window.Page_friends = (() => {
</svg>
<input id="fr-search" type="search" autocomplete="off"
placeholder="Namen eines Hundebesitzers suchen…"
value="${_esc(prefill || '')}"
value="${UI.escape(prefill || '')}"
style="width:100%;box-sizing:border-box;
padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
@ -278,17 +278,19 @@ window.Page_friends = (() => {
const text = item.text || '';
const page = _ACTIVITY_PAGE[item.type] || '';
const dogLabel = item.dog_name
? `<span class="fr-activity-dog">${_esc(item.dog_name)}</span>`
? `<span class="fr-activity-dog">${UI.escape(item.dog_name)}</span>`
: '';
const avatar = item.dog_foto
? `<img src="${_esc(item.dog_foto)}" alt="${_esc(item.dog_name || '')}"
? `<img src="${UI.escape(item.dog_foto)}" alt="${UI.escape(item.dog_name || '')}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">`
: item.avatar_url
? `<img src="${_esc(item.avatar_url)}" alt="${_esc(item.user_name)}"
? `<img src="${UI.escape(item.avatar_url)}" alt="${UI.escape(item.user_name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">`
: `<div class="fr-activity-avatar fr-activity-avatar--initial">
${_esc((item.user_name || '?')[0].toUpperCase())}
${UI.escape((item.user_name || '?')[0].toUpperCase())}
</div>`;
const tag = page ? `button type="button"` : `div`;
@ -301,17 +303,17 @@ window.Page_friends = (() => {
${avatar}
<div class="fr-activity-icon-badge">
<svg class="ph-icon" style="width:10px;height:10px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(item.icon)}"></use>
<use href="/icons/phosphor.svg#${UI.escape(item.icon)}"></use>
</svg>
</div>
</div>
<div class="fr-activity-body">
<div class="fr-activity-meta">
<span class="fr-activity-user">${_esc(item.user_name)}</span>
<span class="fr-activity-user">${UI.escape(item.user_name)}</span>
${dogLabel}
</div>
${text ? `<div class="fr-activity-text">${_esc(text)}</div>` : ''}
<div class="fr-activity-time">${_esc(ago)}</div>
${text ? `<div class="fr-activity-text">${UI.escape(text)}</div>` : ''}
<div class="fr-activity-time">${UI.escape(ago)}</div>
</div>
</${page ? 'button' : 'div'}>
`;
@ -351,7 +353,7 @@ window.Page_friends = (() => {
<div style="flex:1;min-width:120px">
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(r.requester_name)}
${UI.escape(r.requester_name)}
</div>
${_dogPills(r.dogs, 2)}
</div>
@ -390,12 +392,12 @@ window.Page_friends = (() => {
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary);
font-size:var(--text-sm);flex-shrink:0">
${_esc((r.addressee_name || '?')[0].toUpperCase())}
${UI.escape((r.addressee_name || '?')[0].toUpperCase())}
</div>
<div style="flex:1">
<div class="flex-1">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(r.addressee_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Anfrage ausstehend</div>
color:var(--c-text)">${UI.escape(r.addressee_name)}</div>
<div class="text-xs-muted">Anfrage ausstehend</div>
</div>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen">
@ -471,20 +473,20 @@ window.Page_friends = (() => {
<div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer;
transition:box-shadow 0.15s"
data-friend-id="${f.friend_id}"
data-friend-name="${_esc(f.friend_name)}"
data-dogs="${_esc(JSON.stringify(dogs))}"
data-profile="${_esc(JSON.stringify(profile))}">
data-friend-name="${UI.escape(f.friend_name)}"
data-dogs="${UI.escape(JSON.stringify(dogs))}"
data-profile="${UI.escape(JSON.stringify(profile))}">
<div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) -->
${_userAvatar(f.friend_name, dogs[0], f.avatar_url)}
<!-- Name + Infos + Hunde -->
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;
margin-bottom:var(--space-1)">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(f.friend_name)}
${UI.escape(f.friend_name)}
</span>
${_erfahrungSpan(f.erfahrung)}
</div>
@ -495,7 +497,7 @@ window.Page_friends = (() => {
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
${_dogPills(dogs, 3)}
</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch kein Hund eingetragen</div>`
: `<div class="text-xs-muted">Noch kein Hund eingetragen</div>`
}
</div>
</div>
@ -504,7 +506,7 @@ window.Page_friends = (() => {
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-sm fr-note-btn"
data-fr-note-id="${f.friend_id}"
data-fr-note-name="${_esc(f.friend_name)}"
data-fr-note-name="${UI.escape(f.friend_name)}"
title="Notiz"
onclick="event.stopPropagation()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
@ -536,13 +538,14 @@ window.Page_friends = (() => {
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);
padding-top:var(--space-3);border-top:1px solid var(--c-border)">
${withPhotos.slice(0, 4).map(d => `
<div style="text-align:center">
<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
<div class="text-center">
<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-surface)">
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px;
max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(d.name)}
${UI.escape(d.name)}
</div>
</div>
`).join('')}
@ -558,9 +561,10 @@ window.Page_friends = (() => {
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
gap:var(--space-3);margin-top:var(--space-4)">
${dogs.map(d => `
<div style="text-align:center">
<div class="text-center">
${d.foto_url
? `<img src="${_esc(d.foto_url)}" alt="${_esc(d.name)}"
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);margin-bottom:var(--space-2)">`
: `<div style="width:72px;height:72px;border-radius:50%;
@ -569,9 +573,9 @@ window.Page_friends = (() => {
font-size:1.75rem;margin:0 auto var(--space-2)">🐕</div>`
}
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(d.name)}</div>
color:var(--c-text)">${UI.escape(d.name)}</div>
${d.rasse
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>`
? `<div class="text-xs-secondary">${UI.escape(d.rasse)}</div>`
: ''}
</div>
`).join('')}
@ -585,24 +589,24 @@ window.Page_friends = (() => {
if (profile.wohnort) {
parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary)">
📍 ${_esc(profile.wohnort)}
📍 ${UI.escape(profile.wohnort)}
</div>`);
}
if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) {
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
parts.push(`<div class="text-sm-secondary">
${_erfahrungBadge[profile.erfahrung]}
</div>`);
}
if (profile.bio && profile.profil_sichtbarkeit !== 'private') {
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.5;padding-top:var(--space-2)">
${_esc(profile.bio)}
${UI.escape(profile.bio)}
</div>`);
}
if (profile.social_link) {
parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
<a href="${UI.escape(profile.social_link)}" target="_blank" rel="noopener noreferrer"
class="text-primary">${UI.escape(profile.social_link)}</a>
</div>`);
}
if (!parts.length) return '';
@ -623,7 +627,7 @@ window.Page_friends = (() => {
</div>` : '';
UI.modal.open({
title: _esc(friendName),
title: UI.escape(friendName),
body: `
<div>
${badgesHTML}
@ -638,7 +642,7 @@ window.Page_friends = (() => {
Nachricht schreiben
</button>
<button class="btn btn-ghost" id="modal-remove-btn" form=""
style="color:var(--c-danger)">
class="text-danger">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-minus"></use></svg>
Entfernen
</button>
@ -679,11 +683,11 @@ window.Page_friends = (() => {
padding:var(--space-3) var(--space-4);
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
${_userAvatar(u.name, null, u.avatar_url)}
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px;
margin-bottom:2px">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(u.name)}</span>
color:var(--c-text)">${UI.escape(u.name)}</span>
${u.is_founder ? `<span style="font-size:10px;font-weight:700;background:#7c3aed;color:#fff;padding:1px 5px;border-radius:4px">${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'}</span>` : ''}
${u.is_partner ? `<span style="font-size:10px;font-weight:700;background:#0ea5e9;color:#fff;padding:1px 5px;border-radius:4px">Partner</span>` : ''}
${_erfahrungSpan(u.erfahrung)}
@ -693,12 +697,12 @@ window.Page_friends = (() => {
${u.dogs?.length
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:2px">
${u.dogs.map(d => _esc(d.name) + (d.rasse ? ` · ${_esc(d.rasse)}` : '')).join(' &nbsp;|&nbsp; ')}
${u.dogs.map(d => UI.escape(d.name) + (d.rasse ? ` · ${UI.escape(d.rasse)}` : '')).join(' &nbsp;|&nbsp; ')}
</div>`
: ''}
</div>
<button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden"
data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
data-user-id="${u.id}" data-user-name="${UI.escape(u.name)}">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
</button>
</div>
@ -782,12 +786,14 @@ window.Page_friends = (() => {
// ----------------------------------------------------------
function _userAvatar(name, firstDog, avatarUrl) {
if (avatarUrl) {
return `<img src="${_esc(avatarUrl)}" alt="${_esc(name)}"
return `<img src="${UI.escape(avatarUrl)}" alt="${UI.escape(name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
if (firstDog?.foto_url) {
return `<img src="${_esc(firstDog.foto_url)}" alt="${_esc(firstDog.name)}"
return `<img src="${UI.escape(firstDog.foto_url)}" alt="${UI.escape(firstDog.name)}"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
@ -797,7 +803,7 @@ window.Page_friends = (() => {
border:2px solid var(--c-primary);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-primary)">
${_esc((name || '?')[0].toUpperCase())}
${UI.escape((name || '?')[0].toUpperCase())}
</div>`;
}
@ -817,7 +823,7 @@ window.Page_friends = (() => {
function _wohnortLine(wohnort) {
if (!wohnort) return '';
return `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">📍 ${_esc(wohnort)}</span>`;
return `<span class="text-xs-muted">📍 ${UI.escape(wohnort)}</span>`;
}
function _bioLine(bio, sichtbarkeit) {
@ -826,7 +832,7 @@ window.Page_friends = (() => {
return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:var(--space-1);line-height:1.4;
overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;
-webkit-box-orient:vertical">${_esc(text)}</div>`;
-webkit-box-orient:vertical">${UI.escape(text)}</div>`;
}
function _dogPills(dogs, max) {
@ -838,7 +844,7 @@ window.Page_friends = (() => {
${visible.map(d => `
<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full);
background:var(--c-surface-2);color:var(--c-text-secondary)">
🐕 ${_esc(d.name)}${d.rasse ? ` · ${_esc(d.rasse)}` : ''}
🐕 ${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''}
</span>
`).join('')}
${rest > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">+${rest}</span>` : ''}
@ -846,11 +852,6 @@ window.Page_friends = (() => {
`;
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
@ -880,7 +881,7 @@ window.Page_friends = (() => {
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>

View file

@ -82,7 +82,7 @@ window.Page_gruender = (() => {
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-4)">
Unsere Partner treten gegeneinander an wer bringt die meisten Gründer?
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<div class="flex-col-gap-2">
${d.partners.map((p, i) => {
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i+1}`;
const barPct = d.partners[0].uses > 0 ? Math.round((p.uses / d.partners[0].uses) * 100) : 0;
@ -91,8 +91,8 @@ window.Page_gruender = (() => {
padding:var(--space-3);border-radius:var(--radius-md);
background:${i === 0 ? 'linear-gradient(135deg,#fef9c3,#fef3c7)' : 'var(--c-surface-2)'}">
<div style="font-size:22px;min-width:32px;text-align:center">${medal}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-sm)">${_esc(p.label)}</div>
<div class="flex-1-min">
<div style="font-weight:700;font-size:var(--text-sm)">${UI.escape(p.label)}</div>
<div style="background:var(--c-surface-3,rgba(0,0,0,.08));border-radius:var(--radius-full);
height:6px;margin-top:var(--space-1);overflow:hidden">
<div style="background:#7c3aed;width:${barPct}%;height:100%;
@ -120,7 +120,7 @@ window.Page_gruender = (() => {
background:var(--c-surface-2);display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:800;color:#7c3aed;min-width:28px">#${f.founder_number}</span>
<span style="font-size:var(--text-sm);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(f.name)}
${UI.escape(f.name)}
</span>
</div>
`).join('')}
@ -131,23 +131,19 @@ window.Page_gruender = (() => {
<span style="font-size:var(--text-xs);font-weight:800;color:var(--c-text-muted);min-width:28px">
#${d.total + i + 1}
</span>
<span style="font-size:var(--text-sm);color:var(--c-text-muted)">frei</span>
<span class="text-sm-muted">frei</span>
</div>
`).join('')}
</div>
</div>` : `
<div class="by-card" style="padding:var(--space-6);text-align:center">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">
<p class="text-sm-muted">
Noch keine Gründer sei der Erste!
</p>
</div>`}
`;
}
function _esc(s) {
return String(s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
return { init, refresh, onDogChange };

File diff suppressed because it is too large Load diff

View file

@ -103,7 +103,7 @@ window.Page_hilfe = (() => {
</p>
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
${_search
? `Zu "${_esc(_search)}" wurde nichts gefunden.`
? `Zu "${UI.escape(_search)}" wurde nichts gefunden.`
: 'Noch keine FAQ-Artikel vorhanden.'}
</p>
</div>
@ -136,7 +136,7 @@ window.Page_hilfe = (() => {
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2);
margin-bottom:var(--space-1)">
${_esc(label)}
${UI.escape(label)}
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)">
`;
@ -148,12 +148,12 @@ window.Page_hilfe = (() => {
// Highlight Suchtreffer in der Frage
const frageHtml = _search
? _highlight(a.frage, _search)
: _esc(a.frage);
: UI.escape(a.frage);
// Antwort: Zeilenumbrüche in <br> wandeln
const antwortHtml = _search
? _highlight(a.antwort, _search).replace(/\n/g, '<br>')
: _esc(a.antwort).replace(/\n/g, '<br>');
: UI.escape(a.antwort).replace(/\n/g, '<br>');
// Bei aktiver Suche: Antwort gleich aufgeklappt
const openByDefault = !!_search;
@ -169,7 +169,7 @@ window.Page_hilfe = (() => {
display:flex;align-items:flex-start;gap:var(--space-2);
font-size:var(--text-sm);font-weight:600;
color:var(--c-text);line-height:1.4">
<span style="flex:1">${frageHtml}</span>
<span class="flex-1">${frageHtml}</span>
<svg id="${chevronId}" class="ph-icon" aria-hidden="true"
style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;
color:var(--c-text-muted);
@ -222,20 +222,12 @@ window.Page_hilfe = (() => {
}
// ----------------------------------------------------------
function _esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _highlight(text, term) {
if (!term) return text;
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${safe})`, 'gi');
return _esc(text).replace(re,
return UI.escape(text).replace(re,
'<mark style="background:var(--c-warning-bg,#fef3c7);color:inherit;border-radius:2px">$1</mark>'
);
}

View file

@ -26,12 +26,12 @@ window.Page_impressum = (() => {
color:var(--c-text);margin:0 0 var(--space-2)">Kontakt</h2>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.7;margin:0 0 var(--space-4)">
E-Mail: <a href="mailto:hallo@banyaro.app"
style="color:var(--c-primary)">hallo@banyaro.app</a><br>
class="text-primary">hallo@banyaro.app</a><br>
Oder nutze das Formular wir antworten in der Regel innerhalb von 24 Stunden.
</p>
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<form id="contact-form" class="flex-col-gap-3">
<div class="grid-2">
<div>
<label for="cf-name" style="display:block;font-size:var(--text-xs);font-weight:600;margin-bottom:4px;color:var(--c-text)">Name *</label>
<input id="cf-name" type="text" required maxlength="100"

View file

@ -7,7 +7,6 @@ window.Page_jobs = (() => {
let _container = null;
let _appState = null;
const _esc = s => UI.escape(s ?? '');
const _ph = (name, size = 22) =>
`<svg class="ph-icon" aria-hidden="true" style="width:${size}px;height:${size}px;flex-shrink:0;color:var(--c-primary)"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
@ -44,7 +43,7 @@ window.Page_jobs = (() => {
</div>
<!-- Stellenbeschreibung -->
<div class="card" style="margin-bottom:var(--space-4)">
<div class="card mb-4">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
<div style="display:grid;gap:var(--space-3)">
@ -76,7 +75,7 @@ window.Page_jobs = (() => {
</div>
<!-- Wen wir suchen -->
<div class="card" style="margin-bottom:var(--space-4)">
<div class="card mb-4">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
@ -121,7 +120,7 @@ window.Page_jobs = (() => {
const s = statusMap[app.status] || statusMap.pending;
return `
<div class="card" style="padding:var(--space-5);text-align:center">
<div style="margin-bottom:var(--space-3)">
<div class="mb-3">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:${s.color}"><use href="/icons/phosphor.svg#${s.icon}"></use></svg>
</div>
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
@ -130,7 +129,7 @@ window.Page_jobs = (() => {
</div>
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);text-align:left">${_esc(app.admin_note)}</div>` : ''}
color:var(--c-text-secondary);text-align:left">${UI.escape(app.admin_note)}</div>` : ''}
</div>`;
}
@ -147,13 +146,13 @@ window.Page_jobs = (() => {
<div class="form-group">
<label class="form-label">Dein Name *</label>
<input class="form-control" type="text" name="name"
value="${u ? _esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
value="${u ? UI.escape(u.name) : ''}" placeholder="Vorname oder Nickname" required>
</div>
<div class="form-group">
<label class="form-label">E-Mail *</label>
<input class="form-control" type="email" name="email"
value="${u ? _esc(u.email || '') : ''}" placeholder="deine@email.de" required>
value="${u ? UI.escape(u.email || '') : ''}" placeholder="deine@email.de" required>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
@ -194,7 +193,7 @@ window.Page_jobs = (() => {
<label class="form-label">Anhänge (optional)</label>
<input class="form-control" type="file" name="files" id="jobs-files"
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
style="padding:var(--space-2)">
class="p-2">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB.
PDF, Bild oder Video.
@ -205,7 +204,7 @@ window.Page_jobs = (() => {
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4)">
💡 <b>Tipp:</b> Wenn du dich vorher
<a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
<a href="#" id="jobs-login-link" class="text-primary">anmeldest oder registrierst</a>,
bekommst du sofort den 14-tägigen Luna-Probezugang.
</div>` : ''}

View file

@ -135,11 +135,11 @@ window.Page_knigge = (() => {
const cards = BEGEGNUNGEN.map((b, i) => `
<div class="knigge-accordion" id="acc-${i}">
<button class="knigge-accordion-head" data-acc="${i}" aria-expanded="false">
<span>${b.icon} <strong>${_esc(b.titel)}</strong></span>
<span>${b.icon} <strong>${UI.escape(b.titel)}</strong></span>
<span class="knigge-accordion-arrow">${UI.icon('caret-down')}</span>
</button>
<div class="knigge-accordion-body" id="acc-body-${i}" hidden>
<p style="color:var(--c-text);line-height:1.6">${_esc(b.tipps)}</p>
<p style="color:var(--c-text);line-height:1.6">${UI.escape(b.tipps)}</p>
</div>
</div>
`).join('');
@ -173,16 +173,16 @@ window.Page_knigge = (() => {
// ----------------------------------------------------------
function _renderVoting() {
const cards = SZENARIEN.map(s => `
<div class="card" style="margin-bottom:var(--space-4)" id="sz-${s.id}">
<div class="card mb-4" id="sz-${s.id}">
<p style="font-weight:var(--weight-semibold);margin:0;padding:var(--space-5) var(--space-5) var(--space-3);line-height:1.5">
${_esc(s.frage)}
${UI.escape(s.frage)}
</p>
<div class="knigge-vote-options" id="opts-${s.id}" style="padding:0 var(--space-5) var(--space-5)">
${s.antworten.map(a => `
<button class="knigge-vote-btn btn btn-secondary"
data-sz="${s.id}" data-key="${a.key}"
style="width:100%;margin-bottom:var(--space-2);justify-content:flex-start;text-align:left">
${_esc(a.text)}
${UI.escape(a.text)}
</button>
`).join('')}
</div>
@ -260,12 +260,12 @@ window.Page_knigge = (() => {
? 'var(--c-success, #22c55e)'
: (isU && !isR ? 'var(--c-danger, #ef4444)' : 'var(--c-border)');
return `
<div style="margin-bottom:var(--space-3)">
<div class="mb-3">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:var(--text-sm)">
<span style="color:${isU ? 'var(--c-text)' : 'var(--c-text-secondary)'};font-weight:${isU ? 'var(--weight-semibold)' : 'normal'}">
${isU ? UI.icon('arrow-right') + ' ' : ''}${_esc(a.text)}${isR ? ' ' + UI.icon('check') : ''}
${isU ? UI.icon('arrow-right') + ' ' : ''}${UI.escape(a.text)}${isR ? ' ' + UI.icon('check') : ''}
</span>
<span style="color:var(--c-text-secondary)">${pct}% (${cnt})</span>
<span class="text-secondary">${pct}% (${cnt})</span>
</div>
<div style="background:var(--c-surface-2);border-radius:4px;height:8px;overflow:hidden">
<div style="width:${pct}%;background:${color};height:8px;border-radius:4px;transition:width 0.4s"></div>
@ -282,7 +282,7 @@ window.Page_knigge = (() => {
<div style="margin-bottom:var(--space-4);padding:0 var(--space-5)">${bars}</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3) var(--space-5);font-size:var(--text-sm);line-height:1.5">
${badge}
<span style="color:var(--c-text-secondary)">${_esc(szenario.erklaerung)}</span>
<span class="text-secondary">${UI.escape(szenario.erklaerung)}</span>
</div>
`;
}
@ -300,8 +300,8 @@ window.Page_knigge = (() => {
<textarea id="ki-situation-input" class="form-control"
rows="3"
placeholder="Beschreibe deine Situation…"
style="margin-bottom:var(--space-3)"></textarea>
<button class="btn btn-primary" id="ki-rat-btn" style="width:100%">
class="mb-3"></textarea>
<button class="btn btn-primary" id="ki-rat-btn" class="w-full">
Rat holen ${UI.icon('robot')}
</button>
<div id="ki-rat-result" style="margin-top:var(--space-4);display:none"></div>
@ -336,7 +336,7 @@ window.Page_knigge = (() => {
padding:var(--space-4);line-height:1.6;color:var(--c-text)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('robot')} KI-Rat</div>
${_esc(data.rat)}
${UI.escape(data.rat)}
</div>
`;
result.style.display = 'block';
@ -347,7 +347,7 @@ window.Page_knigge = (() => {
padding:var(--space-4);color:var(--c-text-secondary);font-size:var(--text-sm)">
${is402
? 'Für KI-Rat wird Ban Yaro Plus oder ein laufender KI-Server benötigt.'
: _esc(err.message || 'Fehler beim KI-Abruf.')}
: UI.escape(err.message || 'Fehler beim KI-Abruf.')}
</div>
`;
result.style.display = 'block';
@ -400,16 +400,7 @@ window.Page_knigge = (() => {
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };

View file

@ -14,7 +14,7 @@ window.Page_laeufi = (() => {
_appState = appState;
if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) {
_container.innerHTML = `<div style="text-align:center;padding:var(--space-10)">
<p style="color:var(--c-text-secondary)">Nur für verifizierte Züchter.</p></div>`;
<p class="text-secondary">Nur für verifizierte Züchter.</p></div>`;
return;
}
API.breeder.status().then(s => {
@ -53,7 +53,7 @@ window.Page_laeufi = (() => {
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
@ -61,7 +61,7 @@ window.Page_laeufi = (() => {
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
<span class="text-xs-secondary">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;
@ -89,7 +89,7 @@ window.Page_laeufi = (() => {
_renderHundeList();
} catch (err) {
document.getElementById('laeufi-list').innerHTML =
`<p style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
`<p class="text-danger">${UI.escape(err.message || 'Fehler')}</p>`;
}
}
@ -129,22 +129,22 @@ window.Page_laeufi = (() => {
<div id="laeufi-toggle-${h.id}"
style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3);
cursor:pointer;user-select:none">
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-size:var(--text-base);font-weight:700">${UI.escape(h.name)}</span>
${h.rufname ? `<span style="color:var(--c-text-muted);font-size:var(--text-sm)">"${UI.escape(h.rufname)}"</span>` : ''}
${alter ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${alter}</span>` : ''}
${h.rufname ? `<span class="text-sm-muted">"${UI.escape(h.rufname)}"</span>` : ''}
${alter ? `<span class="text-xs-muted">${alter}</span>` : ''}
</div>
${h.rasse_text || h.farbe ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
${[h.rasse_text, h.farbe].filter(Boolean).map(s => UI.escape(s)).join(' · ')}
</div>` : ''}
</div>
<span style="color:var(--c-text-muted)">${UI.icon('caret-down')}</span>
<span class="text-muted">${UI.icon('caret-down')}</span>
</div>
<div id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)">
<div id="laeufi-content-${h.id}"
style="padding:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
class="p-4">
<p class="text-sm-muted">Lädt</p>
</div>
</div>
</div>`;
@ -177,7 +177,7 @@ window.Page_laeufi = (() => {
]);
_renderHundContent(el, hundId, laeufiList, deckList);
} catch (err) {
el.innerHTML = `<p style="color:var(--c-danger)">${UI.escape(err.message || 'Fehler')}</p>`;
el.innerHTML = `<p class="text-danger">${UI.escape(err.message || 'Fehler')}</p>`;
}
}
@ -270,11 +270,11 @@ window.Page_laeufi = (() => {
return list.map(l => `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md);
padding:var(--space-3);margin-bottom:var(--space-2);display:flex;gap:var(--space-3);align-items:flex-start">
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-weight:600;font-size:var(--text-sm)">${_fmtDate(l.beginn)}</span>
${l.ende ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">→ ${_fmtDate(l.ende)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
${l.ende ? `<span class="text-xs-muted">→ ${_fmtDate(l.ende)}</span>
<span class="text-xs-muted">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
</div>
${l.notiz ? `<p style="font-size:var(--text-xs);color:var(--c-text-secondary);margin:var(--space-1) 0 0;font-style:italic">${UI.escape(l.notiz)}</p>` : ''}
</div>
@ -286,7 +286,7 @@ window.Page_laeufi = (() => {
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-ghost btn-xs laeufi-delete-btn" data-id="${l.id}"
title="Löschen" style="color:var(--c-danger)">
title="Löschen" class="text-danger">
${UI.icon('trash')}
</button>
</div>
@ -314,7 +314,7 @@ window.Page_laeufi = (() => {
margin-bottom:var(--space-3);overflow:hidden">
<!-- Deck-Header -->
<div style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start">
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:700;font-size:var(--text-sm)">${UI.icon('heart')} Deckung ${_fmtDate(d.deckdatum)}</span>
<span style="background:${tc.color}1a;color:${tc.color};border:1px solid ${tc.color}30;
@ -335,7 +335,7 @@ window.Page_laeufi = (() => {
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs deck-edit-btn" data-id="${d.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs deck-delete-btn" data-id="${d.id}" title="Löschen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
<button class="btn btn-ghost btn-xs deck-delete-btn" data-id="${d.id}" title="Löschen" class="text-danger">${UI.icon('trash')}</button>
</div>
</div>
<!-- Meilensteine -->
@ -358,7 +358,7 @@ window.Page_laeufi = (() => {
color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px">
${m.vorbei ? '✓' : m.tag}
</span>
<span style="color:var(--c-text-secondary)">${_fmtDate(m.datum)}</span>
<span class="text-secondary">${_fmtDate(m.datum)}</span>
<span style="color:${m.vorbei ? 'var(--c-text-muted)' : 'var(--c-text)'};font-weight:${m.vorbei ? '400' : '600'}">
${UI.escape(m.label)}
</span>
@ -377,8 +377,8 @@ window.Page_laeufi = (() => {
UI.modal.open({
title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen',
body: `
<form id="laeufi-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<form id="laeufi-form" class="flex-col-gap-3">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Beginn *</label>
<input class="form-control" type="date" name="beginn" required value="${v.beginn || today}">
@ -421,8 +421,8 @@ window.Page_laeufi = (() => {
UI.modal.open({
title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen',
body: `
<form id="deck-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<form id="deck-form" class="flex-col-gap-3">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Deckdatum *</label>
<input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}">
@ -435,7 +435,7 @@ window.Page_laeufi = (() => {
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Rüde</label>
<input class="form-control" name="ruede_name" placeholder="Name des Deckrüden"
@ -451,7 +451,7 @@ window.Page_laeufi = (() => {
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Trächtigkeitsstatus</label>
<select class="form-control" name="traechtig">
@ -503,7 +503,7 @@ window.Page_laeufi = (() => {
async function _showProgModal(hundId, laeufi) {
UI.modal.open({
title: `Progesterontests — ${_fmtDate(laeufi.beginn)}`,
body: `<div id="prog-modal-content"><p style="color:var(--c-text-muted)">Lädt…</p></div>`,
body: `<div id="prog-modal-content"><p class="text-muted">Lädt…</p></div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="prog-add-btn">${UI.icon('plus')} Test eintragen</button>`,
@ -535,7 +535,7 @@ window.Page_laeufi = (() => {
<tbody>
${tests.map(t => `
<tr style="border-top:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${_fmtDate(t.datum)}</td>
<td class="p-2">${_fmtDate(t.datum)}</td>
<td style="text-align:right;padding:var(--space-2);font-weight:600">
${t.wert != null ? `${t.wert} ${UI.escape(t.einheit)}` : '—'}
${t.wert != null ? `<span style="font-size:10px;margin-left:4px;color:var(--c-text-muted)">${_progEinschaetzung(t.wert, t.einheit)}</span>` : ''}
@ -543,7 +543,7 @@ window.Page_laeufi = (() => {
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${t.labor ? UI.escape(t.labor) : '—'}</td>
<td style="padding:var(--space-2);text-align:right">
<button class="btn btn-ghost btn-xs prog-delete-btn" data-id="${t.id}"
style="color:var(--c-danger)">${UI.icon('trash')}</button>
class="text-danger">${UI.icon('trash')}</button>
</td>
</tr>`).join('')}
</tbody>
@ -572,8 +572,8 @@ window.Page_laeufi = (() => {
UI.modal.open({
title: 'Progesterontest eintragen',
body: `
<form id="prog-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<form id="prog-form" class="flex-col-gap-3">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${today}">
@ -586,7 +586,7 @@ window.Page_laeufi = (() => {
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Wert</label>
<input class="form-control" type="number" step="0.01" name="wert" placeholder="z.B. 8.5">

View file

@ -19,15 +19,11 @@ window.Page_litters = (() => {
return `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon(icon)}</div>
<h3 style="margin:0 0 var(--space-2)">${_esc(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p>
<h3 style="margin:0 0 var(--space-2)">${UI.escape(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${UI.escape(text)}</p>
</div>`;
}
function _esc(s) {
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _statusBadge(status) {
const map = {
@ -37,7 +33,7 @@ window.Page_litters = (() => {
abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' },
};
const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge ${s.cls}">${_esc(s.label)}</span>`;
return `<span class="badge ${s.cls}">${UI.escape(s.label)}</span>`;
}
function _fmtDate(iso) {
@ -59,7 +55,7 @@ window.Page_litters = (() => {
abgegeben: { label: 'Abgegeben', cls: 'badge-muted' },
};
const s = map[status] || { label: status, cls: 'badge-muted' };
return `<span class="badge badge-sm ${s.cls}">${_esc(s.label)}</span>`;
return `<span class="badge badge-sm ${s.cls}">${UI.escape(s.label)}</span>`;
}
// ----------------------------------------------------------
@ -101,7 +97,7 @@ window.Page_litters = (() => {
const zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
const logoUrl = _breederInfo?.logo_url || null;
const logoHtml = logoUrl
? `<img src="${_esc(logoUrl)}" alt="Logo"
? `<img src="${UI.escape(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">`
@ -118,15 +114,15 @@ window.Page_litters = (() => {
padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)">
${logoHtml}
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${_esc(zwinger)}</h2>
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<svg style="width:11px;height:11px;color:var(--c-primary);flex-shrink:0" viewBox="0 0 256 256">
<use href="/icons/phosphor.svg#lock-key"></use>
</svg>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">Privater Bereich · Nur du siehst das</span>
<span class="text-xs-secondary">Privater Bereich · Nur du siehst das</span>
</div>
</div>
</div>`;
@ -232,7 +228,7 @@ window.Page_litters = (() => {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4);
border:1px dashed var(--c-border);border-radius:var(--radius-lg)">
<p style="color:var(--c-text-muted)">Keine Würfe für diesen Filter.</p>
<p class="text-muted">Keine Würfe für diesen Filter.</p>
</div>`;
return;
}
@ -248,8 +244,8 @@ window.Page_litters = (() => {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="litters-first-btn">
<p class="text-secondary">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary mt-4" id="litters-first-btn">
${UI.icon('plus')} Ersten Wurf anlegen
</button>
</div>`;
@ -315,7 +311,7 @@ window.Page_litters = (() => {
function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?';
const elternLabel = [l.vater_name, l.mutter_name].filter(Boolean).map(n => _esc(n)).join(' × ') || '—';
const elternLabel = [l.vater_name, l.mutter_name].filter(Boolean).map(n => UI.escape(n)).join(' × ') || '—';
// Datum + Countdown
let datumChip = '';
@ -325,10 +321,10 @@ window.Page_litters = (() => {
const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`;
let countdownHtml = '';
if (days !== null && !l.geburt_datum) {
const c = days < 0 ? `<span style="color:var(--c-danger)">überfällig</span>`
: days === 0 ? `<span style="color:var(--c-success)">heute!</span>`
const c = days < 0 ? `<span class="text-danger">überfällig</span>`
: days === 0 ? `<span class="text-success">heute!</span>`
: days <= 7 ? `<span style="color:var(--c-warning,#f59e0b)">${days}d</span>`
: `<span style="color:var(--c-text-muted)">${days}d</span>`;
: `<span class="text-muted">${days}d</span>`;
countdownHtml = ` · ${c}`;
}
datumChip = `<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('calendar-dots')} ${label}${countdownHtml}</span>`;
@ -341,7 +337,7 @@ window.Page_litters = (() => {
const welpenChip = `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('dog')} ${verfuegbar}/${gesamt} verfügbar</span>`;
const preisChip = l.preis_spanne
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('currency-eur')} ${_esc(l.preis_spanne)}</span>`
? `<span style="display:inline-flex;align-items:center;gap:3px;font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.icon('currency-eur')} ${UI.escape(l.preis_spanne)}</span>`
: '';
return `
@ -355,11 +351,11 @@ window.Page_litters = (() => {
<div style="min-width:0">
${(l.wurf_rang || l.wurf_name) ? `
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
${l.wurf_rang ? `<span style="background:var(--c-primary);color:white;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${_esc(l.wurf_rang)}-Wurf</span>` : ''}
${l.wurf_name ? `<span style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${_esc(l.wurf_name)}</span>` : ''}
${l.wurf_rang ? `<span style="background:var(--c-primary);color:white;border-radius:999px;padding:1px 10px;font-size:var(--text-xs);font-weight:700">${UI.escape(l.wurf_rang)}-Wurf</span>` : ''}
${l.wurf_name ? `<span style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${UI.escape(l.wurf_name)}</span>` : ''}
</div>` : ''}
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)">
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${elternLabel}</span>
<span class="text-sm-secondary">${elternLabel}</span>
${_statusBadge(l.status)}
${sichtbarChip}
</div>
@ -390,21 +386,21 @@ window.Page_litters = (() => {
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}" title="Löschen"
style="color:var(--c-danger)">
class="text-danger">
${UI.icon('trash')}
</button>
</div>
</div>
${l.beschreibung ? `<p style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${_esc(l.beschreibung)}</p>` : ''}
${l.beschreibung ? `<p style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.5">${UI.escape(l.beschreibung)}</p>` : ''}
</div>
<!-- Welpen-Bereich -->
<div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="puppies-inner-${l.id}">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
<p class="text-sm-muted">Lädt</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}"
style="margin-top:var(--space-3)">
class="mt-3">
${UI.icon('plus')} Welpen hinzufügen
</button>
</div>
@ -412,10 +408,10 @@ window.Page_litters = (() => {
<!-- Wartelisten-Bereich -->
<div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="waitlist-inner-${l.id}">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
<p class="text-sm-muted">Lädt</p>
</div>
<button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}"
style="margin-top:var(--space-3)">
class="mt-3">
${UI.icon('plus')} Interessent eintragen
</button>
</div>
@ -455,13 +451,13 @@ window.Page_litters = (() => {
const puppies = await API.litters.puppies(litterId);
_renderPuppies(inner, litterId, puppies);
} catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`;
}
}
function _renderPuppies(container, litterId, puppies) {
if (!puppies.length) {
container.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Welpen eingetragen.</p>`;
container.innerHTML = `<p class="text-sm-muted">Noch keine Welpen eingetragen.</p>`;
return;
}
@ -469,10 +465,10 @@ window.Page_litters = (() => {
<div class="litters-puppy-row" data-puppy-id="${p.id}">
<div class="litters-puppy-info">
${_genderIcon(p.geschlecht)}
<span class="litters-puppy-name">${p.name ? _esc(p.name) : '<em style="color:var(--c-text-muted)">Unbenannt</em>'}</span>
${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(p.farbe)}</span>` : ''}
<span class="litters-puppy-name">${p.name ? UI.escape(p.name) : '<em class="text-muted">Unbenannt</em>'}</span>
${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${UI.escape(p.farbe)}</span>` : ''}
${_puppyStatusBadge(p.status)}
<span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" style="font-size:var(--text-xs);color:var(--c-text-secondary)"></span>
<span class="litters-puppy-last-weight" id="puppy-last-weight-${p.id}" class="text-xs-secondary"></span>
</div>
<div class="litters-puppy-actions">
<button class="btn btn-ghost btn-xs litters-puppy-photo-btn" data-litter-id="${litterId}" data-puppy-id="${p.id}"
@ -542,16 +538,16 @@ window.Page_litters = (() => {
const puppyLabel = puppy.name || 'Welpe';
const body = `
<div id="weight-history" style="margin-bottom:var(--space-3)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
<div id="weight-history" class="mb-3">
<p class="text-sm-muted">Lädt</p>
</div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="weight-form" style="display:flex;gap:var(--space-2);align-items:flex-end">
<div style="flex:1">
<div class="flex-1">
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Gewicht (g)</label>
<input class="form-control" name="gewicht_g" type="number" min="1" max="99999" step="1" required placeholder="z. B. 420">
</div>
<div style="flex:1">
<div class="flex-1">
<label style="display:block;font-size:var(--text-xs);color:var(--c-text-secondary);margin-bottom:var(--space-1)">Datum</label>
<input class="form-control" name="gemessen_am" type="date" required value="${today}">
</div>
@ -565,7 +561,7 @@ window.Page_litters = (() => {
`;
UI.modal.open({
title: `${UI.icon('scales')} Gewichtsverlauf — ${_esc(puppyLabel)}`,
title: `${UI.icon('scales')} Gewichtsverlauf — ${UI.escape(puppyLabel)}`,
body,
footer,
});
@ -600,7 +596,7 @@ window.Page_litters = (() => {
try {
const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
if (!weights || !weights.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Messungen eingetragen.</p>`;
el.innerHTML = `<p class="text-sm-muted">Noch keine Messungen eingetragen.</p>`;
return;
}
@ -630,22 +626,22 @@ window.Page_litters = (() => {
el.innerHTML = `
<!-- Stats-Zeile -->
<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
<div style="text-align:center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Aktuell</div>
<div class="text-center">
<div class="text-xs-muted">Aktuell</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-primary)">${last} g</div>
</div>
<div style="text-align:center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Zunahme</div>
<div class="text-center">
<div class="text-xs-muted">Zunahme</div>
<div style="font-size:var(--text-base);font-weight:700;color:${gain >= 0 ? 'var(--c-success)' : 'var(--c-danger)'}">
${gain >= 0 ? '+' : ''}${gain} g
</div>
</div>
<div style="text-align:center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Ø tägl.</div>
<div class="text-center">
<div class="text-xs-muted">Ø tägl.</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${dailyGain} g</div>
</div>
<div style="text-align:center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Messungen</div>
<div class="text-center">
<div class="text-xs-muted">Messungen</div>
<div style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${weights.length}</div>
</div>
</div>
@ -695,7 +691,7 @@ window.Page_litters = (() => {
</tbody>
</table>`;
} catch (err) {
el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`;
}
}
@ -737,7 +733,7 @@ window.Page_litters = (() => {
btn.innerHTML = `${UI.icon('list-bullets')} Warteliste${active ? ` <span style="background:var(--c-primary);color:white;border-radius:999px;padding:0 6px;font-size:10px;font-weight:700">${active}</span>` : ''}`;
}
} catch (err) {
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler.')}</p>`;
inner.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler.')}</p>`;
}
}
@ -764,34 +760,34 @@ window.Page_litters = (() => {
<div style="text-align:center;padding:var(--space-6) var(--space-4);border:1px dashed var(--c-border);border-radius:var(--radius-md)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('users')}</div>
<p style="font-weight:600;font-size:var(--text-sm);color:var(--c-text);margin-bottom:var(--space-1)">Noch keine Interessenten</p>
<p style="font-size:var(--text-xs);color:var(--c-text-muted)">Trage Anfragen ein mit Wunsch-Geschlecht, Kontaktdaten und Status.</p>
<p class="text-xs-muted">Trage Anfragen ein mit Wunsch-Geschlecht, Kontaktdaten und Status.</p>
</div>`;
return;
}
container.innerHTML = header + `
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<div class="flex-col-gap-2">
${entries.map((e, i) => `
<div style="background:var(--c-bg-secondary);border-radius:var(--radius-md);padding:var(--space-3) var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start" data-entry-id="${e.id}">
<div style="background:var(--c-primary);color:white;border-radius:50%;width:1.6rem;height:1.6rem;display:flex;align-items:center;justify-content:center;font-size:var(--text-xs);font-weight:700;flex-shrink:0;margin-top:2px">${i + 1}</div>
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-1)">
<span style="font-weight:600;font-size:var(--text-sm)">${_esc(e.name)}</span>
<span style="font-weight:600;font-size:var(--text-sm)">${UI.escape(e.name)}</span>
${_wlStatusBadge(e.status)}
${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}</span>` : ''}
${e.wunsch_farbe ? `<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(e.wunsch_farbe)}</span>` : ''}
${e.wunsch_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span class="text-xs-secondary">${e.wunsch_geschlecht === 'maennlich' ? '♂ Rüde' : '♀ Hündin'}</span>` : ''}
${e.wunsch_farbe ? `<span class="text-xs-secondary">${UI.escape(e.wunsch_farbe)}</span>` : ''}
</div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)">
${e.email ? `<span>${UI.icon('envelope')} ${_esc(e.email)}</span>` : ''}
${e.telefon ? `<span>${UI.icon('phone')} ${_esc(e.telefon)}</span>` : ''}
${e.email ? `<span>${UI.icon('envelope')} ${UI.escape(e.email)}</span>` : ''}
${e.telefon ? `<span>${UI.icon('phone')} ${UI.escape(e.telefon)}</span>` : ''}
<span>${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'}</span>
</div>
${e.nachricht ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-secondary);font-style:italic">"${_esc(e.nachricht)}"</div>` : ''}
${e.notiz ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);background:var(--c-warning-bg,#fffbeb);color:#92400e;border-radius:4px;padding:2px 6px">${UI.icon('note-pencil')} ${_esc(e.notiz)}</div>` : ''}
${e.nachricht ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-secondary);font-style:italic">"${UI.escape(e.nachricht)}"</div>` : ''}
${e.notiz ? `<div style="margin-top:var(--space-1);font-size:var(--text-xs);background:var(--c-warning-bg,#fffbeb);color:#92400e;border-radius:4px;padding:2px 6px">${UI.icon('note-pencil')} ${UI.escape(e.notiz)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-xs wl-edit-btn" data-entry-id="${e.id}" title="Bearbeiten">${UI.icon('pencil-simple')}</button>
<button class="btn btn-ghost btn-xs wl-delete-btn" data-entry-id="${e.id}" title="Entfernen" style="color:var(--c-danger)">${UI.icon('trash')}</button>
<button class="btn btn-ghost btn-xs wl-delete-btn" data-entry-id="${e.id}" title="Entfernen" class="text-danger">${UI.icon('trash')}</button>
</div>
</div>`).join('')}
</div>`;
@ -820,22 +816,22 @@ window.Page_litters = (() => {
UI.modal.open({
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
body: `
<form id="wl-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<form id="wl-form" class="flex-col-gap-3">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" name="name" required value="${_esc(v.name || '')}">
<input class="form-control" name="name" required value="${UI.escape(v.name || '')}">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">E-Mail</label>
<input class="form-control" type="email" name="email" value="${_esc(v.email || '')}">
<input class="form-control" type="email" name="email" value="${UI.escape(v.email || '')}">
</div>
<div class="form-group">
<label class="form-label">Telefon</label>
<input class="form-control" name="telefon" value="${_esc(v.telefon || '')}">
<input class="form-control" name="telefon" value="${UI.escape(v.telefon || '')}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Wunsch Geschlecht</label>
<select class="form-control" name="wunsch_geschlecht">
@ -846,14 +842,14 @@ window.Page_litters = (() => {
</div>
<div class="form-group">
<label class="form-label">Wunsch Farbe</label>
<input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${_esc(v.wunsch_farbe || '')}">
<input class="form-control" name="wunsch_farbe" placeholder="z.B. schwarz-weiß" value="${UI.escape(v.wunsch_farbe || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Nachricht des Interessenten</label>
<textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${_esc(v.nachricht || '')}</textarea>
<textarea class="form-control" name="nachricht" rows="2" placeholder="Was hat der Interessent geschrieben?">${UI.escape(v.nachricht || '')}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Status</label>
<select class="form-control" name="status">
@ -867,7 +863,7 @@ window.Page_litters = (() => {
</div>
<div class="form-group">
<label class="form-label">Interne Notiz</label>
<input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${_esc(v.notiz || '')}">
<input class="form-control" name="notiz" placeholder="Nur für dich sichtbar" value="${UI.escape(v.notiz || '')}">
</div>
</form>`,
footer: `
@ -919,15 +915,15 @@ window.Page_litters = (() => {
const buildSelect = (name, idName, list, currentId, currentName, placeholder) => {
const opts = list.map(h => {
const label = h.name + (h.rufname ? ` (${h.rufname})` : '') + (h.zuchtbuchnummer ? ` · ${h.zuchtbuchnummer}` : '');
return `<option value="${h.id}" data-name="${_esc(h.name)}" ${currentId == h.id ? 'selected' : ''}>${_esc(label)}</option>`;
return `<option value="${h.id}" data-name="${UI.escape(h.name)}" ${currentId == h.id ? 'selected' : ''}>${UI.escape(label)}</option>`;
}).join('');
return `
<select class="form-control" name="${idName}" id="${idName}-sel" style="margin-bottom:var(--space-2)">
<select class="form-control" name="${idName}" id="${idName}-sel" class="mb-2">
<option value=""> ${placeholder} </option>
${opts}
</select>
<input class="form-control" type="text" name="${name}" id="${name}-txt"
value="${_esc(currentName || '')}" placeholder="oder Namen frei eingeben">`;
value="${UI.escape(currentName || '')}" placeholder="oder Namen frei eingeben">`;
};
const rangOpts = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(l =>
@ -949,11 +945,11 @@ window.Page_litters = (() => {
<label class="form-label">Wurf-Name <span style="font-weight:normal;color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="wurf_name"
placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …"
value="${_esc(v.wurf_name || '')}">
value="${UI.escape(v.wurf_name || '')}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Vater</label>
${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')}
@ -968,18 +964,18 @@ window.Page_litters = (() => {
<div class="form-group">
<label class="form-label">Erwarteter Geburtstermin <span style="font-weight:normal;color:var(--c-text-muted)">(geplant)</span></label>
<input class="form-control" type="date" name="erwartetes_datum"
value="${_esc(v.erwartetes_datum || '')}">
value="${UI.escape(v.erwartetes_datum || '')}">
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Für geplante Würfe / laufende Trächtigkeit</p>
</div>
<div class="form-group">
<label class="form-label">Geburtsdatum <span style="font-weight:normal;color:var(--c-text-muted)">(tatsächlich)</span></label>
<input class="form-control" type="date" name="geburt_datum"
value="${_esc(v.geburt_datum || '')}">
value="${UI.escape(v.geburt_datum || '')}">
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:4px 0 0">Wenn die Welpen bereits geboren sind</p>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Welpen gesamt</label>
<input class="form-control" type="number" name="welpen_gesamt" min="0"
@ -1005,19 +1001,19 @@ window.Page_litters = (() => {
<div class="form-group">
<label class="form-label">Preisspanne</label>
<input class="form-control" type="text" name="preis_spanne"
value="${_esc(v.preis_spanne || '')}" placeholder="z. B. 1.500 2.000 €">
value="${UI.escape(v.preis_spanne || '')}" placeholder="z. B. 1.500 2.000 €">
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="Elternlinie, Besonderheiten, Charakter…">${_esc(v.beschreibung || '')}</textarea>
placeholder="Elternlinie, Besonderheiten, Charakter…">${UI.escape(v.beschreibung || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Gesundheitstests <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Gesundheitstests <span class="text-secondary">(optional)</span></label>
<textarea class="form-control" name="gesundheitstests" rows="2"
placeholder="HD, ED, Gentest, Augenkontrolle…">${_esc(v.gesundheitstests || '')}</textarea>
placeholder="HD, ED, Gentest, Augenkontrolle…">${UI.escape(v.gesundheitstests || '')}</textarea>
</div>
<div class="form-group">
@ -1028,9 +1024,9 @@ window.Page_litters = (() => {
</div>
<div class="form-group">
<label class="form-label">Sichtbar bis <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Sichtbar bis <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="date" name="sichtbar_bis"
value="${_esc(v.sichtbar_bis || '')}">
value="${UI.escape(v.sichtbar_bis || '')}">
</div>
</form>
@ -1134,11 +1130,11 @@ window.Page_litters = (() => {
const body = `
<form id="puppy-form" autocomplete="off">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Name <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Name <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="text" name="name"
value="${_esc(v.name || '')}" placeholder="z. B. Max">
value="${UI.escape(v.name || '')}" placeholder="z. B. Max">
</div>
<div class="form-group">
<label class="form-label">Geschlecht</label>
@ -1153,7 +1149,7 @@ window.Page_litters = (() => {
<div class="form-group">
<label class="form-label">Farbe / Fellzeichnung</label>
<input class="form-control" type="text" name="farbe"
value="${_esc(v.farbe || '')}" placeholder="z. B. schwarz-braun">
value="${UI.escape(v.farbe || '')}" placeholder="z. B. schwarz-braun">
</div>
<div class="form-group">
@ -1165,11 +1161,11 @@ window.Page_litters = (() => {
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Chip-Nr.</label>
<input class="form-control" type="text" name="chip_nr"
value="${_esc(v.chip_nr || '')}" placeholder="15-stellig">
value="${UI.escape(v.chip_nr || '')}" placeholder="15-stellig">
</div>
<div class="form-group">
<label class="form-label">Geburtsgewicht (g)</label>
@ -1186,9 +1182,9 @@ window.Page_litters = (() => {
</div>
<div class="form-group">
<label class="form-label">Notiz <span style="color:var(--c-text-secondary)">(intern)</span></label>
<label class="form-label">Notiz <span class="text-secondary">(intern)</span></label>
<textarea class="form-control" name="notiz" rows="2"
placeholder="Interne Notizen…">${_esc(v.notiz || '')}</textarea>
placeholder="Interne Notizen…">${UI.escape(v.notiz || '')}</textarea>
</div>
</form>
@ -1249,22 +1245,22 @@ window.Page_litters = (() => {
const body = `
<form id="contract-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name des Käufers <span style="color:var(--c-danger)">*</span></label>
<label class="form-label">Name des Käufers <span class="text-danger">*</span></label>
<input class="form-control" type="text" name="kaeufer_name" required
placeholder="Vor- und Nachname">
</div>
<div class="form-group">
<label class="form-label">Adresse des Käufers <span style="color:var(--c-danger)">*</span></label>
<label class="form-label">Adresse des Käufers <span class="text-danger">*</span></label>
<textarea class="form-control" name="kaeufer_adresse" rows="2" required
placeholder="Straße, PLZ, Ort"></textarea>
</div>
<div class="form-group">
<label class="form-label">E-Mail des Käufers <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">E-Mail des Käufers <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="email" name="kaeufer_email"
placeholder="kaeufer@beispiel.de">
</div>
<div class="form-group">
<label class="form-label">Kaufpreis <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Kaufpreis <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="text" name="preis"
placeholder="z. B. 1.500 €">
</div>
@ -1279,7 +1275,7 @@ window.Page_litters = (() => {
`;
UI.modal.open({
title: `${UI.icon('file-text')} Kaufvertrag — ${_esc(puppyLabel)}`,
title: `${UI.icon('file-text')} Kaufvertrag — ${UI.escape(puppyLabel)}`,
body,
footer,
});
@ -1317,11 +1313,11 @@ window.Page_litters = (() => {
const visOrder = ['public', 'inquiry', 'private'];
const body = `
<div id="${galleryId}" style="margin-bottom:var(--space-4)">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p>
<div id="${galleryId}" class="mb-4">
<p class="text-sm-muted">Lädt</p>
</div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)">
<form id="${uploadFormId}" style="display:flex;flex-direction:column;gap:var(--space-2)">
<form id="${uploadFormId}" class="flex-col-gap-2">
<label style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">
${UI.icon('upload-simple')} Foto hochladen
</label>
@ -1336,7 +1332,7 @@ window.Page_litters = (() => {
`;
UI.modal.open({
title: `${UI.icon('images')} Fotos — ${_esc(label)}`,
title: `${UI.icon('images')} Fotos — ${UI.escape(label)}`,
body,
footer,
});
@ -1348,7 +1344,7 @@ window.Page_litters = (() => {
try {
const photos = await API.breederPhotos.list(entityType, entityId);
if (!photos.length) {
el.innerHTML = `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos vorhanden.</p>`;
el.innerHTML = `<p class="text-sm-muted">Noch keine Fotos vorhanden.</p>`;
return;
}
el.innerHTML = `
@ -1358,21 +1354,21 @@ window.Page_litters = (() => {
const vis = visLabels[ph.visibility] || visLabels.private;
return `
<div style="position:relative;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--c-border);aspect-ratio:1">
<a href="${_esc(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}"
<a href="${UI.escape(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${UI.escape(thumb)}" alt="${UI.escape(ph.caption || '')}"
loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.src='/static/img/placeholder.webp'">
</a>
<button class="photos-vis-btn"
data-photo-id="${ph.id}"
data-vis="${_esc(ph.visibility)}"
data-vis="${UI.escape(ph.visibility)}"
title="Sichtbarkeit ändern"
style="position:absolute;bottom:0;left:0;right:0;
background:${vis.color};color:#fff;
border:none;cursor:pointer;font-size:10px;padding:2px 4px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(vis.text)}
${UI.escape(vis.text)}
</button>
<button class="photos-del-btn"
data-photo-id="${ph.id}"
@ -1418,7 +1414,7 @@ window.Page_litters = (() => {
} catch (err) {
const el = document.getElementById(galleryId);
if (el) el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${_esc(err.message || 'Fehler beim Laden.')}</p>`;
if (el) el.innerHTML = `<p style="color:var(--c-danger);font-size:var(--text-sm)">${UI.escape(err.message || 'Fehler beim Laden.')}</p>`;
}
}
@ -1464,13 +1460,13 @@ window.Page_litters = (() => {
const issueHTML = (welfare.issues || []).map(i => `
<div style="display:flex;gap:8px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,.06)">
<span style="color:${color};flex-shrink:0">${UI.icon('warning')}</span>
<span style="font-size:var(--text-sm)">${_esc(i.text)}</span>
<span class="text-sm">${UI.escape(i.text)}</span>
</div>`).join('');
const okHTML = (welfare.ok_points || []).map(p => `
<div style="display:flex;gap:8px;padding:4px 0">
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span>
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(p)}</span>
<span class="text-sm-secondary">${UI.escape(p)}</span>
</div>`).join('');
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
@ -1500,7 +1496,7 @@ window.Page_litters = (() => {
Trotzdem fortfahren
</button>
</div>` : `
<button class="btn btn-primary" data-modal-close style="width:100%">
<button class="btn btn-primary" data-modal-close class="w-full">
${UI.icon('check')} Verstanden
</button>`,
});
@ -1540,7 +1536,7 @@ window.Page_litters = (() => {
} catch (err) {
UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<p style="color:var(--c-danger)">${_esc(err.message || 'Fehler beim Generieren.')}</p>`,
body: `<p class="text-danger">${UI.escape(err.message || 'Fehler beim Generieren.')}</p>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
});
return;
@ -1548,7 +1544,7 @@ window.Page_litters = (() => {
UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${_esc(text)}</div>`,
body: `<div style="white-space:pre-wrap;font-size:var(--text-sm);line-height:1.6">${UI.escape(text)}</div>`,
footer: `
<button class="btn btn-secondary flex-1" id="ki-announce-copy">
${UI.icon('clipboard-text')} Kopieren

View file

@ -130,54 +130,24 @@ window.Page_lost = (() => {
document.getElementById('lost-btn-report')
?.addEventListener('click', _showReportForm);
await _loadLeaflet();
_initMap();
await _initMap();
setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad();
}
// ----------------------------------------------------------
// LEAFLET DYNAMISCH LADEN
// KARTE INITIALISIEREN (lädt Leaflet via UI.map.create)
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
await new Promise(resolve => {
if (document.querySelector('link[href*="leaflet"]')) { resolve(); return; }
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = resolve;
link.onerror = resolve;
document.head.appendChild(link);
});
await new Promise((resolve, reject) => {
if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
async function _initMap() {
_injectStyles();
const mapEl = document.getElementById('lost-map');
if (!mapEl || !window.L || _map) return;
if (!mapEl || _map) return;
_map = L.map('lost-map', { zoomControl: true, attributionControl: false })
.setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(_map);
_map = await UI.map.create('lost-map', {
center: [51.1657, 10.4515], zoom: 6,
zoomControl: true, attributionControl: false,
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
@ -303,26 +273,21 @@ window.Page_lost = (() => {
_reports.forEach(r => {
const dotColor = r._isPending ? '#d97706' : '#e74c3c';
const anim = r._isPending ? 'by-lost-pulse-p' : 'by-lost-pulse-r';
const icon = L.divIcon({
className : '',
html : `<div style="background:${dotColor};color:#fff;border-radius:50%;
const html = `<div style="background:${dotColor};color:#fff;border-radius:50%;
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
font-size:17px;border:2px solid #fff;
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
animation:${anim} 1.8s ease-in-out infinite">🐕</div>`;
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${Math.round(r.distanz_m)} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
const marker = L.marker([r.lat, r.lon], { icon })
const marker = UI.map.svgMarker(r.lat, r.lon, html, { size: 34, anchorY: 17 })
.addTo(_map)
.bindPopup(`
<b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
<b>🔍 ${UI.escape(r.name)}</b><br>
${r.rasse ? UI.escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
${r._isPending ? '<small>⏳ Sync ausstehend</small><br>' : ''}
<small>📅 ${_fmtDate(r.created_at)}</small>
@ -413,14 +378,14 @@ window.Page_lost = (() => {
border-radius:var(--radius-md);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:2rem">🐕</div>`}
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span style="font-weight:var(--weight-semibold);font-size:var(--text-base)">
${_escape(r.name)}
${UI.escape(r.name)}
</span>
${r.rasse
? `<span class="badge">${_escape(r.rasse)}</span>`
? `<span class="badge">${UI.escape(r.rasse)}</span>`
: ''}
${isOwn
? '<span class="badge badge-warning">Meine Meldung</span>'
@ -434,11 +399,11 @@ window.Page_lost = (() => {
</div>
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
color:var(--c-text)">
${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<div class="text-xs-secondary">
Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
${r.melder_name ? '· ' + UI.escape(r.melder_name.split(' ')[0]) : ''}
</div>
${r._isPending
? `<div style="margin-top:var(--space-2);display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
@ -450,10 +415,10 @@ window.Page_lost = (() => {
🗑 Verwerfen
</button>
</div>`
: (_appState.user ? `<div style="margin-top:var(--space-2)">
: (_appState.user ? `<div class="mt-2">
<button class="btn btn-ghost btn-xs lost-note-btn"
data-lost-note-id="${r.id}"
data-lost-note-name="${_escape(r.name)}"
data-lost-note-name="${UI.escape(r.name)}"
title="Notiz" onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>
@ -482,19 +447,19 @@ window.Page_lost = (() => {
: ''}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-danger">🐕 ${_escape(r.name)}</span>
${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
<span class="badge badge-danger">🐕 ${UI.escape(r.name)}</span>
${r.rasse ? `<span class="badge">${UI.escape(r.rasse)}</span>` : ''}
</div>
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
${_escape(r.beschreibung)}
${UI.escape(r.beschreibung)}
</p>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4);line-height:1.8">
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${_escape(r.melder_name.split(' ')[0])}</div>` : ''}
${r.melder_name ? `<div>👤 Gemeldet von: ${UI.escape(r.melder_name.split(' ')[0])}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
@ -508,7 +473,7 @@ window.Page_lost = (() => {
</div>
`;
UI.modal.open({ title: `🔍 ${_escape(r.name)} wird vermisst`, body });
UI.modal.open({ title: `🔍 ${UI.escape(r.name)} wird vermisst`, body });
document.getElementById('detail-lost-map')?.addEventListener('click', () => {
UI.modal.close();
@ -546,10 +511,10 @@ window.Page_lost = (() => {
// ----------------------------------------------------------
function _showFoundDialog(r) {
UI.modal.open({
title: `🎉 ${_escape(r.name)} gefunden?`,
title: `🎉 ${UI.escape(r.name)} gefunden?`,
body: `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als
Wurde ${UI.escape(r.name)} wiedergefunden? Die Meldung wird als
abgeschlossen markiert und aus der Liste entfernt.
</p>
`,
@ -590,7 +555,7 @@ window.Page_lost = (() => {
const dogs = _appState.dogs || [];
const dogOpts = dogs.length > 0
? `<option value="">— kein registrierter Hund —</option>` +
dogs.map(d => `<option value="${d.id}">${_escape(d.name)}${d.rasse ? ' (' + _escape(d.rasse) + ')' : ''}</option>`).join('')
dogs.map(d => `<option value="${d.id}">${UI.escape(d.name)}${d.rasse ? ' (' + UI.escape(d.rasse) + ')' : ''}</option>`).join('')
: '';
const body = `
@ -600,7 +565,7 @@ window.Page_lost = (() => {
<div class="form-group">
<label class="form-label">
Registrierter Hund
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
</label>
<select class="form-control" name="dog_id" id="lf-dog-select">
${dogOpts}
@ -616,7 +581,7 @@ window.Page_lost = (() => {
<div class="form-group">
<label class="form-label">
Rasse
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
</label>
<input class="form-control" type="text" name="rasse"
placeholder="z. B. Labrador">
@ -643,7 +608,7 @@ window.Page_lost = (() => {
</div>
<input type="hidden" name="lat" id="lf-lat">
<input type="hidden" name="lon" id="lf-lon">
<small id="lf-gps-hint" style="color:var(--c-text-secondary)">
<small id="lf-gps-hint" class="text-secondary">
${_userPos
? '✅ Aktueller Standort vorausgefüllt'
: 'GPS-Button drücken um Standort zu ermitteln'}
@ -653,7 +618,7 @@ window.Page_lost = (() => {
<div class="form-group">
<label class="form-label">
Foto
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
@ -825,17 +790,7 @@ window.Page_lost = (() => {
day: '2-digit', month: '2-digit', year: 'numeric'
});
}
function _escape(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _emptyState(icon, title, text, cta = '') {
function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>
@ -864,7 +819,7 @@ window.Page_lost = (() => {
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_escape(parentLabel)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${UI.escape(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>

View file

@ -221,7 +221,7 @@ window.Page_map = (() => {
<div class="map-statusbar" id="map-statusbar">
<span id="map-zoom-info"></span>
<span id="map-osm-status" style="display:none"></span>
<span id="map-osm-status" class="hidden"></span>
<span class="map-statusbar-sep map-weather-chip--hidden" id="map-weather-sep">·</span>
<span class="map-weather-chip--hidden" id="map-weather-info"></span>
</div>
@ -1344,15 +1344,15 @@ window.Page_map = (() => {
});
const marker = L.marker([b.location_lat, b.location_lng], { icon, zIndexOffset: t.z ?? 0 })
.bindTooltip(_esc(b.zwingername), { direction: 'top', offset: [0, -16] });
.bindTooltip(UI.escape(b.zwingername), { direction: 'top', offset: [0, -16] });
marker.on('click', () => {
const rasseText = b.rasse_text ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${_esc(b.rasse_text)}</div>` : '';
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${_esc(b.stadt)}</div>` : '';
const rasseText = b.rasse_text ? `<div style="font-size:12px;color:#666;margin-bottom:4px">${UI.escape(b.rasse_text)}</div>` : '';
const stadtText = b.stadt ? `<div style="font-size:12px;color:#888;margin-bottom:8px">${UI.escape(b.stadt)}</div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${_esc(b.zwingername)}</div>
<div style="font-weight:600;margin-bottom:6px">${t.icon} ${UI.escape(b.zwingername)}</div>
${rasseText}${stadtText}
<button class="btn btn-primary btn-sm" id="breeder-profile-btn">Profil ansehen</button>
</div>
@ -1780,7 +1780,7 @@ window.Page_map = (() => {
border:1.5px solid var(--c-border);border-radius:100px;cursor:pointer;
font-size:var(--text-xs);font-weight:600;user-select:none">
<input type="checkbox" name="dog_ids" value="${d.id}" ${checked ? 'checked' : ''}
style="display:none" class="rec-dog-cb">
class="rec-dog-cb hidden">
${av}<span>${UI.escape(d.name)}</span>
</label>`;
}).join('')}
@ -1798,7 +1798,7 @@ window.Page_map = (() => {
<input class="form-control" type="text" name="name"
placeholder="Wird automatisch ermittelt…" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="grid-2">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit">
@ -1842,7 +1842,7 @@ window.Page_map = (() => {
</label>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Beschreibung <span class="text-secondary">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div>

View file

@ -161,20 +161,20 @@ window.Page_moderation = (() => {
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
gap:var(--space-4)">
${fotos.map(f => `
<div class="card" style="padding:var(--space-4)" data-id="${f.id}">
<a href="#wiki?rasse=${_esc(f.rasse_slug)}" style="display:block;text-decoration:none">
<img src="${_esc(f.foto_url)}" alt=""
<div class="card p-4" data-id="${f.id}">
<a href="#wiki?rasse=${UI.escape(f.rasse_slug)}" style="display:block;text-decoration:none">
<img src="${UI.escape(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;
border-radius:var(--radius-md);margin-bottom:var(--space-3)">
</a>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">
${_esc(f.rasse_name || f.rasse_slug)}
${UI.escape(f.rasse_name || f.rasse_slug)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-2)">
von ${_esc(f.user_name)}
von ${UI.escape(f.user_name)}
</div>
<div style="margin-bottom:var(--space-3)">
<div class="mb-3">
${f.rights_confirmed
? `<span style="font-size:10px;font-weight:700;padding:2px 8px;border-radius:20px;
background:#dcfce7;color:#166534"> Bildrechte bestätigt</span>`
@ -183,17 +183,17 @@ window.Page_moderation = (() => {
</div>
${f.aktuell_foto ? `
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:4px">Aktuell:</div>
<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
<img src="${UI.escape(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:70px;object-fit:cover;
border-radius:var(--radius-sm);opacity:.5;
margin-bottom:var(--space-3)">
` : `<div style="font-size:var(--text-xs);color:var(--c-warning);
margin-bottom:var(--space-3)">Noch kein Foto vorhanden</div>`}
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
<button class="btn btn-sm btn-primary mod-foto-approve"
data-id="${f.id}" style="flex:1"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
data-id="${f.id}" class="flex-1"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
<button class="btn btn-sm btn-ghost mod-foto-reject"
data-id="${f.id}" style="color:var(--c-danger)"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg> Ablehnen</button>
data-id="${f.id}" class="text-danger"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg> Ablehnen</button>
</div>
</div>
`).join('')}
@ -287,7 +287,7 @@ window.Page_moderation = (() => {
el.innerHTML = `
<div style="margin-bottom:var(--space-2);font-size:var(--text-xs);
color:var(--c-text-muted)">${total} Nutzer gefunden</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
<div class="flex-col-gap-2">
${visible.map(u => {
const isAdminUser = u.rolle === 'admin' || u.is_admin;
const canAction = isAdmin && !isAdminUser;
@ -299,23 +299,23 @@ window.Page_moderation = (() => {
background:var(--c-surface-2);
display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary)">
${_esc(u.name[0].toUpperCase())}
${UI.escape(u.name[0].toUpperCase())}
</div>
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">
${_esc(u.name)}
${UI.escape(u.name)}
${u.is_banned ? `<span style="font-size:10px;padding:1px 5px;
border-radius:3px;background:var(--c-danger);
color:#fff;margin-left:4px">GESPERRT</span>` : ''}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
${_esc(u.email)} ·
<div class="text-xs-muted">
${UI.escape(u.email)} ·
<span style="color:${
u.rolle === 'admin' ? 'var(--c-danger)'
: u.rolle === 'moderator' ? '#f59e0b'
: 'var(--c-text-muted)'}">
${_esc(u.rolle)}
${UI.escape(u.rolle)}
</span>
</div>
</div>
@ -323,13 +323,13 @@ window.Page_moderation = (() => {
${canAction
? (u.is_banned
? `<button class="btn btn-sm btn-ghost mod-unban"
data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperre aufheben" style="color:var(--c-success)">
data-uid="${u.id}" data-name="${UI.escape(u.name)}"
title="Sperre aufheben" class="text-success">
${UI.icon('lock-open')}
</button>`
: `<button class="btn btn-sm btn-ghost mod-ban"
data-uid="${u.id}" data-name="${_esc(u.name)}"
title="Sperren" style="color:var(--c-danger)">
data-uid="${u.id}" data-name="${UI.escape(u.name)}"
title="Sperren" class="text-danger">
${UI.icon('lock')}
</button>`)
: ''
@ -400,27 +400,27 @@ window.Page_moderation = (() => {
return;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="flex-col-gap-3">
${reports.map(r => `
<div class="card" style="padding:var(--space-4);
border-left:3px solid var(--c-danger)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-1)">
${_esc(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${_esc(r.melder_name)}</strong>
${UI.escape(r.target_type)} #${r.target_id} ·
Gemeldet von <strong>${UI.escape(r.melder_name)}</strong>
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
Grund: ${UI.escape(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);
border-radius:var(--radius-sm)">
${_esc(r.content_preview)}
${UI.escape(r.content_preview)}
</div>` : ''}
</div>
<button class="btn btn-sm btn-primary mod-resolve-btn"
@ -476,14 +476,14 @@ window.Page_moderation = (() => {
const STATUS_COLOR = { pending: 'var(--c-warning)', approved: 'var(--c-success,#22c55e)', rejected: 'var(--c-danger)' };
el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="flex-col-gap-3">
${edits.map(e => `
<div class="card" style="padding:var(--space-4)" data-edit-id="${e.id}">
<div class="card p-4" data-edit-id="${e.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-2);flex-wrap:wrap">
<div>
<div style="font-weight:600">${_esc(e.poi_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
OSM-ID: ${_esc(e.osm_id)} · Feld: ${_esc(e.field)} · von ${_esc(e.einreicher_name)}
<div style="font-weight:600">${UI.escape(e.poi_name)}</div>
<div class="text-xs-muted">
OSM-ID: ${UI.escape(e.osm_id)} · Feld: ${UI.escape(e.field)} · von ${UI.escape(e.einreicher_name)}
· ${new Date(e.created_at).toLocaleDateString('de-DE')}
</div>
</div>
@ -494,11 +494,11 @@ window.Page_moderation = (() => {
<div style="margin-top:var(--space-3);display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Aktuell</div>
<div style="font-size:var(--text-sm)">${_esc(e.old_value) || '<em style="color:var(--c-text-muted)">leer</em>'}</div>
<div class="text-sm">${UI.escape(e.old_value) || '<em class="text-muted">leer</em>'}</div>
</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-sm);padding:var(--space-2)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:2px">Vorschlag</div>
<div style="font-size:var(--text-sm);font-weight:600">${_esc(e.new_value)}</div>
<div style="font-size:var(--text-sm);font-weight:600">${UI.escape(e.new_value)}</div>
</div>
</div>
${e.status === 'pending' ? `
@ -532,15 +532,6 @@ window.Page_moderation = (() => {
});
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ------------------------------------------------------------------
return { init, refresh, onDogChange };

View file

@ -88,7 +88,7 @@ window.Page_movies = (() => {
<div class="movies-search-row">
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" id="movies-search" class="form-control movies-search-input"
placeholder="Film, Serie oder Rasse suchen …" value="${_esc(_search)}" autocomplete="off">
placeholder="Film, Serie oder Rasse suchen …" value="${UI.escape(_search)}" autocomplete="off">
</div>
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
@ -96,7 +96,7 @@ window.Page_movies = (() => {
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button>
</div>
<div class="movies-filter-row" style="margin-top:var(--space-2)">
<div class="movies-filter-row mt-2">
<button class="movies-filter-btn movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme</button>
<button class="movies-filter-btn movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie"><svg class="ph-icon" aria-hidden="true" style="width:15px;height:15px"><use href="/icons/phosphor.svg#list"></use></svg> Serien</button>
@ -201,18 +201,18 @@ window.Page_movies = (() => {
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
const _ico = name => `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px;vertical-align:middle"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
const typLabel = film.typ === 'serie' ? `${_ico('list')} Serie` : film.typ === 'doku' ? `${_ico('camera')} Doku` : '';
const imdb = film.imdb_rating ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">IMDb ${film.imdb_rating}</span>` : '';
const streaming = film.streaming ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(film.streaming)}</span>` : '';
const imdb = film.imdb_rating ? `<span class="text-xs-muted">IMDb ${film.imdb_rating}</span>` : '';
const streaming = film.streaming ? `<span class="text-xs-muted">${UI.escape(film.streaming)}</span>` : '';
return `
<div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card" data-film-id="${UI.escape(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body">
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-title">${UI.escape(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre" style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
<span>${_esc(film.genre)}</span>${typLabel ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${typLabel}</span>` : ''}
<span>${UI.escape(film.genre)}</span>${typLabel ? `<span class="text-xs-muted">${typLabel}</span>` : ''}
</div>
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${UI.escape(film.hund_rasse)}</div>
${tag}
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
<div class="movie-card-stars">${stars}</div>
@ -234,17 +234,17 @@ window.Page_movies = (() => {
const body = `
<div class="movie-modal-emoji">${film.bild_emoji}</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${_esc(film.genre)}</span>
<span class="badge"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</span>
<span class="badge badge-primary">${UI.escape(film.genre)}</span>
<span class="badge"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${UI.escape(film.hund_rasse)}</span>
<span class="badge">${film.jahr}</span>
</div>
<div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</div>
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${_esc(film.beschreibung)}</p>
<div style="margin-bottom:var(--space-2)">
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${UI.escape(film.beschreibung)}</p>
<div class="mb-2">
<strong>Community-Bewertung:</strong>
</div>
<div id="modal-stars-${_esc(film.id)}">${stars}</div>
<div id="modal-avg-${_esc(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">
<div id="modal-stars-${UI.escape(film.id)}">${stars}</div>
<div id="modal-avg-${UI.escape(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">
Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen
</div>
${loginHint}
@ -262,9 +262,9 @@ window.Page_movies = (() => {
const filled = Math.round(avg);
const stars = [1,2,3,4,5].map(i => {
const active = i <= (userRating || filled) ? ' movie-star--active' : '';
return `<span class="movie-star${active}" data-film-id="${_esc(filmId)}" data-val="${i}"><svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px"><use href="/icons/phosphor.svg#star"></use></svg></span>`;
return `<span class="movie-star${active}" data-film-id="${UI.escape(filmId)}" data-val="${i}"><svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px"><use href="/icons/phosphor.svg#star"></use></svg></span>`;
}).join('');
return `<div class="movie-star-rating" data-film-id="${_esc(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
return `<div class="movie-star-rating" data-film-id="${UI.escape(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
}
function _bindStarRatings(container) {
@ -339,9 +339,9 @@ window.Page_movies = (() => {
<div class="movie-promi-card">
<div class="movie-promi-emoji">${p.emoji}</div>
<div class="movie-promi-body">
<div class="movie-promi-name">${_esc(p.name)}</div>
<div class="movie-promi-rasse">${_esc(p.rasse)}</div>
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
<div class="movie-promi-name">${UI.escape(p.name)}</div>
<div class="movie-promi-rasse">${UI.escape(p.rasse)}</div>
<div class="movie-promi-text">${UI.escape(p.bekannt_fuer)}</div>
</div>
</div>
`).join('')}
@ -370,13 +370,13 @@ window.Page_movies = (() => {
const voteCards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
<div class="hdm-vote-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''}
<button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}">
${isVoted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Gewählt' : 'Abstimmen'}
</button>
@ -405,16 +405,16 @@ window.Page_movies = (() => {
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
<div class="hdm-top-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} <svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg></div>
@ -427,7 +427,7 @@ window.Page_movies = (() => {
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
<div class="hdm-monat">${UI.escape(monthName)}</div>
</div>
${voteSection}
@ -465,16 +465,7 @@ window.Page_movies = (() => {
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str && str !== 0) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };

View file

@ -47,14 +47,6 @@ window.Page_notes = (() => {
// ----------------------------------------------------------
// Hilfsfunktionen
// ----------------------------------------------------------
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _formatTime(isoStr) {
if (!isoStr) return '';
@ -76,7 +68,10 @@ window.Page_notes = (() => {
} catch (_) { return 'Älteres'; }
}
function _truncate(str, max = 150) {
function _truncate(str, max = 600) {
// Karten zeigen max 5 Zeilen via CSS-Clamp — Text muss lang genug
// sein dass die Clamp greift. Bei sehr langen Notes: vor Clamp abschneiden
// damit der String nicht riesig in der DOM-Page steht.
if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str;
}
@ -125,7 +120,7 @@ window.Page_notes = (() => {
.filter(([, items]) => items.length > 0)
.map(([label, items]) => `
<div class="notes-group">
<div class="notes-group-label">${_esc(label)}</div>
<div class="list-group-header">${UI.escape(label)}</div>
${items.map(_noteCard).join('')}
</div>
`).join('');
@ -166,9 +161,9 @@ window.Page_notes = (() => {
<div class="notes-filter-chips">
${RUBRIKEN.map(r => `
<button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}"
data-type="${_esc(r.type)}"
data-type="${UI.escape(r.type)}"
style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}">
${_esc(r.label)}
${UI.escape(r.label)}
</button>
`).join('')}
</div>
@ -178,7 +173,7 @@ window.Page_notes = (() => {
<div class="notes-search-wrap">
<svg class="ph-icon notes-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input id="notes-search" type="search" class="notes-search-input"
placeholder="Suche…" value="${_esc(_searchQ)}">
placeholder="Suche…" value="${UI.escape(_searchQ)}">
</div>
<div class="notes-sort-btns">
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
@ -243,21 +238,32 @@ window.Page_notes = (() => {
/* Gruppen */
.notes-group { display: flex; flex-direction: column; gap: var(--space-2); }
/* TODO nach Migration entfernen: ersetzt durch .list-group-header in lists.css */
.notes-group-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--c-text-muted); text-transform: uppercase; letter-spacing: .05em; padding: var(--space-1) 0; }
/* Karten */
.notes-card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: var(--space-3) var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); }
.notes-rubrik-chip { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 2px var(--space-2); border-radius: 999px; flex-shrink: 0; }
/* Karten — Notes-spezifischer Override: vertikales Layout statt horizontalem .list-item-card */
.notes-card { flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; }
/* TODO nach Migration entfernen: ersetzt durch .list-item-chip */
/* .notes-rubrik-chip { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 2px var(--space-2); border-radius: 999px; flex-shrink: 0; } */
.notes-parent-label { font-size: var(--text-xs); color: var(--c-text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; }
.notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); }
.notes-card-actions { display: flex; gap: var(--space-2); margin-left: auto; flex-shrink: 0; }
.notes-card-text { font-size: var(--text-sm); color: var(--c-text); line-height: 1.55; white-space: pre-wrap; margin: 0; }
.notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.notes-micro-badge { font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); background: var(--c-surface-2); color: var(--c-text-secondary); }
.notes-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-muted); cursor: pointer; font-size: 1rem; transition: background .15s, color .15s; }
.notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); }
.notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); }
/* TODO nach Migration entfernen: ersetzt durch .list-item-meta-row */
/* .notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); } */
/* Notes-Override: Actions in Top-Zeile rechts ausrichten (statt align-self:center bei list-item-actions) */
.notes-card-actions { margin-left: auto; align-self: flex-start; }
/* Notes-Override: Newlines (pre-wrap) + max 5 Zeilen mit "…", Rest in Detail-Modal */
.notes-card-text { line-height: 1.55; white-space: pre-wrap; margin: 0; color: var(--c-text);
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; }
/* Detail-Modal: voller Notiz-Text scrollbar */
.notes-detail-text { white-space: pre-wrap; line-height: 1.6; font-size: var(--text-base);
color: var(--c-text); margin: 0; max-height: 60vh; overflow-y: auto; }
/* TODO nach Migration entfernen: ersetzt durch .list-item-micro-badges / .list-item-micro-badge */
/* .notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); } */
/* .notes-micro-badge { font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); background: var(--c-surface-2); color: var(--c-text-secondary); } */
/* TODO nach Migration entfernen: ersetzt durch .list-item-action-btn / --danger */
/* .notes-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-muted); cursor: pointer; font-size: 1rem; transition: background .15s, color .15s; } */
/* .notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); } */
/* .notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); } */
.notes-list { display: flex; flex-direction: column; gap: var(--space-4); }
@keyframes spin { to { transform: rotate(360deg); } }
@ -285,11 +291,11 @@ window.Page_notes = (() => {
<button class="notes-ki-btn" id="notes-ki-analyse-btn" ${_kiLoading ? 'disabled' : ''}>
${_kiLoading ? '<svg class="ph-icon" aria-hidden="true" style="animation:spin 1s linear infinite"><use href="/icons/phosphor.svg#spinner-gap"></use></svg> Analysiere…' : 'Analysieren'}
</button>
${_kiError ? `<div class="notes-ki-error"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-circle"></use></svg> ${_esc(_kiError)}</div>` : ''}
${_kiError ? `<div class="notes-ki-error"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-circle"></use></svg> ${UI.escape(_kiError)}</div>` : ''}
${_kiSuggestions ? `
<div class="notes-ki-suggestions">
<ul>
${_kiSuggestions.map(s => `<li>${_esc(s)}</li>`).join('')}
${_kiSuggestions.map(s => `<li>${UI.escape(s)}</li>`).join('')}
</ul>
</div>
` : ''}
@ -314,43 +320,42 @@ window.Page_notes = (() => {
const hasLocation = !!note.location_name;
return `
<div class="notes-card" data-id="${note.id}">
<div class="list-item-card list-item-card--clickable notes-card" data-id="${note.id}">
<!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons -->
<div class="notes-card-top">
<span class="notes-rubrik-chip"
style="background:${rb.color}22;color:${rb.color}">
<span class="list-item-chip" style="--chip-color:${rb.color}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg>
${_esc(rb.label)}
${UI.escape(rb.label)}
</span>
${note.parent_label
? `<span class="notes-parent-label" title="${_esc(note.parent_label)}">${_esc(note.parent_label)}</span>`
? `<span class="notes-parent-label" title="${UI.escape(note.parent_label)}">${UI.escape(note.parent_label)}</span>`
: ''
}
<div class="notes-card-actions">
<button class="notes-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
<div class="list-item-actions notes-card-actions">
<button class="list-item-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg>
</button>
<button class="notes-action-btn notes-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen">
<button class="list-item-action-btn list-item-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
<!-- Notiztext -->
<p class="notes-card-text">${_esc(_truncate(note.text))}</p>
<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>
<!-- Micro-Badges -->
${microBadges.length ? `
<div class="notes-micro-badges">
${microBadges.map(b => `<span class="notes-micro-badge">${_esc(b)}</span>`).join('')}
<div class="list-item-micro-badges">
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
</div>
` : ''}
<!-- Meta: Zeit + Ort -->
<div class="notes-card-meta">
<div class="list-item-meta-row">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg>
${_esc(_formatTime(note.updated_at || note.created_at))}
${hasLocation ? `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${_esc(note.location_name)}` : ''}
${UI.escape(_formatTime(note.updated_at || note.created_at))}
${hasLocation ? `<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${UI.escape(note.location_name)}` : ''}
</div>
</div>
`;
@ -460,6 +465,64 @@ window.Page_notes = (() => {
}
});
});
// Karte selbst klickbar → Detail-Modal mit vollem Text
_container.querySelectorAll('.notes-card').forEach(card => {
card.addEventListener('click', e => {
// Klicks auf Action-Buttons nicht doppelt verarbeiten
if (e.target.closest('.list-item-action-btn')) return;
const note = _notes.find(n => n.id === parseInt(card.dataset.id, 10));
if (note) _openDetailModal(note);
});
});
}
// ----------------------------------------------------------
// Detail-Modal: voller Notiz-Text + Meta + Bearbeiten/Löschen
// ----------------------------------------------------------
function _openDetailModal(note) {
const rb = RUBRIKEN.find(r => r.id === note.rubrik) || RUBRIKEN[0];
const meta = (() => { try { return JSON.parse(note.meta || '{}'); } catch { return {}; } })();
const microBadges = [];
if (meta.erfolg) microBadges.push(`🐾 ${meta.erfolg}/5`);
if (meta.umgebung) microBadges.push({ zuhause: '🏠 Zuhause', natur: '🌿 Natur', stadt: '🌆 Stadt' }[meta.umgebung] || meta.umgebung);
if (meta.hund_stimmung) microBadges.push({ super: '😊 Super', ok: '😐 Ok', mude: '😔 Müde' }[meta.hund_stimmung] || meta.hund_stimmung);
UI.modal.open({
title: `${UI.icon(rb.icon)} ${UI.escape(rb.label)}`,
body: `
<div class="flex-col-gap-3">
${note.parent_label
? `<div class="text-sm-secondary"><strong>${UI.escape(note.parent_label)}</strong></div>` : ''}
<p class="notes-detail-text">${UI.escape(note.text || '')}</p>
${microBadges.length ? `
<div class="list-item-micro-badges">
${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
</div>` : ''}
<div class="list-item-meta-row" style="margin-top:var(--space-2)">
<svg class="ph-icon icon-sm" aria-hidden="true"><use href="/icons/phosphor.svg#clock"></use></svg>
${UI.escape(_formatTime(note.updated_at || note.created_at))}
${note.location_name
? `<svg class="ph-icon icon-sm" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> ${UI.escape(note.location_name)}` : ''}
</div>
</div>
`,
footer: `
<div class="flex-gap-2" style="width:100%">
<button class="btn btn-ghost flex-1" id="notes-detail-edit">
${UI.icon('pencil')} Bearbeiten
</button>
<button class="btn btn-secondary" data-modal-close>Schließen</button>
</div>
`,
});
document.getElementById('notes-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_openEditModal(note);
});
}
// ----------------------------------------------------------
@ -499,7 +562,7 @@ window.Page_notes = (() => {
<h3 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-4)">Neue Notiz</h3>
<!-- Kategorie-Auswahl -->
<div style="margin-bottom:var(--space-4)">
<div class="mb-4">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Kategorie</label>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${ERSTELL_RUBRIKEN.map(r => `
@ -508,13 +571,13 @@ window.Page_notes = (() => {
border-radius:999px;border:1.5px solid ${_selType===r.type ? r.color : 'var(--c-border)'};
background:${_selType===r.type ? r.color+'22' : 'var(--c-surface-2)'};
color:${_selType===r.type ? r.color : 'var(--c-text-secondary)'};cursor:pointer">
${_esc(r.label)}
${UI.escape(r.label)}
</button>`).join('')}
</div>
</div>
<!-- Text -->
<div style="margin-bottom:var(--space-4)">
<div class="mb-4">
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
<textarea id="nc-text" rows="5" placeholder="Was möchtest du festhalten…"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
@ -524,9 +587,9 @@ window.Page_notes = (() => {
box-sizing:border-box"></textarea>
</div>
<div style="display:flex;gap:var(--space-3)">
<button id="nc-cancel" class="btn btn-ghost" style="flex:1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary" style="flex:1">Speichern</button>
<div class="flex-gap-3">
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>`;
};
@ -601,7 +664,7 @@ window.Page_notes = (() => {
<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);
font-weight:var(--weight-semibold);padding:2px var(--space-2);border-radius:999px;
background:${rb.color}22;color:${rb.color}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> ${_esc(rb.label)}
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> ${UI.escape(rb.label)}
</span>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
Notiz bearbeiten
@ -619,7 +682,7 @@ window.Page_notes = (() => {
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;line-height:1.5;
box-sizing:border-box">${_esc(note.text)}</textarea>
box-sizing:border-box">${UI.escape(note.text)}</textarea>
</div>
${note.parent_type === 'training_session' ? `
@ -627,7 +690,7 @@ window.Page_notes = (() => {
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung</label>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
${[1,2,3,4,5].map(n => `
<button type="button" class="notes-pfote" data-val="${n}"
style="font-size:1.3rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
@ -642,7 +705,7 @@ window.Page_notes = (() => {
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung</label>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => `
<button type="button" class="notes-umgebung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
@ -657,7 +720,7 @@ window.Page_notes = (() => {
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => `
<button type="button" class="notes-stimmung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);

View file

@ -85,7 +85,7 @@ window.Page_onboarding = (() => {
// ----------------------------------------------------------
function _step1() {
return `
<div style="text-align:center">
<div class="text-center">
<!-- Logo -->
<div style="margin-bottom:var(--space-6)">
@ -133,19 +133,19 @@ window.Page_onboarding = (() => {
<div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${title}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${desc}</div>
<div class="text-xs-secondary">${desc}</div>
</div>
</div>
`).join('')}
</div>
<!-- Buttons -->
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-primary" id="ob-next-btn" style="width:100%">
<div class="flex-col-gap-3">
<button class="btn btn-primary" id="ob-next-btn" class="w-full">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-right"></use></svg>
Los geht's
</button>
<button class="btn btn-ghost" id="ob-skip-btn" style="width:100%">
<button class="btn btn-ghost" id="ob-skip-btn" class="w-full">
Überspringen
</button>
</div>
@ -222,7 +222,7 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
<span id="ob-photo-label">Foto auswählen</span>
<input type="file" name="foto" id="ob-photo-input"
accept="image/*" style="display:none">
accept="image/*" class="hidden">
</label>
</div>
@ -234,13 +234,13 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button>
<button type="submit" form="ob-dog-form" class="btn btn-primary" id="ob-save-btn"
style="flex:1">
class="flex-1">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
Hund anlegen
</button>
</div>
<div style="text-align:center;margin-top:var(--space-3)">
<button class="btn btn-ghost" id="ob-skip-btn" style="font-size:var(--text-sm)">
<button class="btn btn-ghost" id="ob-skip-btn" class="text-sm">
Ohne Hund fortfahren
</button>
</div>
@ -255,7 +255,7 @@ window.Page_onboarding = (() => {
function _step3() {
const dogName = _appState.activeDog?.name;
return `
<div style="text-align:center">
<div class="text-center">
<!-- Erfolgs-Icon -->
<div style="margin-bottom:var(--space-6)">
@ -276,7 +276,7 @@ window.Page_onboarding = (() => {
${dogName ? `
<p style="font-size:var(--text-base);color:var(--c-text-secondary);
line-height:1.6;margin:0 0 var(--space-3)">
<strong>${_esc(dogName)}</strong> ist jetzt in Ban Yaro.
<strong>${UI.escape(dogName)}</strong> ist jetzt in Ban Yaro.
Du kannst jetzt Einträge im Tagebuch anlegen, die Gesundheit pflegen
und viele weitere Funktionen nutzen.
</p>
@ -294,13 +294,13 @@ window.Page_onboarding = (() => {
</p>
<!-- CTA -->
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<button class="btn btn-primary" id="ob-diary-btn" style="width:100%">
<div class="flex-col-gap-3">
<button class="btn btn-primary" id="ob-diary-btn" class="w-full">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
Zum Tagebuch
</button>
${dogName ? `
<button class="btn btn-secondary" id="ob-profile-btn" style="width:100%">
<button class="btn btn-secondary" id="ob-profile-btn" class="w-full">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
Profil vervollständigen
</button>
@ -416,7 +416,7 @@ window.Page_onboarding = (() => {
}
App.renderDogSwitcher();
UI.toast.success(`${_esc(dog.name)} wurde angelegt!`);
UI.toast.success(`${UI.escape(dog.name)} wurde angelegt!`);
_step = 3;
_render();
@ -452,9 +452,6 @@ window.Page_onboarding = (() => {
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(s) {
return UI.escape(s || '');
}
// ----------------------------------------------------------
// PUBLIC

View file

@ -0,0 +1,274 @@
/* ============================================================
BAN YARO Partner-Profil-Editor
Nur für User mit is_partner=1.
============================================================ */
window.Page_partner_profil = (() => {
let _container = null;
let _profile = null;
async function init(container, appState) {
_container = container;
_render();
await _load();
}
function refresh() { _load(); }
function onDogChange() {}
function _render() {
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<div style="margin-bottom:var(--space-5)">
<h1 style="font-size:var(--text-xl);font-weight:800;margin:0 0 var(--space-1)">
Mein Partner-Profil
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin:0">
Richte deine öffentliche Präsenz auf der Partner-Seite ein.
Nach dem Absenden prüfen wir dein Profil und schalten es frei.
</p>
</div>
<div id="pp-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade</div>
</div>
</div>
`;
}
async function _load() {
const el = _container.querySelector('#pp-content');
try {
const d = await API.get('/partner/my-profile');
_profile = d.profile || {};
_profile._storage_mb = d.storage_mb || 0;
_profile._storage_limit_mb = d.storage_limit_mb || 200;
el.innerHTML = _renderEditor();
_bindEvents(el);
} catch (e) {
el.innerHTML = `<p class="text-danger">${e.message}</p>`;
}
}
function _statusBadge() {
if (!_profile?.submitted_at && !_profile?.approved) return '';
const a = _profile.approved;
if (a === 1) return `<span style="background:#dcfce7;color:#16a34a;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">✓ Freigegeben</span>`;
if (a === -1) return `<span style="background:#fee2e2;color:#dc2626;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">✗ Abgelehnt</span>`;
if (_profile.submitted_at) return `<span style="background:#fef9c3;color:#a16207;padding:3px 10px;border-radius:999px;font-size:var(--text-xs);font-weight:700">⏳ In Prüfung</span>`;
return `<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Entwurf</span>`;
}
function _renderEditor() {
const p = _profile || {};
const photos = p.photos || [];
return `
<!-- Status -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
<span class="text-sm-muted">Status:</span>
${_statusBadge() || '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Noch kein Profil angelegt</span>'}
</div>
<!-- Logo -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Logo</div>
<div style="display:flex;align-items:center;gap:var(--space-4)">
<div id="pp-logo-preview" style="width:80px;height:80px;border-radius:var(--radius-md);
background:var(--c-surface-2);display:flex;align-items:center;justify-content:center;
overflow:hidden;flex-shrink:0">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" style="width:100%;height:100%;object-fit:contain">`
: `<svg class="ph-icon" style="width:32px;height:32px;opacity:.3"><use href="/icons/phosphor.svg#image"></use></svg>`}
</div>
<div>
<label class="btn btn-secondary btn-sm" style="cursor:pointer">
Logo hochladen
<input type="file" id="pp-logo-input" accept="image/*" class="hidden">
</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
PNG, JPG oder WebP · max. 5 MB · wird quadratisch zugeschnitten
</div>
</div>
</div>
</div>
<!-- Texte -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;
letter-spacing:.06em;color:var(--c-text-muted);margin-bottom:var(--space-3)">Texte</div>
<form id="pp-text-form" class="flex-col-gap-3">
<div class="form-group">
<label class="form-label">Anzeigename *</label>
<input class="form-control" name="display_name" type="text" maxlength="60" required
placeholder="z. B. Hundeblog Musterfrau"
value="${UI.escape(p.display_name || '')}">
</div>
<div class="form-group">
<label class="form-label">Kurzslogan <span style="font-weight:400;color:var(--c-text-muted)">(max. 80 Zeichen)</span></label>
<input class="form-control" name="tagline" type="text" maxlength="80"
placeholder="z. B. Hundetrainerin · 15.000 Follower auf Instagram"
value="${UI.escape(p.tagline || '')}">
</div>
<div class="form-group">
<label class="form-label">Über dich / euer Kanal</label>
<textarea class="form-control" name="bio" rows="4" maxlength="500"
placeholder="Wer bist du, was machst du, was verbindet dich mit Hunden?">${UI.escape(p.bio || p.pp_bio || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Website</label>
<input class="form-control" name="website" type="url"
placeholder="https://deine-seite.de"
value="${UI.escape(p.website || '')}">
</div>
<div class="form-group">
<label class="form-label">Instagram</label>
<input class="form-control" name="instagram" type="text"
placeholder="@deinkanal"
value="${UI.escape(p.instagram || '')}">
</div>
<button type="submit" class="btn btn-secondary btn-sm" style="align-self:flex-start">
Texte speichern
</button>
</form>
</div>
<!-- Fotos -->
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:var(--space-2)">
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--c-text-muted)">
Fotos & Videos <span style="font-weight:400">(max. 6)</span>
</div>
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">
JPG, PNG, HEIC, MP4, MOV · max. 200 MB pro Datei
</div>
<div class="mb-3">
${_storageBar(p._storage_mb || 0, p._storage_limit_mb || 200)}
</div>
<div id="pp-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);
gap:var(--space-2);margin-bottom:var(--space-3)">
${photos.map((url, i) => {
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
return `
<div style="position:relative;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2)">
${isVid
? `<video src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);
border-radius:4px;padding:1px 5px;font-size:10px;color:#fff"> Video</div>`
: `<img src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover">`}
<button class="pp-photo-del" data-idx="${i}"
style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);
border:none;border-radius:50%;width:24px;height:24px;cursor:pointer;
color:#fff;font-size:14px;display:flex;align-items:center;justify-content:center">
×
</button>
</div>`;
}).join('')}
${photos.length < 6 ? `
<label style="aspect-ratio:1;border-radius:var(--radius-md);border:2px dashed var(--c-border);
display:flex;align-items:center;justify-content:center;cursor:pointer;
color:var(--c-text-muted);flex-direction:column;gap:4px">
<svg class="ph-icon" style="width:24px;height:24px"><use href="/icons/phosphor.svg#plus"></use></svg>
<span style="font-size:10px">Foto</span>
<input type="file" id="pp-photo-input" accept="image/*,video/*" class="hidden">
</label>` : ''}
</div>
</div>
<!-- Absenden -->
<div style="display:flex;gap:var(--space-3);justify-content:flex-end;margin-top:var(--space-4)">
<button id="pp-submit-btn" class="btn btn-primary">
Zur Freigabe einreichen
</button>
</div>
`;
}
function _bindEvents(el) {
// Logo hochladen
el.querySelector('#pp-logo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const r = await API.upload('/partner/my-profile/logo', fd);
el.querySelector('#pp-logo-preview').innerHTML =
`<img src="${UI.escape(r.logo_url)}" style="width:100%;height:100%;object-fit:contain">`;
_profile = { ..._profile, logo_url: r.logo_url };
UI.toast.success('Logo gespeichert.');
} catch (err) { UI.toast.error(err.message); }
});
// Texte speichern
el.querySelector('#pp-text-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.put('/partner/my-profile', fd);
_profile = { ..._profile, ...fd };
UI.toast.success('Gespeichert.');
});
});
// Foto/Video hochladen
el.querySelector('#pp-photo-input')?.addEventListener('change', async e => {
const file = e.target.files[0];
if (!file) return;
const isVideo = file.type.startsWith('video/');
const fd = new FormData();
fd.append('file', file);
if (isVideo) UI.toast.info('Video wird hochgeladen und komprimiert das kann 12 Minuten dauern …', 120_000);
try {
const r = await API.upload('/partner/my-profile/photos', fd);
_profile = { ..._profile, photos: r.photos };
await _load();
UI.toast.success(isVideo ? 'Video hinzugefügt.' : 'Foto hinzugefügt.');
} catch (err) { UI.toast.error(err.message); }
});
// Foto löschen
el.querySelectorAll('.pp-photo-del').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx);
try {
const r = await API.post(`/partner/my-profile/photos/${idx}/delete`, {});
_profile = { ..._profile, photos: r.photos };
await _load();
} catch (err) { UI.toast.error(err.message); }
});
});
// Einreichen
el.querySelector('#pp-submit-btn')?.addEventListener('click', async () => {
const btn = el.querySelector('#pp-submit-btn');
await UI.asyncButton(btn, async () => {
await API.post('/partner/my-profile/submit', {});
UI.toast.success('Eingereicht! Wir prüfen dein Profil und schalten es bald frei.');
await _load();
});
});
}
function _storageBar(usedMb, limitMb) {
const pct = Math.min(100, Math.round((usedMb / limitMb) * 100));
const color = pct > 85 ? '#dc2626' : pct > 60 ? '#f59e0b' : '#22c55e';
return `
<div style="display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted)">
<div style="flex:1;height:4px;background:var(--c-surface-2);border-radius:2px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:2px;transition:width .4s"></div>
</div>
<span style="white-space:nowrap;color:${pct > 85 ? '#dc2626' : 'var(--c-text-muted)'}">
${usedMb.toFixed(1)} / ${limitMb} MB
</span>
</div>`;
}
return { init, refresh, onDogChange };
})();

View file

@ -0,0 +1,149 @@
/* ============================================================
BAN YARO Partner-Seite
Showcase der offiziellen Ban Yaro Partner.
============================================================ */
window.Page_partner = (() => {
let _container = null;
async function init(container) {
_container = container;
_render();
_load();
}
function refresh() { _load(); }
function onDogChange() {}
function _render() {
_container.innerHTML = `
<div style="max-width:680px;margin:0 auto;padding:var(--space-4)">
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-2)">🤝</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Unsere Partner
</h1>
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);max-width:480px;margin:0 auto">
Diese Menschen glauben an Ban Yaro und helfen uns, die Community zu wachsen.
Über ihre persönlichen Einladungscodes können sie neue Gründer vermitteln.
</p>
</div>
<div id="partner-content">
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted)">Lade</div>
</div>
</div>
`;
}
async function _load() {
const el = _container.querySelector('#partner-content');
try {
const d = await API.get('/partners/public');
if (!d?.partners) throw new Error('Keine Daten.');
el.innerHTML = _renderPartners(d.partners);
} catch (e) {
el.innerHTML = `<p style="color:var(--c-text-muted);text-align:center">${e.message || 'Fehler beim Laden.'}</p>`;
}
}
function _renderPartners(partners) {
if (!partners.length) {
return `
<div class="by-card" style="padding:var(--space-6);text-align:center">
<p class="text-sm-muted">
Noch keine Partner das könnte schon bald du sein.
</p>
</div>
${_cta()}
`;
}
const COLORS = [
'linear-gradient(135deg,#7c3aed,#a855f7)',
'linear-gradient(135deg,#2563eb,#3b82f6)',
'linear-gradient(135deg,#059669,#10b981)',
'linear-gradient(135deg,#d97706,#f59e0b)',
'linear-gradient(135deg,#db2777,#ec4899)',
];
return `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);margin-bottom:var(--space-5)">
${partners.map((p, i) => {
const initial = (p.name || '?')[0].toUpperCase();
const grad = COLORS[i % COLORS.length];
return `
<div class="by-card" style="padding:var(--space-4);position:relative;overflow:hidden">
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:${grad}"></div>
<div style="display:flex;align-items:center;gap:var(--space-3)">
${p.logo_url
? `<img src="${UI.escape(p.logo_url)}" alt=""
style="width:56px;height:56px;border-radius:var(--radius-md);object-fit:contain;flex-shrink:0;background:var(--c-surface-2);padding:4px">`
: p.avatar_url
? `<img src="${UI.escape(p.avatar_url)}" alt=""
style="width:56px;height:56px;border-radius:50%;object-fit:cover;flex-shrink:0">`
: `<div style="width:56px;height:56px;border-radius:50%;flex-shrink:0;
background:${grad};display:flex;align-items:center;
justify-content:center;font-size:24px;font-weight:800;color:#fff">
${initial}
</div>`
}
<div class="flex-1-min">
<div style="font-weight:700;font-size:var(--text-base)">${UI.escape(p.display_name || p.name)}</div>
${p.tagline ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:1px">${UI.escape(p.tagline)}</div>` : ''}
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-1)">
${p.website ? `<a href="${UI.escape(p.website)}" target="_blank" rel="noopener"
style="font-size:var(--text-xs);color:var(--c-primary)">
🌐 ${UI.escape(p.website.replace(/^https?:\/\//, ''))}</a>` : ''}
${p.instagram ? `<span class="text-xs-muted">📸 ${UI.escape(p.instagram)}</span>` : ''}
</div>
</div>
</div>
${p.pp_bio || p.bio ? `<p style="margin:var(--space-3) 0 0;font-size:var(--text-sm);
color:var(--c-text-secondary);line-height:1.5">
${UI.escape(p.pp_bio || p.bio)}
</p>` : ''}
${p.photos?.length ? `
<div style="display:grid;grid-template-columns:repeat(${Math.min(p.photos.length,3)},1fr);
gap:var(--space-1);margin-top:var(--space-3);border-radius:var(--radius-md);overflow:hidden">
${p.photos.slice(0,3).map(url => {
const isVid = url.endsWith('.mp4') || url.endsWith('.webm');
return isVid
? `<video src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover"
muted playsinline loop autoplay></video>`
: `<img src="${UI.escape(url)}" style="width:100%;aspect-ratio:1;object-fit:cover">`;
}).join('')}
</div>` : ''}
</div>
`;
}).join('')}
</div>
${_cta()}
`;
}
function _cta() {
return `
<div class="by-card" style="padding:var(--space-5);text-align:center;
background:linear-gradient(135deg,rgba(124,58,237,.08),rgba(168,85,247,.08));
border:1px solid rgba(124,58,237,.2)">
<div style="font-size:var(--text-base);font-weight:700;margin-bottom:var(--space-2)">
Du möchtest Partner werden?
</div>
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
Schreib uns wir richten deinen persönlichen Einladungscode ein.
</p>
<a href="mailto:partner@banyaro.app?subject=Ban Yaro Partner"
style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#7c3aed,#a855f7);
color:#fff;border-radius:var(--radius-full);font-weight:700;
font-size:var(--text-sm);text-decoration:none">
📧 partner@banyaro.app
</a>
</div>
`;
}
return { init, refresh, onDogChange };
})();

View file

@ -237,7 +237,7 @@ window.Page_personality = (() => {
<!-- Fortschritt -->
<div style="padding:var(--space-4) var(--space-4) 0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
<span class="text-xs-muted">
Frage ${_current + 1} von ${FRAGEN.length}
</span>
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary)">${pct}%</span>
@ -344,7 +344,7 @@ window.Page_personality = (() => {
return `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:1rem;width:24px;text-align:center">${tp.emoji}</span>
<div style="flex:1">
<div class="flex-1">
<div style="height:8px;background:var(--c-border);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:${tp.color};border-radius:4px;transition:width .6s"></div>
</div>
@ -414,7 +414,7 @@ window.Page_personality = (() => {
<div style="padding:var(--space-3) var(--space-4);font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Dein Profil</div>
<div style="padding:var(--space-4)">${scoreBars}</div>
<div class="p-4">${scoreBars}</div>
</div>
<!-- Teilen + Nochmal -->

View file

@ -281,8 +281,8 @@ window.Page_places = (() => {
</div>
</div>
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${UI.escape(place.adresse)}</p>` : ''}
${place.telefon ? `<p style="margin-bottom:var(--space-2)"><a href="tel:${UI.escape(place.telefon)}" style="color:var(--c-primary)">${UI.icon('phone')} ${UI.escape(place.telefon)}</a></p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${UI.escape(place.website)}" target="_blank" style="color:var(--c-primary)">${UI.icon('arrow-square-out')} ${UI.escape(place.website)}</a></p>` : ''}
${place.telefon ? `<p class="mb-2"><a href="tel:${UI.escape(place.telefon)}" class="text-primary">${UI.icon('phone')} ${UI.escape(place.telefon)}</a></p>` : ''}
${place.website ? `<p class="mb-2"><a href="${UI.escape(place.website)}" target="_blank" class="text-primary">${UI.icon('arrow-square-out')} ${UI.escape(place.website)}</a></p>` : ''}
${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''}
<div id="place-rating-${place.id}"></div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
@ -291,7 +291,7 @@ window.Page_places = (() => {
`;
const footer = isOwn ? `
<button type="button" class="btn btn-secondary" style="width:100%" id="place-detail-edit">Bearbeiten</button>
<button type="button" class="btn btn-secondary w-full" id="place-detail-edit">Bearbeiten</button>
<button type="button" class="btn btn-ghost" style="width:100%;margin-top:var(--space-2)" id="place-detail-close">Schließen</button>
` : `
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
@ -348,24 +348,24 @@ window.Page_places = (() => {
</div>
<div class="form-group">
<label class="form-label">Adresse <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Adresse <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="text" name="adresse"
value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
</div>
<div class="form-group">
<label class="form-label">Website <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Website <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="url" name="website"
value="${UI.escape(place?.website || '')}" placeholder="https://…">
</div>
<div class="form-group">
<label class="form-label">Telefon <span style="color:var(--c-text-secondary)">(optional)</span></label>
<label class="form-label">Telefon <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="tel" name="telefon"
value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456">
</div>
<div class="form-group" style="display:flex;flex-direction:column;gap:var(--space-2)">
<div class="form-group flex-col-gap-2">
<label class="form-label">Hundefreundlichkeit</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
@ -386,10 +386,10 @@ window.Page_places = (() => {
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="place-form" class="btn btn-primary" style="width:100%">
<button type="submit" form="place-form" class="btn btn-primary w-full">
${isEdit ? 'Speichern' : 'Ort hinzufügen'}
</button>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
${isEdit ? `<button type="button" class="btn btn-danger" id="place-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
</div>

View file

@ -15,21 +15,16 @@ window.Page_playdate = (() => {
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _fmtDate(iso) {
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());
const initials = UI.escape((name || '?').charAt(0).toUpperCase());
if (foto_url) {
return `<img src="${_esc(foto_url)}" alt="${initials}"
return `<img src="${UI.escape(foto_url)}" alt="${initials}"
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
}
@ -86,7 +81,7 @@ window.Page_playdate = (() => {
<div class="playdate-layout">
<!-- Tabs -->
<div class="by-tabs" id="playdate-tabs" style="margin-bottom:var(--space-4)">
<div class="by-tabs" id="playdate-tabs" class="mb-4">
<button class="by-tab active" data-tab="nearby">In der Nähe</button>
<button class="by-tab" data-tab="listings">Meine Inserate</button>
<button class="by-tab" data-tab="requests">
@ -133,7 +128,7 @@ window.Page_playdate = (() => {
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4);flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('map-pin')}
<span style="font-size:var(--text-sm);color:var(--c-text-secondary)" id="nearby-location-label">
<span class="text-sm-secondary" id="nearby-location-label">
${_userPos ? 'Standort bekannt' : 'Kein Standort'}
</span>
</div>
@ -245,34 +240,34 @@ window.Page_playdate = (() => {
function _nearbyCard(d) {
return `
<div class="card" style="padding:var(--space-4)">
<div class="card p-4">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(d.foto_url, d.dog_name, 56)}
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
color:var(--c-text)">${_esc(d.dog_name)}</div>
${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''}
${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''}
color:var(--c-text)">${UI.escape(d.dog_name)}</div>
${d.rasse ? `<div class="text-sm-secondary">${UI.escape(d.rasse)}</div>` : ''}
${d.alter ? `<div class="text-xs-muted">${UI.escape(d.alter)}</div>` : ''}
</div>
</div>
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap">
<span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">
${UI.icon('map-pin')}
${d.ort_name ? _esc(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
${d.ort_name ? UI.escape(d.ort_name) + ' · ' : ''}${d.entfernung_km} km entfernt
</span>
${d.geschlecht ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.geschlecht)}</span>` : ''}
${d.geschlecht ? `<span class="text-xs-muted">${UI.escape(d.geschlecht)}</span>` : ''}
</div>
${d.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">
${_esc(d.beschreibung)}
${UI.escape(d.beschreibung)}
</p>` : ''}
<button class="btn btn-primary btn-sm playdate-anfrage-btn"
data-dog-id="${d.dog_id}"
data-dog-name="${_esc(d.dog_name)}">
data-dog-name="${UI.escape(d.dog_name)}">
${UI.icon('paw-print')} Spielkamerad anfragen
</button>
</div>
@ -389,12 +384,12 @@ window.Page_playdate = (() => {
function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv;
return `
<div class="card" style="padding:var(--space-4)">
<div class="card p-4">
<div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
${_dogAvatar(dog.foto_url, dog.name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div>
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''}
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="text-xs-secondary">${UI.escape(dog.rasse)}</div>` : ''}
</div>
<span style="font-size:var(--text-xs);font-weight:600;
padding:2px 10px;border-radius:999px;
@ -407,12 +402,12 @@ window.Page_playdate = (() => {
${isAktiv ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${UI.icon('map-pin')}
${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''}
${listing.ort_name ? UI.escape(listing.ort_name) + ' · ' : ''}
Radius: ${listing.radius_km} km
</div>
${listing.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5">${_esc(listing.beschreibung)}</p>` : ''}
margin:0 0 var(--space-3);line-height:1.5">${UI.escape(listing.beschreibung)}</p>` : ''}
` : `
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0 0 var(--space-3)">
Noch kein Inserat trage dich ein, damit andere dich finden können.
@ -442,10 +437,10 @@ window.Page_playdate = (() => {
<form id="${formId}">
<div class="form-group">
<label class="form-label">Ort / Standort</label>
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
<input type="text" id="listing-ort" class="form-control"
placeholder="z.B. München"
value="${_esc(existing?.ort_name || '')}">
value="${UI.escape(existing?.ort_name || '')}">
<button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
title="GPS-Standort ermitteln">
${UI.icon('crosshair')}
@ -472,7 +467,7 @@ window.Page_playdate = (() => {
<div class="form-group">
<label class="form-label">Beschreibung (optional)</label>
<textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400"
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${_esc(existing?.beschreibung || '')}</textarea>
placeholder="Erzähl etwas über deinen Hund und was ihr sucht…">${UI.escape(existing?.beschreibung || '')}</textarea>
</div>
</form>
`,
@ -578,7 +573,7 @@ window.Page_playdate = (() => {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Eingehende Anfragen</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="flex-col-gap-3">
${incoming.map(r => _incomingCard(r)).join('')}
</div>
</div>` : ''}
@ -588,7 +583,7 @@ window.Page_playdate = (() => {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Ausgehende Anfragen</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-3)">
<div class="flex-col-gap-3">
${outgoing.map(r => _outgoingCard(r)).join('')}
</div>
</div>` : ''}
@ -631,17 +626,17 @@ window.Page_playdate = (() => {
function _incomingCard(r) {
const isPending = r.status === 'pending';
return `
<div class="card" style="padding:var(--space-4)">
<div class="card p-4">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? _esc(r.alter) + ' · ' : ''}
von ${_esc(r.from_user_name)}
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.from_dog_name)}</div>
<div class="text-xs-secondary">
${r.from_dog_rasse ? UI.escape(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? UI.escape(r.alter) + ' · ' : ''}
von ${UI.escape(r.from_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
<div class="text-xs-muted">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
@ -651,11 +646,11 @@ window.Page_playdate = (() => {
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
line-height:1.5">
"${_esc(r.nachricht)}"
"${UI.escape(r.nachricht)}"
</div>` : ''}
${isPending ? `
<div style="display:flex;gap:var(--space-2)">
<div class="flex-gap-2">
<button class="btn btn-primary btn-sm req-accept-btn"
data-req-id="${r.id}" data-status="accepted">
${UI.icon('check')} Annehmen
@ -676,23 +671,23 @@ window.Page_playdate = (() => {
function _outgoingCard(r) {
return `
<div class="card" style="padding:var(--space-4)">
<div class="card p-4">
<div style="display:flex;gap:var(--space-3);align-items:flex-start;margin-bottom:var(--space-3)">
${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
<div style="flex:1;min-width:0">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''}
von ${_esc(r.to_user_name)}
<div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.to_dog_name)}</div>
<div class="text-xs-secondary">
${r.to_dog_rasse ? UI.escape(r.to_dog_rasse) + ' · ' : ''}
von ${UI.escape(r.to_user_name)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_fmtDate(r.created_at)}</div>
<div class="text-xs-muted">${_fmtDate(r.created_at)}</div>
</div>
${_statusBadge(r.status)}
</div>
${r.nachricht ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
"${_esc(r.nachricht)}"
"${UI.escape(r.nachricht)}"
</p>` : ''}
${r.status === 'accepted' ? `

View file

@ -73,7 +73,7 @@ window.Page_poison = (() => {
<a href="tel:110" class="btn btn-secondary" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('phone')} <strong>110</strong> Polizei
</a>
<button class="btn btn-secondary" id="poison-btn-erstehilfe" style="flex:1">
<button class="btn btn-secondary" id="poison-btn-erstehilfe" class="flex-1">
${UI.icon('first-aid')} Erste Hilfe & Tiergift
</button>
</div>
@ -94,8 +94,7 @@ window.Page_poison = (() => {
document.getElementById('poison-btn-erstehilfe')
?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' }));
await UI.loadLeaflet();
_initMap();
await _initMap();
// Leaflet muss nach CSS-Load die Container-Größe neu berechnen
setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad();
@ -104,17 +103,16 @@ window.Page_poison = (() => {
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
async function _initMap() {
const mapEl = document.getElementById('poison-map');
if (!mapEl || !window.L || _map) return;
_map = L.map('poison-map', { zoomControl: true, attributionControl: false })
.setView([51.1657, 10.4515], 6); // Deutschland-Mitte
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(_map);
if (!mapEl || _map) return;
_map = await UI.map.create('poison-map', {
center: [51.1657, 10.4515], // Deutschland-Mitte
zoom: 6,
zoomControl: true,
attributionControl: false,
});
}
// ----------------------------------------------------------
@ -221,7 +219,7 @@ window.Page_poison = (() => {
${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
<small>📍 ${distStr} entfernt</small><br>
<small>📅 ${_fmtDate(r.created_at)}</small>
${r.bestaetigt ? '<br><small><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</small>' : ''}
${r.bestaetigt ? '<br><small><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</small>' : ''}
`);
marker.on('click', () => _openDetail(r));
@ -276,13 +274,13 @@ window.Page_poison = (() => {
border-left:4px solid ${typ.color}">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<div style="width:40px;height:40px;flex-shrink:0;color:${typ.color};display:flex;align-items:center;justify-content:center">${UI.icon(typ.icon)}</div>
<div style="flex:1;min-width:0">
<div class="flex-1-min">
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span class="badge"
style="background:${typ.color};color:#fff">${typ.label}</span>
${r.bestaetigt
? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>'
? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>'
: ''}
<span style="margin-left:auto;color:var(--c-text-secondary);
font-size:var(--text-sm);white-space:nowrap">
@ -295,7 +293,7 @@ window.Page_poison = (() => {
${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>`
: ''}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
<div class="text-xs-secondary">
Gemeldet ${_fmtDate(r.created_at)} ·
läuft ab ${_fmtDate(r.expires_at)}
</div>
@ -336,7 +334,7 @@ window.Page_poison = (() => {
<span class="badge" style="background:${typ.color};color:#fff">
${UI.icon(typ.icon)} ${typ.label}
</span>
${r.bestaetigt ? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' : ''}
${r.bestaetigt ? '<span class="badge badge-success"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigt</span>' : ''}
</div>
${r.beschreibung
@ -353,7 +351,7 @@ window.Page_poison = (() => {
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
${!r.bestaetigt && _appState.user && !isOwnEntry
? `<button class="btn btn-secondary flex-1" id="detail-confirm"><svg class="ph-icon" aria-hidden="true" style="color:var(--c-success)"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigen</button>`
? `<button class="btn btn-secondary flex-1" id="detail-confirm"><svg class="ph-icon" aria-hidden="true" class="text-success"><use href="/icons/phosphor.svg#check-circle"></use></svg> Bestätigen</button>`
: ''}
<button class="btn btn-secondary flex-1" id="detail-show-map">🗺 Auf Karte</button>
${isOwnEntry || isAdmin
@ -472,7 +470,7 @@ window.Page_poison = (() => {
<div class="form-group">
<label class="form-label">
Beschreibung
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
</label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="z. B. Wurstköder mit Nadeln, liegt beim Eingang Hundeparkplatz, linke Seite…"></textarea>
@ -481,7 +479,7 @@ window.Page_poison = (() => {
<div class="form-group">
<label class="form-label">
Foto
<span style="color:var(--c-text-secondary)">(optional)</span>
<span class="text-secondary">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
@ -593,7 +591,7 @@ window.Page_poison = (() => {
title: 'Danke für deine Meldung!',
body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)">
<div style="margin-bottom:var(--space-4)">
<div class="mb-4">
<svg class="ph-icon" aria-hidden="true" style="width:48px;height:48px;color:var(--c-danger)"><use href="/icons/phosphor.svg#siren"></use></svg>
</div>
<p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0">

Some files were not shown because too many files have changed in this diff Show more