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 \ ffmpeg \
&& rm -rf /var/lib/apt/lists/* && 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) # Python-Dependencies zuerst (Docker Layer Cache)
COPY backend/requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r 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 \ RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \
/data/media/breeds/gallery /data/media/breeds/submissions /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 EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] 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 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/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; \ 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) # 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 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: def has_pro_access(user: dict) -> bool:
"""True wenn User Pro-Features nutzen darf.""" """True wenn User Pro-Features nutzen darf."""
if not user: 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["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; " "script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
"img-src 'self' data: blob: https:; " "img-src 'self' data: blob: https:; "
"connect-src 'self' https:; " "connect-src 'self' https:; "
"frame-ancestors 'none'; " "frame-ancestors 'none'; "
@ -1763,19 +1763,40 @@ async def force_update():
<title>Ban Yaro Update</title> <title>Ban Yaro Update</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center; <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} 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> <body>
<div> Einen Moment</div> <div> Einen Moment</div>
<p id="s">Wir besorgen neue Leckerlis 🦴</p> <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> <script>
// Zweiten Reload durch SW-updatefound verhindern
sessionStorage.setItem('by_skip_sw_reload','1'); sessionStorage.setItem('by_skip_sw_reload','1');
// Fire-and-forget kein await, Reload nach spätestens 1.5s // Cleanup IM HINTERGRUND starten (fire-and-forget) kein await,
// kein Blockieren. Selbst wenn die Promises nie resolven (iOS-Bug),
// hängen wir nicht.
try { try {
navigator.serviceWorker?.getRegistrations().then(r=>r.forEach(s=>s.unregister())).catch(()=>{}); if (navigator.serviceWorker) {
caches.keys().then(k=>k.forEach(c=>caches.delete(c))).catch(()=>{}); 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) {} } catch(e) {}
setTimeout(()=>location.replace('/'),1500);
// 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>""" </script></body></html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"}) 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 import APIRouter, Depends, HTTPException
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db, DB_PATH from database import db, DB_PATH
from auth import get_current_user from auth import get_current_user
@ -92,15 +92,15 @@ _VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "bree
class QuarterlyReportBody(BaseModel): class QuarterlyReportBody(BaseModel):
year: int year: int
quarter: int quarter: int
email: str email: str = Field(..., max_length=254)
class UserPatch(BaseModel): 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_moderator: Optional[int] = None
is_banned: 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 is_social_media: Optional[int] = None
subscription_tier: Optional[str] = None subscription_tier: Optional[str] = Field(None, max_length=50)
class WikiEnrichBody(BaseModel): class WikiEnrichBody(BaseModel):
limit: int = 10 limit: int = 10

View file

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

View file

@ -1,10 +1,10 @@
"""BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)""" """BAN YARO — Nearby Alerts (Giftköder + Vermisste Hunde)"""
import math
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from database import db from database import db
from auth import get_current_user_optional as get_optional_user from auth import get_current_user_optional as get_optional_user
from math_utils import haversine_m, bbox_deg_from_km
router = APIRouter() router = APIRouter()
@ -12,21 +12,9 @@ _RADIUS_M = 20_000 # 20 km
_RADIUS_KM = _RADIUS_M / 1000.0 _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]: def _bbox(lat: float, lon: float, radius_km: float) -> tuple[float, float, float, float]:
"""Bounding-Box-Approximation für lat/lon innerhalb radius_km.""" """Bounding-Box-Approximation für lat/lon innerhalb radius_km."""
lat_delta = radius_km / 111.0 lat_delta, lon_delta = bbox_deg_from_km(lat, radius_km)
# 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)
return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta) 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"]) (lat, lon, user["id"])
) )
has_poison = any(_haversine(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in poisons) has_poison = any(haversine_m(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_lost = any(haversine_m(lat, lon, r["lat"], r["lon"]) <= _RADIUS_M for r in lost)
return {"poison": has_poison, "lost": has_lost} return {"poison": has_poison, "lost": has_lost}

View file

@ -10,7 +10,7 @@ from typing import Optional
import jwt as _pyjwt import jwt as _pyjwt
from fastapi import APIRouter, HTTPException, Request, Response, Depends from fastapi import APIRouter, HTTPException, Request, Response, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field
from database import db from database import db
from auth import ( from auth import (
hash_password, verify_password, create_token, hash_password, verify_password, create_token,
@ -146,13 +146,13 @@ def _send_verification_email(email: str, name: str, token: str):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str = Field(..., min_length=1, max_length=200)
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str = Field(..., min_length=8, max_length=200)
name: str name: str = Field(..., min_length=2, max_length=40)
ref_code: Optional[str] = None ref_code: Optional[str] = Field(None, max_length=50)
def _gen_referral_code() -> str: def _gen_referral_code() -> str:
@ -426,8 +426,8 @@ class ForgotPasswordRequest(BaseModel):
email: EmailStr email: EmailStr
class ResetPasswordRequest(BaseModel): class ResetPasswordRequest(BaseModel):
token: str token: str = Field(..., min_length=10, max_length=200)
password: str password: str = Field(..., min_length=8, max_length=200)
@router.post("/forgot-password") @router.post("/forgot-password")
async def forgot_password(data: ForgotPasswordRequest, request: Request): async def forgot_password(data: ForgotPasswordRequest, request: Request):
@ -471,8 +471,8 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
class UpgradeRequestBody(BaseModel): class UpgradeRequestBody(BaseModel):
tier: str tier: str = Field(..., max_length=50)
message: Optional[str] = None message: Optional[str] = Field(None, max_length=2000)
@router.post("/upgrade-request") @router.post("/upgrade-request")
async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)): 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 import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db 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): 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 # PUT /api/breeder/profile — eigenes Profil bearbeiten
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class BreederProfileUpdate(BaseModel): class BreederProfileUpdate(BaseModel):
zwingername: Optional[str] = None zwingername: Optional[str] = Field(None, max_length=200)
rasse_text: Optional[str] = None rasse_text: Optional[str] = Field(None, max_length=200)
verein: Optional[str] = None verein: Optional[str] = Field(None, max_length=200)
vdh_mitglied: Optional[int] = None vdh_mitglied: Optional[int] = None
stadt: Optional[str] = None stadt: Optional[str] = Field(None, max_length=200)
website: Optional[str] = None website: Optional[str] = Field(None, max_length=500)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
@router.put("/breeder/profile") @router.put("/breeder/profile")
async def update_breeder_profile(body: BreederProfileUpdate, user=Depends(require_breeder)): 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)""" """BAN YARO — Züchter-Fotos (Upload, Verwaltung, öffentliche Ansicht)"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
import os, logging, asyncio import os, logging, asyncio
from database import db from database import db
@ -30,10 +30,10 @@ def _require_breeder(user=Depends(get_current_user)):
# Modelle # Modelle
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class VisibilityBody(BaseModel): class VisibilityBody(BaseModel):
visibility: str visibility: str = Field(..., max_length=30)
class CaptionBody(BaseModel): 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 import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user 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): class SendMsgModel(BaseModel):
text: str text: str = Field(..., min_length=1, max_length=2000)
@router.post("/conversations/{conv_id}/messages", status_code=201) @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() text = data.text.strip()
if not text: if not text:
raise HTTPException(400, "Nachricht darf nicht leer sein.") 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: with db() as conn:
conv = conn.execute( conv = conn.execute(

View file

@ -1,8 +1,8 @@
"""BAN YARO — Tagebuch Routes""" """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 fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, require_admin from auth import get_current_user, require_admin
@ -11,6 +11,7 @@ import httpx
import weather as weather_mod 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 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 timeutils import safe_client_time
from math_utils import haversine_km
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,27 +20,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel): class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute datum: Optional[str] = Field(None, max_length=32) # ISO date, default heute
client_time: Optional[str] = None # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS) client_time: Optional[str] = Field(None, max_length=64) # lokale Uhrzeit des Geräts (YYYY-MM-DDTHH:MM:SS)
typ: str = "eintrag" typ: str = Field("eintrag", max_length=50)
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = None text: Optional[str] = Field(None, max_length=10000)
tags: Optional[list] = None tags: Optional[list] = None
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: 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 is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary 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): class DiaryUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
text: Optional[str] = None text: Optional[str] = Field(None, max_length=10000)
tags: Optional[list] = None tags: Optional[list] = None
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: 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 is_milestone: Optional[bool] = None
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen 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") elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon") elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon: 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 typ = next((el["tags"].get(k) for k in
["tourism", "historic", "leisure", "amenity", "shop"] ["tourism", "historic", "leisure", "amenity", "shop"]
if el["tags"].get(k)), "place") 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] 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") @router.get("/{dog_id}/diary/nearby")
async def nearby_places(dog_id: int, lat: float, lon: float, async def nearby_places(dog_id: int, lat: float, lon: float,
user=Depends(get_current_user)): user=Depends(get_current_user)):
@ -445,7 +436,7 @@ async def nearby_places(dog_id: int, lat: float, lon: float,
(user["id"],) (user["id"],)
).fetchall() ).fetchall()
for p in places: 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: if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place", results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"], "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 != ''" "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall() ).fetchall()
for p in osm: 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: if km <= 2:
results.append({"name": p["name"], "type": p["type"], results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"], "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") elat = el.get("lat") or el.get("center", {}).get("lat")
elon = el.get("lon") or el.get("center", {}).get("lon") elon = el.get("lon") or el.get("center", {}).get("lon")
if elat and elon: 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 typ = next((el["tags"].get(k) for k in
["tourism","historic","leisure","amenity","shop"] ["tourism","historic","leisure","amenity","shop"]
if el["tags"].get(k)), "place") if el["tags"].get(k)), "place")

View file

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

View file

@ -2,7 +2,7 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -16,18 +16,18 @@ logger = logging.getLogger(__name__)
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class FutterProfilUpdate(BaseModel): class FutterProfilUpdate(BaseModel):
futter_typ: Optional[str] = None # trocken|nass|barf|mix futter_typ: Optional[str] = Field(None, max_length=50) # trocken|nass|barf|mix
marke: Optional[str] = None marke: Optional[str] = Field(None, max_length=200)
kcal_tag: Optional[int] = None kcal_tag: Optional[int] = None
portionen: Optional[int] = None portionen: Optional[int] = None
notizen: Optional[str] = None notizen: Optional[str] = Field(None, max_length=5000)
class KiBeratungRequest(BaseModel): class KiBeratungRequest(BaseModel):
frage: str frage: str = Field(..., min_length=3, max_length=2000)
dog_name: Optional[str] = None dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = None alter: Optional[str] = Field(None, max_length=50)
gewicht: Optional[float] = None gewicht: Optional[float] = None
aktiv: Optional[bool] = None aktiv: Optional[bool] = None
@ -183,20 +183,20 @@ _GASTRO_HINWEIS = "Magen-Darm-Symptome wie {label} treten meist innerhalb wenige
class FutterEintragCreate(BaseModel): class FutterEintragCreate(BaseModel):
datum: str datum: str = Field(..., max_length=32)
uhrzeit: str uhrzeit: str = Field(..., max_length=20)
futter_name: str futter_name: str = Field(..., max_length=200)
futter_typ: Optional[str] = "trockenfutter" futter_typ: Optional[str] = Field("trockenfutter", max_length=50)
menge_g: Optional[int] = None menge_g: Optional[int] = None
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class ReaktionCreate(BaseModel): class ReaktionCreate(BaseModel):
datum: str datum: str = Field(..., max_length=32)
uhrzeit: str uhrzeit: str = Field(..., max_length=20)
reaktion_typ: str reaktion_typ: str = Field(..., max_length=100)
intensitaet: Optional[int] = 3 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)""" """BAN YARO — Events (Hundeveranstaltungen)"""
import math
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from math_utils import haversine_m
router = APIRouter() router = APIRouter()
TYPEN = {'ausstellung', 'training', 'treffen', 'markt', 'wettkampf', 'sonstiges'} 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 # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RsvpCreate(BaseModel): class RsvpCreate(BaseModel):
status: str = 'going' # 'going' | 'maybe' status: str = Field('going', max_length=20) # 'going' | 'maybe'
class EventCreate(BaseModel): class EventCreate(BaseModel):
titel: str titel: str = Field(..., min_length=3, max_length=200)
datum: str # YYYY-MM-DD datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: Optional[str] = None uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
typ: str = 'sonstiges' typ: str = Field('sonstiges', max_length=50)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = None link: Optional[str] = Field(None, max_length=500)
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = None uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
typ: Optional[str] = None typ: Optional[str] = Field(None, max_length=50)
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=10000)
link: Optional[str] = None link: Optional[str] = Field(None, max_length=500)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -86,7 +77,7 @@ async def list_events(
result = [dict(r) for r in rows] result = [dict(r) for r in rows]
if lat is not None and lon is not None: if lat is not None and lon is not None:
result = [r for r in result 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 return result

View file

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

View file

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

View file

@ -1,37 +1,28 @@
"""BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)""" """BAN YARO — Gassi-Zeiten-Pool (regelmäßige Gassi-Zeiten mit Gleichgesinnten)"""
import json import json
import math
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from math_utils import haversine_m
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() 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): class GassiZeitCreate(BaseModel):
dog_id: Optional[int] = None dog_id: Optional[int] = None
wochentage: List[str] # ["mo", "mi", "fr"] wochentage: List[str] # ["mo", "mi", "fr"]
uhrzeit: str # "17:00" uhrzeit: str = Field(..., max_length=20) # "17:00"
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
lat: Optional[float] = None lat: Optional[float] = None
lon: Optional[float] = None lon: Optional[float] = None
radius_m: int = 500 radius_m: int = 500
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class GassiZeitUpdate(BaseModel): class GassiZeitUpdate(BaseModel):
@ -83,7 +74,7 @@ async def list_gassi_zeiten(
# Distanz-Filter # Distanz-Filter
if lat is not None and lon is not None and d.get("lat") and d.get("lon"): 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: if not nur_eigene and dist > radius:
continue continue
d["distance_m"] = int(dist) d["distance_m"] = int(dist)

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
"""BAN YARO — KI Routes""" """BAN YARO — KI Routes"""
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
import ki as ki_module import ki as ki_module
from auth import get_current_user from auth import get_current_user
@ -11,9 +11,9 @@ router = APIRouter()
class TrainingRequest(BaseModel): class TrainingRequest(BaseModel):
problem: str problem: str = Field(..., min_length=10, max_length=1000)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[str] = None alter: Optional[str] = Field(None, max_length=50)
@router.post("/training") @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") rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
if not req.problem or len(req.problem.strip()) < 10: if not req.problem or len(req.problem.strip()) < 10:
raise HTTPException(400, "Bitte beschreibe das Problem genauer.") 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" rasse = req.rasse or "unbekannt"
alter = req.alter or "unbekannt" alter = req.alter or "unbekannt"
@ -69,10 +67,10 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
# POST /ki/tierarzt — KI-Tierarztfragen # POST /ki/tierarzt — KI-Tierarztfragen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class TierarztRequest(BaseModel): class TierarztRequest(BaseModel):
symptom: str symptom: str = Field(..., min_length=5, max_length=1000)
dog_id: Optional[int] = None dog_id: Optional[int] = None
dog_name: Optional[str] = None dog_name: Optional[str] = Field(None, max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
@router.post("/tierarzt") @router.post("/tierarzt")
@ -81,8 +79,6 @@ async def ki_tierarzt(req: TierarztRequest, request: Request,
"""KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung.""" """KI-Tierarztfragen: Symptombeschreibung → erste Einschätzung."""
if not req.symptom or len(req.symptom.strip()) < 5: if not req.symptom or len(req.symptom.strip()) < 5:
raise HTTPException(400, "Bitte beschreibe das Symptom genauer.") 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 # Rate-Limit: max 5 Anfragen pro User pro Tag
with db() as conn: with db() as conn:
@ -173,10 +169,10 @@ def _log_rasse_request(user_id: int):
class BirthdayRequest(BaseModel): class BirthdayRequest(BaseModel):
dog_id: int dog_id: int
name: str name: str = Field(..., max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
alter: Optional[int] = None alter: Optional[int] = None
mode: str = "tomorrow" # "tomorrow" | "today" mode: str = Field("tomorrow", max_length=20) # "tomorrow" | "today"
@router.post("/geburtstag") @router.post("/geburtstag")
async def ki_geburtstag(req: BirthdayRequest, request: Request, 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): class AbschiedRequest(BaseModel):
dog_id: int dog_id: int
name: str name: str = Field(..., max_length=80)
rasse: Optional[str] = None rasse: Optional[str] = Field(None, max_length=80)
km_total: Optional[float] = None km_total: Optional[float] = None
diary_count: Optional[int] = None diary_count: Optional[int] = None
gemeinsam_tage: 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") @router.post("/abschied")
async def ki_abschied(req: AbschiedRequest, request: Request, async def ki_abschied(req: AbschiedRequest, request: Request,

View file

@ -1,7 +1,7 @@
"""BAN YARO — Hunde-Knigge Routes""" """BAN YARO — Hunde-Knigge Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
@ -13,12 +13,12 @@ router = APIRouter()
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class VoteRequest(BaseModel): class VoteRequest(BaseModel):
szenario_id: str szenario_id: str = Field(..., max_length=100)
answer: str answer: str = Field(..., max_length=100)
class KiRatRequest(BaseModel): 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)""" """BAN YARO — Läufigkeit, Progesterontests & Trächtigkeit (Züchter)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from datetime import date, timedelta from datetime import date, timedelta
@ -78,47 +78,47 @@ def _calc_meilensteine(deckdatum_str: str) -> list:
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class LaeufiCreate(BaseModel): class LaeufiCreate(BaseModel):
beginn: str beginn: str = Field(..., max_length=32)
ende: Optional[str] = None ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class LaeufiUpdate(BaseModel): class LaeufiUpdate(BaseModel):
beginn: Optional[str] = None beginn: Optional[str] = Field(None, max_length=32)
ende: Optional[str] = None ende: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class ProgestCreate(BaseModel): class ProgestCreate(BaseModel):
datum: str datum: str = Field(..., max_length=32)
wert: Optional[float] = None wert: Optional[float] = None
einheit: str = "ng/ml" einheit: str = Field("ng/ml", max_length=20)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class ProgestUpdate(BaseModel): class ProgestUpdate(BaseModel):
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
wert: Optional[float] = None wert: Optional[float] = None
einheit: Optional[str] = None einheit: Optional[str] = Field(None, max_length=20)
labor: Optional[str] = None labor: Optional[str] = Field(None, max_length=200)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class DeckCreate(BaseModel): class DeckCreate(BaseModel):
deckdatum: str deckdatum: str = Field(..., max_length=32)
laeufi_id: Optional[int] = None laeufi_id: Optional[int] = None
ruede_id: Optional[int] = None ruede_id: Optional[int] = None
ruede_name: Optional[str] = None ruede_name: Optional[str] = Field(None, max_length=200)
deckart: str = "natuerlich" deckart: str = Field("natuerlich", max_length=50)
traechtig: int = 0 traechtig: int = 0
ultraschall_datum: Optional[str] = None ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
class DeckUpdate(BaseModel): class DeckUpdate(BaseModel):
deckdatum: Optional[str] = None deckdatum: Optional[str] = Field(None, max_length=32)
ruede_id: Optional[int] = None ruede_id: Optional[int] = None
ruede_name: Optional[str] = None ruede_name: Optional[str] = Field(None, max_length=200)
deckart: Optional[str] = None deckart: Optional[str] = Field(None, max_length=50)
traechtig: Optional[int] = None traechtig: Optional[int] = None
ultraschall_datum: Optional[str] = None ultraschall_datum: Optional[str] = Field(None, max_length=32)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ import httpx
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user, get_current_user_optional as get_optional_user 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: except Exception as exc:
logger.warning(f"Overpass Verbindungsfehler {url}: {exc}") logger.warning(f"Overpass Verbindungsfehler {url}: {exc}")
break # nächste URL 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): def _stale_tiles(poi_type, tiles):
stale = [] stale = []
@ -273,11 +273,11 @@ async def get_pois(
# POST /user-poi — Community-Marker setzen # POST /user-poi — Community-Marker setzen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class UserPoiIn(BaseModel): class UserPoiIn(BaseModel):
type: str type: str = Field(..., max_length=200)
lat: float lat: float
lon: float lon: float
name: Optional[str] = None name: Optional[str] = Field(None, max_length=300)
notiz: Optional[str] = None notiz: Optional[str] = Field(None, max_length=2000)
ALLOWED_TYPES = { ALLOWED_TYPES = {
'waste_basket', 'drinking_water', 'dog_park', '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 # POST /report — Marker als ungültig melden
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class ReportIn(BaseModel): class ReportIn(BaseModel):
type: str type: str = Field(..., max_length=100)
grund: str grund: str = Field(..., max_length=200)
osm_id: Optional[int] = None osm_id: Optional[int] = None
user_poi_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 # POST /pois/{osm_id}/edit — Nutzer schlägt Korrektur vor
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PoiEditCreate(BaseModel): class PoiEditCreate(BaseModel):
poi_name: str poi_name: str = Field(..., max_length=300)
field: str = 'opening_hours' field: str = Field('opening_hours', max_length=50)
new_value: str new_value: str = Field(..., max_length=1000)
@router.post('/pois/{osm_id}/edit', status_code=201) @router.post('/pois/{osm_id}/edit', status_code=201)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,17 @@
"""BAN YARO — Playdate-Matching""" """BAN YARO — Playdate-Matching"""
import math
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from math_utils import haversine_km
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) 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]: def _calc_alter(geburtstag: Optional[str]) -> Optional[str]:
"""Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'.""" """Gibt lesbares Alter zurück z.B. '2 Jahre' oder '5 Monate'."""
if not geburtstag: if not geburtstag:
@ -53,18 +40,18 @@ class ListingUpsert(BaseModel):
dog_id: int dog_id: int
lat: float lat: float
lon: float lon: float
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
radius_km: int = 10 radius_km: int = 10
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=2000)
class RequestCreate(BaseModel): class RequestCreate(BaseModel):
to_dog_id: int to_dog_id: int
nachricht: Optional[str] = None nachricht: Optional[str] = Field(None, max_length=2000)
class RequestPatch(BaseModel): 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 = [] result = []
for r in rows: for r in rows:
dist = _haversine(lat, lon, r["lat"], r["lon"]) dist = haversine_km(lat, lon, r["lat"], r["lon"])
if dist <= radius: if dist <= radius:
result.append({ result.append({
"listing_id": r["listing_id"], "listing_id": r["listing_id"],

View file

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

View file

@ -7,7 +7,7 @@ import uuid
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from auth import get_current_user from auth import get_current_user
from database import db from database import db
@ -20,17 +20,17 @@ VALID_SICHTBARKEIT = {"public", "friends", "private"}
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
real_name: Optional[str] = None real_name: Optional[str] = Field(None, max_length=100)
bio: Optional[str] = None bio: Optional[str] = Field(None, max_length=300)
wohnort: Optional[str] = None wohnort: Optional[str] = Field(None, max_length=60)
erfahrung: Optional[str] = None erfahrung: Optional[str] = Field(None, max_length=30)
social_link: Optional[str] = None social_link: Optional[str] = Field(None, max_length=120)
profil_sichtbarkeit: Optional[str] = None profil_sichtbarkeit: Optional[str] = Field(None, max_length=30)
notes_ki_enabled: Optional[int] = None notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = None preferred_theme: Optional[str] = Field(None, max_length=20)
billing_address: Optional[str] = None billing_address: Optional[str] = Field(None, max_length=500)
geburtstag: Optional[str] = None geburtstag: Optional[str] = Field(None, max_length=10)
def _load_user(user_id: int) -> dict: 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.") 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"): 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.") raise HTTPException(400, "preferred_theme muss 'system', 'light' oder 'dark' sein.")
if "bio" in fields and len(fields["bio"]) > 300: # Längen-Begrenzungen sind jetzt via Field max_length im Schema abgedeckt.
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.")
if "geburtstag" in fields and fields["geburtstag"]: if "geburtstag" in fields and fields["geburtstag"]:
if not re.fullmatch(r"\d{2}\.\d{2}", 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).") 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 json
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from pywebpush import webpush, WebPushException from pywebpush import webpush, WebPushException
@ -33,7 +33,7 @@ async def get_vapid_key():
# POST /api/push/subscribe — Subscription speichern # POST /api/push/subscribe — Subscription speichern
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class PushSubscription(BaseModel): class PushSubscription(BaseModel):
endpoint: str endpoint: str = Field(..., max_length=2000)
keys: dict # { p256dh, auth } keys: dict # { p256dh, auth }
expirationTime: Optional[int] = None expirationTime: Optional[int] = None

View file

@ -1,7 +1,7 @@
"""BAN YARO — Bewertungssystem (Ratings)""" """BAN YARO — Bewertungssystem (Ratings)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -23,10 +23,10 @@ TABLE_MAP = {
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class RatingCreate(BaseModel): class RatingCreate(BaseModel):
target_type: str target_type: str = Field(..., max_length=50)
target_id: int target_id: int
stars: 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 # Interne Hilfsfunktion: RASFF API abfragen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def fetch_rasff_recalls() -> list[dict]: 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: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(RASFF_URL, params=RASFF_PARAMS) resp = await client.get(RASFF_URL, params=RASFF_PARAMS)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() 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: except Exception as e:
logger.error(f"RASFF API-Fehler: {e}") logger.error(f"RASFF API-Fehler: {e}")
return [] return []

View file

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

View file

@ -2,7 +2,7 @@
import secrets import secrets
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -14,7 +14,7 @@ share_router = APIRouter()
class ShareInvite(BaseModel): 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""" """BAN YARO — Hundesitting"""
import json import json
import math
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from math_utils import haversine_m
router = APIRouter() router = APIRouter()
SERVICES = {'tagesbetreuung', 'uebernachtung', 'gassi', 'hausbesuch'} 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 # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class SitterCreate(BaseModel): class SitterCreate(BaseModel):
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: float = 0 preis_pro_tag: float = 0
max_hunde: int = 1 max_hunde: int = 1
lat: Optional[float] = None lat: Optional[float] = None
@ -35,7 +26,7 @@ class SitterCreate(BaseModel):
services: List[str] = [] services: List[str] = []
class SitterUpdate(BaseModel): class SitterUpdate(BaseModel):
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
preis_pro_tag: Optional[float] = None preis_pro_tag: Optional[float] = None
max_hunde: Optional[int] = None max_hunde: Optional[int] = None
lat: Optional[float] = None lat: Optional[float] = None
@ -47,12 +38,12 @@ class SitterUpdate(BaseModel):
class RequestCreate(BaseModel): class RequestCreate(BaseModel):
sitter_id: int sitter_id: int
dog_ids: List[int] = [] dog_ids: List[int] = []
von: str # YYYY-MM-DD von: str = Field(..., max_length=32) # YYYY-MM-DD
bis: str bis: str = Field(..., max_length=32)
nachricht: Optional[str] = None nachricht: Optional[str] = Field(None, max_length=2000)
class RequestUpdate(BaseModel): 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']: if service and service not in d['services']:
continue continue
if lat is not None and lon is not None and d['lat'] and d['lon']: 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: if dist > radius:
continue continue
d['distanz_m'] = round(dist) d['distanz_m'] = round(dist)

View file

@ -1,7 +1,7 @@
"""BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)""" """BAN YARO — Gasthund-Zugang (Sitter-Subscriptions)"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from database import db from database import db
from auth import get_current_user from auth import get_current_user
@ -11,7 +11,7 @@ router = APIRouter()
class AccessCreate(BaseModel): class AccessCreate(BaseModel):
dog_id: int dog_id: int
sitter_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) @router.post("", status_code=201)

View file

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

View file

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

View file

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

View file

@ -1,55 +1,43 @@
"""BAN YARO — Gassi-Treffen""" """BAN YARO — Gassi-Treffen"""
import math, os, uuid import os, uuid
import httpx import httpx
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from database import db from database import db
from auth import get_current_user from auth import get_current_user
from routes.push import send_push_to_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") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
router = APIRouter() 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 # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class WalkCreate(BaseModel): class WalkCreate(BaseModel):
titel: str titel: str = Field(..., min_length=1, max_length=200)
datum: str # YYYY-MM-DD datum: str = Field(..., max_length=32) # YYYY-MM-DD
uhrzeit: str # HH:MM uhrzeit: str = Field(..., max_length=20) # HH:MM
lat: float lat: float
lon: float lon: float
ort_name: Optional[str] = None ort_name: Optional[str] = Field(None, max_length=300)
max_teilnehmer: int = 10 max_teilnehmer: int = 10
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
class WalkUpdate(BaseModel): class WalkUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = Field(None, max_length=200)
datum: Optional[str] = None datum: Optional[str] = Field(None, max_length=32)
uhrzeit: Optional[str] = None uhrzeit: Optional[str] = Field(None, max_length=20)
lat: Optional[float] = None lat: Optional[float] = None
lon: 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 max_teilnehmer: Optional[int] = None
beschreibung: Optional[str] = None beschreibung: Optional[str] = Field(None, max_length=5000)
class JoinRequest(BaseModel): class JoinRequest(BaseModel):
dog_ids: List[int] = [] # leere Liste = ohne Hund (selten) dog_ids: List[int] = [] # leere Liste = ohne Hund (selten)
@ -58,7 +46,7 @@ class InviteRequest(BaseModel):
friend_id: int friend_id: int
class RsvpRequest(BaseModel): 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 # Umkreis-Filter
if lat is not None and lon is not None: 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 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", "SELECT name, typ, lat, lon FROM places WHERE lat IS NOT NULL",
).fetchall() ).fetchall()
for p in places: 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: if km <= 5:
results.append({"name": p["name"], "type": p["typ"] or "place", results.append({"name": p["name"], "type": p["typ"] or "place",
"lat": p["lat"], "lon": p["lon"], "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 != ''" "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
).fetchall() ).fetchall()
for p in osm: 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: if km <= 2:
results.append({"name": p["name"], "type": p["type"], results.append({"name": p["name"], "type": p["type"],
"lat": p["lat"], "lon": p["lon"], "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") elon = el.get("lon") or el.get("center", {}).get("lon")
if elat is None or elon is None: if elat is None or elon is None:
continue continue
km = _haversine_km(lat, lon, elat, elon) km = haversine_km(lat, lon, elat, elon)
if km <= 1: if km <= 1:
results.append({"name": name, "type": "osm", results.append({"name": name, "type": "osm",
"lat": elat, "lon": elon, "lat": elat, "lon": elon,

View file

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

View file

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

View file

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

View file

@ -46,6 +46,14 @@ def start():
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True, 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( _scheduler.add_job(
_job_weather_alert, _job_weather_alert,
CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr 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}") logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}")
with db() as conn: with db() as conn:
# diary hat keinen user_id — User kommt über dogs.user_id
entries = conn.execute(""" 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 (SELECT dm.url FROM diary_media dm
WHERE dm.diary_id=d.id LIMIT 1) AS foto_url WHERE dm.diary_id=d.id LIMIT 1) AS foto_url
FROM diary d FROM diary d
JOIN dogs ON dogs.id = d.dog_id
WHERE strftime('%m-%d', d.datum) = ? WHERE strftime('%m-%d', d.datum) = ?
AND d.datum < date('now') AND d.datum < date('now')
AND d.titel IS NOT NULL AND d.titel IS NOT NULL
@ -2231,3 +2241,16 @@ async def _job_error_digest():
except Exception as e: except Exception as e:
logger.error(f"Error-Digest: Mail-Fehler: {e}") logger.error(f"Error-Digest: Mail-Fehler: {e}")
_log_job("error_digest", "error", str(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); 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 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-text { flex: 1; min-width: 0; }
.offline-status-row .osr-title { font-weight: 600; } .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; } .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 */ /* Text — Warmbraun aus dem Halsband */
--c-text: #2A1F14; --c-text: #2A1F14;
--c-text-secondary: #7A6A58; --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; --c-text-inverse: #FAF7F2;
/* Funktionsfarben */ /* Funktionsfarben */
@ -179,7 +179,7 @@
--c-text: #F0EAE0; --c-text: #F0EAE0;
--c-text-secondary: #C0B0A0; --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; --c-text-inverse: #2A1F14;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);

View file

@ -86,8 +86,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 44px;
height: 40px; height: 44px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--c-text-secondary); color: var(--c-text-secondary);
cursor: pointer; cursor: pointer;
@ -99,8 +99,8 @@
/* Hamburger-Button (nur Mobile) */ /* Hamburger-Button (nur Mobile) */
.header-menu-btn { .header-menu-btn {
width: 40px; width: 44px;
height: 40px; height: 44px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script> <script src="/js/boot-early.js?v=1120"></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>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1099"> <link rel="stylesheet" href="/css/design-system.css?v=1120">
<link rel="stylesheet" href="/css/layout.css?v=1099"> <link rel="stylesheet" href="/css/layout.css?v=1120">
<link rel="stylesheet" href="/css/components.css?v=1099"> <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> </head>
<body> <body>
@ -111,7 +101,8 @@
<div id="offline-banner" aria-live="polite" <div id="offline-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999; 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; 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)"> 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"> <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"/> <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" <div id="verify-banner" aria-live="polite"
style="display:none;position:fixed;top:0;left:0;right:0;z-index:9998; 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; 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)"> 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"> <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"/> <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>
<div id="header-actions"></div> <div id="header-actions"></div>
<button id="header-user-btn" aria-label="Profil" <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; background:var(--c-surface-2);cursor:pointer;flex-shrink:0;
display:flex;align-items:center;justify-content:center;overflow:hidden; display:flex;align-items:center;justify-content:center;overflow:hidden;
padding:0;position:relative"> padding:0;position:relative">
@ -625,11 +617,11 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1099"></script> <script src="/js/api.js?v=1120"></script>
<script src="/js/ui.js?v=1099"></script> <script src="/js/ui.js?v=1120"></script>
<script src="/js/app.js?v=1099"></script> <script src="/js/app.js?v=1120"></script>
<script src="/js/worlds.js?v=1099"></script> <script src="/js/worlds.js?v=1120"></script>
<script src="/js/offline-indicator.js?v=1099"></script> <script src="/js/offline-indicator.js?v=1120"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- 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> <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 --> <!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script> <script src="/js/boot.js?v=1120"></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>
</body> </body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;
@ -129,16 +129,23 @@ const App = (() => {
function navigate(pageId, pushHistory = true, params = {}) { function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return; if (!pages[pageId]) return;
// Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist // 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) { if (window._byUpdatePending) {
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null; 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; window._byUpdatePending = false;
sessionStorage.setItem('by_updated_to', window._byNewVersion || ''); 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'; location.href = '/force-update';
return; return;
} }
// Modal offen → beim nächsten Seitenwechsel versuchen // Modal offen oder Cooldown → bei nächstem Seitenwechsel versuchen
} }
if (window.Worlds?._visible) window.Worlds.hide(); 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 }); 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 // Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte
function _prefetchPages() { function _prefetchPages() {
['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost'] ['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(); 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 }; 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" <input id="adp-rasse" class="form-control" type="text"
placeholder="Rasse filtern…" placeholder="Rasse filtern…"
style="flex:1;min-width:120px;max-width:220px" 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" <button class="btn btn-secondary" id="adp-btn-locate"
style="white-space:nowrap"> style="white-space:nowrap">
${UI.icon('map-pin')} Mein Standort ${UI.icon('map-pin')} Mein Standort
@ -270,7 +270,7 @@ window.Page_adoption = (() => {
content.innerHTML = ` content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)"> <div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div> <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"> <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 Erlaube den Zugriff auf deinen Standort oder gib eine PLZ ein, um Tierheim-Hunde
in deiner Umgebung zu finden. in deiner Umgebung zu finden.
@ -306,7 +306,7 @@ window.Page_adoption = (() => {
if (!animals.length) { if (!animals.length) {
content.innerHTML = ` content.innerHTML = `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)"> <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> </p>
<div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px"> <div style="display:flex;flex-direction:column;gap:var(--space-3);max-width:380px">
<a href="https://www.tierheimhelden.de/hunde/liste" <a href="https://www.tierheimhelden.de/hunde/liste"
@ -339,7 +339,7 @@ window.Page_adoption = (() => {
</p> </p>
<a href="https://www.tierheimhelden.de/hunde/liste" <a href="https://www.tierheimhelden.de/hunde/liste"
target="_blank" rel="noopener noreferrer" 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 ${UI.icon('arrow-square-out')} Tierheimhelden.de alle Hunde
</a> </a>
</div> </div>
@ -355,7 +355,7 @@ window.Page_adoption = (() => {
function _animalCard(a) { function _animalCard(a) {
const foto = a.foto_url 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" 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>'">` 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>'; : '<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 || ''; const tierheim = a.tierheim || '';
return ` 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; style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2);cursor:pointer; background:var(--c-surface-2);cursor:pointer;
box-shadow:0 1px 4px rgba(0,0,0,0.08); 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="padding:var(--space-2) var(--space-2) var(--space-3)">
<div style="font-weight:600;font-size:var(--text-sm); <div style="font-weight:600;font-size:var(--text-sm);
margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(a.name)} ${UI.escape(a.name)}
</div> </div>
${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); ${rasseTxt ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(rasseTxt)} ${UI.escape(rasseTxt)}
</div>` : ''} </div>` : ''}
<div style="display:flex;gap:var(--space-1);flex-wrap:wrap;margin-top:var(--space-1)"> <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); ${alterTxt ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${_esc(alterTxt)} ${UI.escape(alterTxt)}
</span>` : ''} </span>` : ''}
${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3); ${a.geschlecht ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -396,12 +396,12 @@ window.Page_adoption = (() => {
</span>` : ''} </span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe); ${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)"> border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${_esc(distTxt)} ${UI.escape(distTxt)}
</span>` : ''} </span>` : ''}
</div> </div>
${tierheim ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1); ${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)}"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${UI.escape(tierheim)}">
${UI.icon('house-line')} ${_esc(tierheim)} ${UI.icon('house-line')} ${UI.escape(tierheim)}
</div>` : ''} </div>` : ''}
</div> </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)"> <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 ${shelters.length} Tierheim${shelters.length !== 1 ? 'e' : ''} im Umkreis von ${_radius} km
</p> </p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div class="flex-col-gap-2">
${shelters.map(s => _shelterRow(s)).join('')} ${shelters.map(s => _shelterRow(s)).join('')}
</div> </div>
<div style="margin-top:var(--space-5);padding-top:var(--space-4);border-top:1px solid var(--c-border)"> <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"> <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<a href="https://www.tierheimhelden.de" <a href="https://www.tierheimhelden.de"
target="_blank" rel="noopener noreferrer" 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 ${UI.icon('arrow-square-out')} Tierheimhelden.de
</a> </a>
<a href="https://www.tierschutz.com/tierheimsuche/" <a href="https://www.tierschutz.com/tierheimsuche/"
target="_blank" rel="noopener noreferrer" 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 ${UI.icon('magnifying-glass')} tierschutz.com
</a> </a>
</div> </div>
@ -459,7 +459,7 @@ window.Page_adoption = (() => {
function _shelterRow(s) { function _shelterRow(s) {
return ` 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); style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md); padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);text-decoration:none;color:inherit; background:var(--c-surface-2);text-decoration:none;color:inherit;
@ -473,13 +473,13 @@ window.Page_adoption = (() => {
font-size:1.2rem"> font-size:1.2rem">
🏠 🏠
</div> </div>
<div style="flex:1;min-width:0"> <div class="flex-1-min">
<div style="font-weight:600;font-size:var(--text-sm); <div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(s.name)} ${UI.escape(s.name)}
</div> </div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)"> <div class="text-xs-secondary">
${_esc(s.plz)} ${_esc(s.stadt)} ${UI.escape(s.plz)} ${UI.escape(s.stadt)}
</div> </div>
</div> </div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:2px;flex-shrink:0"> <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 = ` content.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4)"> <div style="text-align:center;padding:var(--space-8) var(--space-4)">
<div style="font-size:2.5rem;margin-bottom:var(--space-3)">🐾</div> <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"> <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 Hier können Halter Hunde privat zur Weitervermittlung anbieten
zum Beispiel bei Umzug, Krankheit oder Allergie. zum Beispiel bei Umzug, Krankheit oder Allergie.
@ -530,7 +530,7 @@ window.Page_adoption = (() => {
${UI.icon('plus')} Hund zur Vermittlung anbieten ${UI.icon('plus')} Hund zur Vermittlung anbieten
</button> </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. Bitte anmelden, um ein Inserat zu erstellen.
</p> </p>
`} `}
@ -556,8 +556,8 @@ window.Page_adoption = (() => {
${isLoggedIn && _myListings && _myListings.length ? ` ${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)"> <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> <h4 class="mb-3">Meine Inserate</h4>
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div class="flex-col-gap-2">
${_myListings.map(l => _myListingRow(l)).join('')} ${_myListings.map(l => _myListingRow(l)).join('')}
</div> </div>
</div> </div>
@ -610,7 +610,7 @@ window.Page_adoption = (() => {
function _communityCard(l) { function _communityCard(l) {
const foto = l.foto_url 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" 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>'">` 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>'; : '<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 const interestBtn = l.user_interested
? `<button class="btn btn-secondary btn-sm" style="width:100%;font-size:var(--text-xs)" ? `<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 Bereits gemeldet
</button>` </button>`
: `<button class="btn btn-primary btn-sm" style="width:100%;font-size:var(--text-xs)" : `<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' : ''}> ${!isActive ? 'disabled' : ''}>
Interesse bekunden Interesse bekunden
</button>`; </button>`;
@ -657,7 +657,7 @@ window.Page_adoption = (() => {
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<span style="color:#fff;font-weight:700;font-size:var(--text-sm); <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"> background:rgba(0,0,0,0.6);padding:4px 12px;border-radius:999px">
${_esc(statusLabel)} ${UI.escape(statusLabel)}
</span> </span>
</div> </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="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); <div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.name)} ${UI.escape(l.name)}
</div> </div>
${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); ${l.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.rasse)} ${UI.escape(l.rasse)}
</div>` : ''} </div>` : ''}
<!-- Badges --> <!-- Badges -->
<div style="display:flex;gap:4px;flex-wrap:wrap"> <div style="display:flex;gap:4px;flex-wrap:wrap">
${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3); ${alterLabel ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
${_esc(alterLabel)} ${UI.escape(alterLabel)}
</span>` : ''} </span>` : ''}
${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3); ${genderIcon ? `<span style="font-size:10px;background:var(--c-surface-3);
border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)"> border-radius:999px;padding:1px 6px;color:var(--c-text-secondary)">
@ -684,14 +684,14 @@ window.Page_adoption = (() => {
</span>` : ''} </span>` : ''}
${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe); ${distTxt ? `<span style="font-size:10px;background:var(--c-primary-light,#ede9fe);
border-radius:999px;padding:1px 6px;color:var(--c-primary)"> border-radius:999px;padding:1px 6px;color:var(--c-primary)">
${_esc(distTxt)} ${UI.escape(distTxt)}
</span>` : ''} </span>` : ''}
</div> </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); ${l.beschreibung ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;display:-webkit-box; overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical"> -webkit-line-clamp:2;-webkit-box-orient:vertical">
${_esc(l.beschreibung)} ${UI.escape(l.beschreibung)}
</div>` : ''} </div>` : ''}
${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)"> ${l.interesse_count ? `<div style="font-size:10px;color:var(--c-text-muted)">
${l.interesse_count} Interessent${l.interesse_count !== 1 ? 'en' : ''} ${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); <div style="display:flex;align-items:center;gap:var(--space-2);
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);border:1px solid var(--c-border)"> 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); <div style="font-weight:600;font-size:var(--text-sm);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(l.name)} ${UI.escape(l.name)}
</div> </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' : ''} ${l.interesse_count || 0} Interessent${(l.interesse_count || 0) !== 1 ? 'en' : ''}
</div> </div>
</div> </div>
<select class="form-control" style="width:auto;font-size:var(--text-xs)" <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 => ` ${statusOptions.map(o => `
<option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option> <option value="${o.value}" ${l.status === o.value ? 'selected' : ''}>${o.label}</option>
`).join('')} `).join('')}
</select> </select>
<button class="btn btn-danger btn-sm" style="font-size:var(--text-xs);white-space:nowrap" <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 ${UI.icon('trash')} Löschen
</button> </button>
</div> </div>
@ -764,7 +764,7 @@ window.Page_adoption = (() => {
// Interesse bekunden — Modal mit optionaler Nachricht // Interesse bekunden — Modal mit optionaler Nachricht
const body = ` 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)"> <p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
Du kannst optional eine Nachricht an den Anbieter schicken. Du kannst optional eine Nachricht an den Anbieter schicken.
</p> </p>
@ -816,9 +816,9 @@ window.Page_adoption = (() => {
} }
const body = ` 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"> <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"> <input class="form-control" name="name" required placeholder="z.B. Bello">
</div> </div>
<div class="form-group"> <div class="form-group">
@ -849,7 +849,7 @@ window.Page_adoption = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">PLZ</label> <label class="form-label">PLZ</label>
<input class="form-control" name="plz" inputmode="numeric" maxlength="5" <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Ort</label> <label class="form-label">Ort</label>
@ -857,7 +857,7 @@ window.Page_adoption = (() => {
</div> </div>
</div> </div>
<div class="form-group"> <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" <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> 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> <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 = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <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 ${UI.icon('plus')} Inserat erstellen
</button> </button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button> <button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
@ -941,15 +941,6 @@ window.Page_adoption = (() => {
return 'Senior'; 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 // 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 _container = null;
let _appState = null; let _appState = null;
const _esc = s => UI.esc ? UI.esc(s) : String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---------------------------------------------------------- // ----------------------------------------------------------
// INIT // INIT
@ -51,7 +49,7 @@ window.Page_breeder = (() => {
} catch (e) { } catch (e) {
document.getElementById('breeder-profile-body').innerHTML = document.getElementById('breeder-profile-body').innerHTML =
`<div style="padding:var(--space-8);text-align:center;color:var(--c-text-secondary)"> `<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>`; </div>`;
} }
} }
@ -75,22 +73,22 @@ window.Page_breeder = (() => {
padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative"> padding:var(--space-6) var(--space-4) var(--space-8);color:white;position:relative">
<div style="max-width:640px;margin:0 auto"> <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="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"> <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 ${UI.icon('seal-check')} Verifizierter Züchter
</p> </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"> <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> </h1>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center"> <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.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>` : ''} ${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 ${_esc(seit)}</span>` : ''} ${seit ? `<span style="opacity:.7;font-size:var(--text-xs)">Züchter seit ${UI.escape(seit)}</span>` : ''}
</div> </div>
</div> </div>
${p.logo_url ${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; 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)" 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'">` onerror="this.style.display='none'">`
@ -117,7 +115,7 @@ window.Page_breeder = (() => {
Anmelden um zu schreiben Anmelden um zu schreiben
</button>` </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); 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); border-radius:999px;padding:var(--space-2) var(--space-5);
font-weight:600;font-size:var(--text-sm);text-decoration:none; font-weight:600;font-size:var(--text-sm);text-decoration:none;
@ -134,7 +132,7 @@ window.Page_breeder = (() => {
${p.beschreibung ? ` ${p.beschreibung ? `
<div style="background:var(--c-bg-secondary);border:1px solid var(--c-border);border-radius:var(--radius-lg); <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)"> 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>` : ''} </div>` : ''}
<!-- Zuchthunde --> <!-- Zuchthunde -->
@ -157,7 +155,7 @@ window.Page_breeder = (() => {
display:flex;align-items:center;gap:var(--space-2)"> display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('baby')} Aktuelle Würfe ${UI.icon('baby')} Aktuelle Würfe
</h2> </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('')} ${p.wuerfe.map(w => _wurfCard(w)).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
@ -192,8 +190,8 @@ window.Page_breeder = (() => {
${p.website ? ` ${p.website ? `
<div style="display:flex;gap:var(--space-2);align-items:baseline"> <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> <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" <dd style="margin:0"><a href="${UI.escape(p.website)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary);word-break:break-all">${_esc(p.website)}</a></dd> style="color:var(--c-primary);word-break:break-all">${UI.escape(p.website)}</a></dd>
</div>` : ''} </div>` : ''}
${seit ? _dl('Züchter seit', seit) : ''} ${seit ? _dl('Züchter seit', seit) : ''}
</dl> </dl>
@ -201,7 +199,7 @@ window.Page_breeder = (() => {
<!-- Fotos / Gallery --> <!-- Fotos / Gallery -->
${p.fotos?.length ? ` ${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; <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)"> display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('images')} Galerie ${UI.icon('images')} Galerie
@ -209,11 +207,11 @@ window.Page_breeder = (() => {
</h2> </h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2)"> <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-2)">
${p.fotos.map((ph, i) => ` ${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; style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:${ph.primary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'}; border:${ph.primary ? '2px solid var(--c-primary)' : '1px solid var(--c-border)'};
aspect-ratio:1;position:relative"> 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'}" loading="${i < 6 ? 'eager' : 'lazy'}"
style="width:100%;height:100%;object-fit:cover;display:block" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'"> 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>` : ''} 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; ${ph.caption ? `<div style="position:absolute;bottom:0;left:0;right:0;
background:linear-gradient(transparent,rgba(0,0,0,.6)); 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('')} </a>`).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
<div id="breeder-photos-section" style="display:none"></div> <div id="breeder-photos-section" class="hidden"></div>
</div>`; </div>`;
@ -251,18 +249,18 @@ window.Page_breeder = (() => {
const augeTest = h.health_tests?.find(t => t.test_typ === 'augen'); const augeTest = h.health_tests?.find(t => t.test_typ === 'augen');
const testPills = [ const testPills = [
hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${_esc(hdTest.ergebnis)}</span>` : '', hdTest ? `<span style="${_testPillStyle(hdTest.ergebnis,'HD')}">HD ${UI.escape(hdTest.ergebnis)}</span>` : '',
edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${_esc(edTest.ergebnis)}</span>` : '', edTest ? `<span style="${_testPillStyle(edTest.ergebnis,'ED')}">ED ${UI.escape(edTest.ergebnis)}</span>` : '',
augeTest ? `<span style="${_testPillStyle('clear','augen')}">Augen ✓</span>` : '', augeTest ? `<span style="${_testPillStyle('clear','augen')}">Augen ✓</span>` : '',
].filter(Boolean).join(''); ].filter(Boolean).join('');
const titlePills = (h.titel || []).map(t => const titlePills = (h.titel || []).map(t =>
`<span style="background:var(--c-primary-light,#f5e6d3);color:var(--c-primary-dark,#a86e2e); `<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(''); ).join('');
const genBadge = h.gentests_total > 0 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 ${h.gentests_clear}/${h.gentests_total} Gentests frei
</span>` </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); <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)"> padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)">
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="color:var(--c-primary)">${gIcon}</span> <span class="text-primary">${gIcon}</span>
<span style="font-weight:700;font-size:var(--text-sm)">${_esc(h.name)}</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)">"${_esc(h.rufname)}"</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>` : ''} ${alter !== null ? `<span style="color:var(--c-text-muted);font-size:var(--text-xs);margin-left:auto">${alter} J.</span>` : ''}
</div> </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>` : ''} ${testPills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${testPills}</div>` : ''}
${titlePills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${titlePills}</div>` : ''} ${titlePills ? `<div style="display:flex;flex-wrap:wrap;gap:4px">${titlePills}</div>` : ''}
${genBadge} ${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); <div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-lg);
padding:var(--space-3) var(--space-4)"> 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)"> <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; <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> border-radius:999px;padding:1px 8px;font-size:var(--text-xs);font-weight:600">${sl}</span>
</div> </div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)"> <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.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> </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>`; </div>`;
} }
@ -340,12 +338,12 @@ window.Page_breeder = (() => {
return ` return `
<div> <div>
<p style="margin:0 0 var(--space-2);font-size:var(--text-xs);font-weight:700; <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)"> <div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${stats.map(r => ` ${stats.map(r => `
<div style="display:flex;align-items:center;gap:6px;font-size:var(--text-sm)"> <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="font-weight:700">${UI.escape(r.ergebnis || '—')}</span>
<span style="color:var(--c-text-muted)">${r.cnt}×</span> <span class="text-muted">${r.cnt}×</span>
<span style="background:var(--c-border);border-radius:999px;height:6px; <span style="background:var(--c-border);border-radius:999px;height:6px;
width:${Math.round(r.cnt/total*80)+16}px;display:inline-block"></span> width:${Math.round(r.cnt/total*80)+16}px;display:inline-block"></span>
</div>`).join('')} </div>`).join('')}
@ -359,8 +357,8 @@ window.Page_breeder = (() => {
function _dl(label, value) { function _dl(label, value) {
if (!value) return ''; if (!value) return '';
return `<div style="display:flex;gap:var(--space-2);align-items:baseline"> 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> <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)">${_esc(String(value))}</dd> <dd style="margin:0;font-size:var(--text-sm)">${UI.escape(String(value))}</dd>
</div>`; </div>`;
} }
@ -377,16 +375,16 @@ window.Page_breeder = (() => {
const photos = await API.breederPhotos.list('breeder', breederId); const photos = await API.breederPhotos.list('breeder', breederId);
if (!photos?.length) return; if (!photos?.length) return;
section.innerHTML = ` 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"> <h2 style="margin:0 0 var(--space-3);font-size:var(--text-base);font-weight:700">
${UI.icon('images')} Fotos ${UI.icon('images')} Fotos
</h2> </h2>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)"> <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:var(--space-2)">
${photos.map(ph => ` ${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; style="display:block;border-radius:var(--radius-md);overflow:hidden;
border:1px solid var(--c-border);aspect-ratio:1"> 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" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.parentElement.style.display='none'"> onerror="this.parentElement.style.display='none'">
</a>`).join('')} </a>`).join('')}

View file

@ -122,7 +122,7 @@ window.Page_chat = (() => {
el.innerHTML = convs.map(c => { el.innerHTML = convs.map(c => {
const initials = (c.partner_name || '?')[0].toUpperCase(); const initials = (c.partner_name || '?')[0].toUpperCase();
const preview = c.last_text 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>'; : '<em style="opacity:0.6">Noch keine Nachrichten</em>';
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : ''; const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
const badge = c.unread_count > 0 const badge = c.unread_count > 0
@ -138,7 +138,7 @@ window.Page_chat = (() => {
${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''} ${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''}
</div> </div>
<div class="chat-conv-info"> <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 class="chat-conv-preview">${preview}</div>
</div> </div>
<div class="chat-conv-meta"> <div class="chat-conv-meta">
@ -178,7 +178,7 @@ window.Page_chat = (() => {
</button>`} </button>`}
<div style="position:relative;flex-shrink:0"> <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> <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> </div>
<span class="chat-thread-partner" id="chat-partner-name"></span> <span class="chat-thread-partner" id="chat-partner-name"></span>
</div> </div>
@ -188,7 +188,7 @@ window.Page_chat = (() => {
</div> </div>
</div> </div>
<div class="chat-input-bar"> <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)"> onchange="Page_chat._onPhotoSelected(this)">
<button class="chat-photo-btn" onclick="document.getElementById('chat-photo-input').click()" title="Foto senden"> <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> <svg class="ph-icon"><use href="/icons/phosphor.svg#camera"></use></svg>
@ -332,10 +332,10 @@ window.Page_chat = (() => {
} }
if (m.text) { if (m.text) {
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') + bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +
_esc(m.text) + UI.escape(m.text) +
(m.media_url ? `</div>` : ''); (m.media_url ? `</div>` : '');
} }
if (!bubbleContent) bubbleContent = _esc(m.text); if (!bubbleContent) bubbleContent = UI.escape(m.text);
html += ` html += `
<div class="chat-bubble-row ${rowClass}"> <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' }); 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 // 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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
</button> </button>
</div> </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-view-content">
<div id="diary-list"></div> <div id="diary-list"></div>
</div> </div>
@ -295,7 +295,7 @@ window.Page_diary = (() => {
`; `;
card.innerHTML = ` card.innerHTML = `
<div style="font-size:1.8rem;flex-shrink:0;line-height:1">🐾</div> <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); <div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-primary-dark);text-transform:uppercase; color:var(--c-primary-dark);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:var(--space-1)"> letter-spacing:.06em;margin-bottom:var(--space-1)">
@ -963,7 +963,7 @@ window.Page_diary = (() => {
// Hunde-Chips (bei mehreren Hunden) // Hunde-Chips (bei mehreren Hunden)
const dogsHtml = dogIds.length > 1 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 => { ${dogIds.map(did => {
const dog = _appState.dogs.find(d => d.id === did); const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip"> return dog ? `<div class="diary-dog-chip">
@ -1279,7 +1279,7 @@ window.Page_diary = (() => {
value="${entry?.datum || today}" required> value="${entry?.datum || today}" required>
</div> </div>
<div class="form-group"> <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" <input class="form-control" type="text" name="titel"
value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag"> value="${UI.escape(entry?.titel || '')}" placeholder="z.B. Erster Schultag">
</div> </div>
@ -1293,10 +1293,10 @@ window.Page_diary = (() => {
<div id="diary-existing-media"></div> <div id="diary-existing-media"></div>
<!-- Neue Medien: Vorschau-Grid --> <!-- 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 --> <!-- 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) --> <!-- 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"> <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> </label>
</div> </div>
<div class="form-group" id="diary-location-group"> <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) --> <!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
<div style="position:relative"> <div style="position:relative">
@ -1318,7 +1318,7 @@ window.Page_diary = (() => {
</div> </div>
<!-- POI-Name + Aktionen --> <!-- 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 id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
<div class="diary-location-chip"> <div class="diary-location-chip">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
@ -1341,7 +1341,7 @@ window.Page_diary = (() => {
${dogPickerHtml} ${dogPickerHtml}
<div class="form-group" style="margin-top:var(--space-5)"> <div class="form-group" style="margin-top:var(--space-5)">
<input type="checkbox" name="is_milestone" id="diary-milestone-cb" <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" <button type="button" id="diary-milestone-btn"
class="diary-milestone-toggle${entry?.is_milestone ? ' diary-milestone-toggle--active' : ''}"> 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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
@ -1353,10 +1353,10 @@ window.Page_diary = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <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'} ${isEdit ? 'Speichern' : 'Erstellen'}
</button> </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>` : ''} ${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> <button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
</div> </div>
@ -1843,32 +1843,32 @@ window.Page_diary = (() => {
<strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>. <strong>${UI.escape(_appState.activeDog?.name || 'deinem Hund')}</strong>.
</p> </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"> <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"> <div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note"></use></svg>
</div> </div>
<div> <div>
<div style="font-weight:var(--weight-semibold)">Synology NoteStation</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> </div>
</label> </label>
<label class="import-format-card" id="fmt-csv"> <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"> <div class="import-format-icon">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-csv"></use></svg>
</div> </div>
<div> <div>
<div style="font-weight:var(--weight-semibold)">CSV / Excel</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> </div>
</label> </label>
</div> </div>
<div style="margin-top:var(--space-4)"> <div class="mt-4">
<label class="form-label">Datei auswählen</label> <label class="form-label">Datei auswählen</label>
<input type="file" class="form-control" id="import-file-input" <input type="file" class="form-control" id="import-file-input"
accept=".nsx,.csv" style="cursor:pointer"> accept=".nsx,.csv" style="cursor:pointer">
@ -1917,7 +1917,7 @@ window.Page_diary = (() => {
: await API.importData.csv(dogId, file); : await API.importData.csv(dogId, file);
const errHtml = res.errors?.length 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>` <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); <div style="background:var(--c-success-subtle);border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);color:var(--c-success)"> padding:var(--space-3) var(--space-4);color:var(--c-success)">
<strong>${res.imported} Einträge importiert</strong> <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} ${errHtml}
</div>`; </div>`;
resultEl.style.display = 'block'; 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"> <div style="position:relative;display:inline-block;margin-bottom:var(--space-4);padding:4px">
${dog.foto_url ${dog.foto_url
? `<div class="dp-avatar-ring"> ? `<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}%)"> style="transform:scale(${dog.foto_zoom||1}) translate(${dog.foto_offset_x||0}%,${dog.foto_offset_y||0}%)">
</div>` </div>`
: `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`} : `<div class="dp-avatar-ring dp-avatar-empty">${UI.icon('dog')}</div>`}
@ -95,28 +95,28 @@ window.Page_dog_profile = (() => {
<!-- Name + Rasse --> <!-- Name + Rasse -->
<h2 style="font-size:var(--text-2xl);font-weight:700; <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 ${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>`} : `<p style="margin:0 0 var(--space-2)"></p>`}
<!-- Rassen-Community-Chip (wird async geladen) --> <!-- 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 --> <!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3); <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
margin-bottom:var(--space-5);text-align:left"> margin-bottom:var(--space-5);text-align:left">
${geburtstag ? ` ${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 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-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)} ${_calcAlter(dog.geburtstag)}
</div> </div>
</div> </div>
` : ''} ` : ''}
${dog.geschlecht ? ` ${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 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)"> <div style="font-weight:500;font-size:var(--text-sm)">
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'} ${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
@ -130,19 +130,19 @@ window.Page_dog_profile = (() => {
</div> </div>
` : ''} ` : ''}
${dog.widerrist_cm ? ` ${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 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 style="font-weight:500;font-size:var(--text-sm)">${dog.widerrist_cm} cm</div>
</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); <div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px"> margin-bottom:2px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg> Transponder <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#wave-sine"></use></svg> Transponder
</div> </div>
${dog.chip_nr ${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);font-weight:500;word-break:break-all">${UI.escape(dog.chip_nr)}</div>`
: `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">nicht eingetragen : `<div class="text-xs-muted">nicht eingetragen
<button class="btn btn-link btn-sm" id="dp-chip-edit-btn" <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> style="padding:0 0 0 var(--space-1);font-size:var(--text-xs)">Eintragen</button>
</div>` </div>`
@ -153,7 +153,7 @@ window.Page_dog_profile = (() => {
${dog.bio ? ` ${dog.bio ? `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-5);text-align:left"> <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"> <p style="margin:0;color:var(--c-text-secondary);font-style:italic;line-height:1.6">
"${_esc(dog.bio)}" "${UI.escape(dog.bio)}"
</p> </p>
</div> </div>
` : ''} ` : ''}
@ -230,12 +230,12 @@ window.Page_dog_profile = (() => {
<div class="card" style="margin-bottom:var(--space-5)"> <div class="card" style="margin-bottom:var(--space-5)">
<div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)"> <div style="padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<div style="font-weight:600">Sitter-Zugang</div> <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. 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. Deine bestehenden Daten und Medien bleiben unsichtbar und privat der Sitter kann nur neue Einträge anlegen.
</div> </div>
</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> </div>
` : ''} ` : ''}
`; `;
@ -335,12 +335,12 @@ window.Page_dog_profile = (() => {
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true"> <svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use> <use href="/icons/phosphor.svg#${isGreen ? 'check' : 'fire'}"></use>
</svg> </svg>
${_esc(skill.exercise_name)} ${UI.escape(skill.exercise_name)}
</span>`; </span>`;
}; };
const sitztBlock = sitzt.length ? ` 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); <div style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2); color:var(--c-text-secondary);margin-bottom:var(--space-2);
text-transform:uppercase;letter-spacing:.04em">Sitzt</div> text-transform:uppercase;letter-spacing:.04em">Sitzt</div>
@ -360,7 +360,7 @@ window.Page_dog_profile = (() => {
</div>` : ''; </div>` : '';
el.innerHTML = ` 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)"> <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"> <svg class="ph-icon" style="width:16px;height:16px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#list-checks"></use> <use href="/icons/phosphor.svg#list-checks"></use>
@ -409,11 +409,11 @@ window.Page_dog_profile = (() => {
: ''; : '';
el.innerHTML = ` 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)"> <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:1.1em">🛁</span>
<span style="font-size:var(--text-sm);font-weight:600"> <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> </span>
</div> </div>
@ -426,24 +426,24 @@ window.Page_dog_profile = (() => {
${t.saisonal_aktuell ? '🌸 Aktuell & Saisonal' : '💡 Tipp des Tages'} ${t.saisonal_aktuell ? '🌸 Aktuell & Saisonal' : '💡 Tipp des Tages'}
</div> </div>
<div style="font-weight:600;font-size:var(--text-sm);margin-bottom:4px"> <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>
<div style="font-size:12px;color:var(--c-text-secondary);margin-bottom:8px; <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)"> ${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)"> ${t.materialien ? `<div style="font-size:11px;color:var(--c-text-muted)">
🛒 ${_esc(t.materialien)}</div>` : ''} 🛒 ${UI.escape(t.materialien)}</div>` : ''}
${t.schritte?.length ? ` ${t.schritte?.length ? `
<details style="margin-top:8px"> <details style="margin-top:8px">
<summary style="font-size:12px;cursor:pointer;color:var(--c-primary); <summary style="font-size:12px;cursor:pointer;color:var(--c-primary);
font-weight:600">Anleitung anzeigen</summary> font-weight:600">Anleitung anzeigen</summary>
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px; <ol style="margin:8px 0 0 16px;padding:0;font-size:12px;
color:var(--c-text);line-height:1.6"> 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> </ol>
${t.tipp ? `<div style="margin-top:8px;font-size:11px;color:#a78bfa; ${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>` : ''} </details>` : ''}
</div>` : ''} </div>` : ''}
@ -457,29 +457,29 @@ window.Page_dog_profile = (() => {
const katTipps = data.tipps.filter(t=>t.kategorie===kat); const katTipps = data.tipps.filter(t=>t.kategorie===kat);
const katBadge = kat === 'Fell' ? pflegeArtBadge : ''; const katBadge = kat === 'Fell' ? pflegeArtBadge : '';
return ` 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); <div style="font-size:11px;font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;margin-bottom:8px;display:flex;align-items:center"> 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 => ` ${katTipps.map(tip => `
<details style="background:var(--c-surface-2);border-radius:8px; <details style="background:var(--c-surface-2);border-radius:8px;
padding:10px;margin-bottom:6px"> padding:10px;margin-bottom:6px">
<summary style="font-size:var(--text-sm);font-weight:600;cursor:pointer; <summary style="font-size:var(--text-sm);font-weight:600;cursor:pointer;
list-style:none;display:flex;justify-content:space-between; list-style:none;display:flex;justify-content:space-between;
align-items:center"> align-items:center">
${_esc(tip.titel)} ${UI.escape(tip.titel)}
${tip.saisonal_aktuell ? '<span style="font-size:10px;color:#10b981">● Aktuell</span>' : ''} ${tip.saisonal_aktuell ? '<span style="font-size:10px;color:#10b981">● Aktuell</span>' : ''}
</summary> </summary>
<div style="margin-top:8px;font-size:12px;color:var(--c-text-secondary); <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); ${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 ? ` ${tip.schritte?.length ? `
<ol style="margin:8px 0 0 16px;padding:0;font-size:12px;line-height:1.6"> <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>` : ''} </ol>` : ''}
${tip.tipp ? `<div style="margin-top:6px;font-size:11px;color:#a78bfa; ${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('')} </details>`).join('')}
</div>`; </div>`;
}).join('')} }).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 // 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)"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)"> <div style="flex:1;font-size:var(--text-sm)">
<strong>${_esc(s.sitter_name)}</strong> <strong>${UI.escape(s.sitter_name)}</strong>
<span style="color:var(--c-text-muted)"> · bis ${_esc(s.valid_until)}</span> <span class="text-muted"> · bis ${UI.escape(s.valid_until)}</span>
</div> </div>
<button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}" <button class="btn btn-link btn-sm sa-revoke-btn" data-sub-id="${s.id}"
style="color:var(--c-danger);padding:0"> style="color:var(--c-danger);padding:0">
@ -538,7 +532,7 @@ window.Page_dog_profile = (() => {
} }
const friendOptions = friends.length 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>'; : '<option value="" disabled>Keine Freunde vorhanden</option>';
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
@ -547,26 +541,26 @@ window.Page_dog_profile = (() => {
wrap.innerHTML = ` wrap.innerHTML = `
${activeHtml} ${activeHtml}
${friends.length ? ` ${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); <div style="font-size:var(--text-xs);color:var(--c-text-muted);
margin-bottom:var(--space-2);font-weight:600">Zugang gewähren</div> 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); <div style="display:grid;grid-template-columns:1fr auto;gap:var(--space-2);
align-items:end"> align-items:end">
<div class="form-group" style="margin:0"> <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"> <select class="form-control form-control-sm" id="sa-friend-select">
<option value="">Freund wählen</option> <option value="">Freund wählen</option>
${friendOptions} ${friendOptions}
</select> </select>
</div> </div>
<div class="form-group" style="margin:0"> <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" <input class="form-control form-control-sm" type="date" id="sa-until-input"
value="${defaultUntil}" min="${today}"> value="${defaultUntil}" min="${today}">
</div> </div>
</div> </div>
<button class="btn btn-primary btn-sm w-full" id="sa-grant-btn" <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 Zugang gewähren
</button> </button>
</div> </div>
@ -617,11 +611,11 @@ window.Page_dog_profile = (() => {
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Chip-Nummer (15-stellig)</label> <label class="form-label">Chip-Nummer (15-stellig)</label>
<input id="chip-edit-input" class="form-control" type="text" <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>`, </div>`,
footer: ` footer: `
<div class="w3-btn-stack"> <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> <button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>`, </div>`,
}); });
@ -666,20 +660,20 @@ window.Page_dog_profile = (() => {
<div class="photo-editor-controls"> <div class="photo-editor-controls">
<label class="form-label">Zoom</label> <label class="form-label">Zoom</label>
<input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}" <input type="range" id="pe-zoom" min="1" max="3" step="0.05" value="${zoom}"
style="width:100%"> class="w-full">
</div> </div>
` : ''} ` : ''}
<label class="btn btn-secondary" style="cursor:pointer"> <label class="btn btn-secondary" style="cursor:pointer">
${UI.icon('upload-simple')} Neues Foto wählen ${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> </label>
</div> </div>
`; `;
const footer = ` const footer = `
<div class="w3-btn-stack"> <div class="w3-btn-stack">
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" style="width:100%">Speichern</button>` : ''} ${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" class="w-full">Speichern</button>` : ''}
<div style="display:flex;gap:var(--space-2)"> <div class="flex-gap-2">
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''} ${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> <button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div> </div>
@ -843,15 +837,15 @@ window.Page_dog_profile = (() => {
<!-- Header --> <!-- Header -->
<div style="display:flex;align-items:center;gap:12px;margin-bottom:18px"> <div style="display:flex;align-items:center;gap:12px;margin-bottom:18px">
${dog.foto_url ${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">` 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); : `<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; 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>`} flex-shrink:0;border:2px solid rgba(196,132,58,0.4)">🐾</div>`}
<div> <div>
<div style="font-size:1.25rem;font-weight:800;color:#fff;line-height:1.2">${_esc(dog.name)}</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">${_esc(metaLine)}</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">📍 ${_esc(wohnort)}</div>` : ''} ${wohnort ? `<div style="font-size:0.75rem;color:rgba(196,132,58,0.9);margin-top:3px">📍 ${UI.escape(wohnort)}</div>` : ''}
</div> </div>
</div> </div>
@ -860,13 +854,13 @@ window.Page_dog_profile = (() => {
<!-- Owner + QR --> <!-- Owner + QR -->
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:12px"> <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> ${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 style="font-size:0.65rem;color:rgba(255,255,255,0.35);margin-top:8px">banyaro.app</div>
</div> </div>
<div style="flex-shrink:0;text-align:center"> <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" style="width:80px;height:80px;border-radius:10px;display:block"
alt="QR-Code"> alt="QR-Code">
<div style="font-size:0.6rem;color:rgba(255,255,255,0.35);margin-top:4px">Profil öffnen</div> <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({ UI.modal.open({
title: 'Visitenkarte', title: 'Visitenkarte',
body: ` 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"> <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> </p>
`, `,
footer: ` footer: `
@ -935,7 +929,7 @@ window.Page_dog_profile = (() => {
async function _showShareModal(dog) { async function _showShareModal(dog) {
UI.modal.open({ UI.modal.open({
title: `${_esc(dog.name)} teilen`, title: `${UI.escape(dog.name)} teilen`,
body: ` body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)"> <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. 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> <label class="form-label">Einladungslink</label>
<div style="display:flex;gap:var(--space-2);align-items:center"> <div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" id="share-link-input" type="text" readonly <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"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button> </button>
@ -961,7 +955,7 @@ window.Page_dog_profile = (() => {
Dieser Link kann einmalig angenommen werden. Dieser Link kann einmalig angenommen werden.
</p> </p>
</div> </div>
<div id="share-list-wrap" style="margin-top:var(--space-4)"></div>`, <div id="share-list-wrap" class="mt-4"></div>`,
footer: ` footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button> <button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="share-create-btn">Link erstellen</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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<div style="flex:1;font-size:var(--text-sm)"> <div style="flex:1;font-size:var(--text-sm)">
${s.shared_with_name ${s.shared_with_name
? `<strong>${_esc(s.shared_with_name)}</strong> · ${s.role}` ? `<strong>${UI.escape(s.shared_with_name)}</strong> · ${s.role}`
: `<em style="color:var(--c-text-muted)">Ausstehend</em> · ${s.role}`} : `<em class="text-muted">Ausstehend</em> · ${s.role}`}
</div> </div>
<button class="btn btn-link btn-sm share-revoke-btn" data-share-id="${s.id}" <button class="btn btn-link btn-sm share-revoke-btn" data-share-id="${s.id}"
style="color:var(--c-danger);padding:0"> style="color:var(--c-danger);padding:0">
@ -1056,7 +1050,7 @@ window.Page_dog_profile = (() => {
body: _formHTML(null, true), body: _formHTML(null, true),
footer: ` footer: `
<div class="w3-btn-stack"> <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> <button type="button" class="btn btn-secondary" id="dp-form-cancel">Abbrechen</button>
</div> </div>
`, `,
@ -1073,8 +1067,8 @@ window.Page_dog_profile = (() => {
body: _formHTML(dog, true), body: _formHTML(dog, true),
footer: ` footer: `
<div class="w3-btn-stack"> <div class="w3-btn-stack">
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button> <button type="submit" form="dp-form" class="btn btn-primary w-full">Speichern</button>
<div style="display:flex;gap:var(--space-2)"> <div class="flex-gap-2">
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button> <button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
<button type="button" id="dp-gedenken-btn" <button type="button" id="dp-gedenken-btn"
style="flex:1;padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); 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"> <div class="form-group">
<label class="form-label">Name *</label> <label class="form-label">Name *</label>
<input class="form-control" type="text" name="name" <input class="form-control" type="text" name="name"
value="${_esc(dog?.name || '')}" value="${UI.escape(dog?.name || '')}"
placeholder="z. B. Ban Yaro" required> placeholder="z. B. Ban Yaro" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Rasse 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.')} ${UI.help('Verknüpfe deine Rasse mit unserem Wiki für personalisierte Pflegetipps.')}
</label> </label>
<input class="form-control" type="text" name="rasse" <input class="form-control" type="text" name="rasse"
id="dp-rasse-input" id="dp-rasse-input"
value="${_esc(dog?.rasse || '')}" value="${UI.escape(dog?.rasse || '')}"
list="dp-rasse-list" list="dp-rasse-list"
autocomplete="off" autocomplete="off"
placeholder="z. B. Mischling, Golden Retriever…"> placeholder="z. B. Mischling, Golden Retriever…">
@ -1126,7 +1120,7 @@ window.Page_dog_profile = (() => {
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Geburtstag</label> <label class="form-label">Geburtstag</label>
<input class="form-control" type="date" name="geburtstag" <input class="form-control" type="date" name="geburtstag"
@ -1142,7 +1136,7 @@ window.Page_dog_profile = (() => {
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Gewicht (kg)</label> <label class="form-label">Gewicht (kg)</label>
<input class="form-control" type="number" name="gewicht_kg" <input class="form-control" type="number" name="gewicht_kg"
@ -1160,14 +1154,14 @@ window.Page_dog_profile = (() => {
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Chip-Nummer Chip-Nummer
${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')} ${UI.help('Die 15-stellige Chip-Nummer findest du im Heimtierausweis oder beim Tierarzt.')}
</label> </label>
<input class="form-control" type="text" name="chip_nr" <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> <div></div>
</div> </div>
@ -1175,7 +1169,7 @@ window.Page_dog_profile = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Felltyp 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.')} ${UI.help('Der Felltyp wird für personalisierte Wetter-Hinweise genutzt.')}
</label> </label>
<select class="form-control" name="fell_typ"> <select class="form-control" name="fell_typ">
@ -1192,10 +1186,10 @@ window.Page_dog_profile = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Bio / Steckbrief Bio / Steckbrief
<span style="color:var(--c-text-secondary)">(optional)</span> <span class="text-secondary">(optional)</span>
</label> </label>
<textarea class="form-control" name="bio" rows="2" <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>
<div class="form-group"> <div class="form-group">
@ -1216,7 +1210,7 @@ window.Page_dog_profile = (() => {
display:${dog?.foto_url ? 'block' : 'none'}"> display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0"> <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 <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"> id="dp-form-foto">
</label> </label>
<button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn" <button type="button" class="btn btn-secondary btn-sm" id="dp-rasse-erkennen-btn"
@ -1225,7 +1219,7 @@ window.Page_dog_profile = (() => {
Rasse erkennen Rasse erkennen
</button> </button>
<input type="file" accept="image/jpeg,image/png,image/webp" <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>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px"> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
Foto hochladen um die Rasse per KI zu erkennen Foto hochladen um die Rasse per KI zu erkennen
@ -1473,11 +1467,11 @@ window.Page_dog_profile = (() => {
title: 'Kein Hund erkannt', title: 'Kein Hund erkannt',
body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)"> body: `<div style="text-align:center;padding:var(--space-6) var(--space-2)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐾</div> <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> Auf diesem Foto konnte kein Hund erkannt werden.<br>
Bitte lade ein deutlicheres Foto hoch. Bitte lade ein deutlicheres Foto hoch.
</p> </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>`, </div>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`, footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
}); });
@ -1490,24 +1484,24 @@ window.Page_dog_profile = (() => {
return ` return `
<div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}"> <div class="rasse-result-card${isTop ? ' rasse-result-card--top' : ''}">
<div style="display:flex;align-items:center;justify-content:space-between"> <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> <span class="rasse-result-pct${isTop ? '' : ' rasse-result-pct--dim'}">${r.sicherheit}%</span>
</div> </div>
<div class="rasse-result-bar-wrap"> <div class="rasse-result-bar-wrap">
<div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}" <div class="rasse-result-bar${isTop ? '' : ' rasse-result-bar--dim'}"
style="width:${r.sicherheit}%"></div> style="width:${r.sicherheit}%"></div>
</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"> <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" ${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 Rasse übernehmen
</button>` : `<button class="btn btn-secondary btn-sm" data-action="uebernehmen" </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 Diese wählen
</button>`} </button>`}
${r.wiki_slug ? `<button class="btn btn-ghost btn-sm" data-action="wiki" ${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 Im Wiki
</button>` : ''} </button>` : ''}
</div> </div>
@ -1521,7 +1515,7 @@ window.Page_dog_profile = (() => {
<div style="padding-bottom:var(--space-2)"> <div style="padding-bottom:var(--space-2)">
${data.hinweis ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md); ${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); 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} ${cardsHtml}
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2); <p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-2);
text-align:center"> text-align:center">
@ -1582,18 +1576,13 @@ window.Page_dog_profile = (() => {
: `${j} Jahr${j !== 1 ? 'e' : ''} alt`; : `${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 // HUNDEPASS
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _showPassportModal(dog) { async function _showPassportModal(dog) {
UI.modal.open({ UI.modal.open({
title: `Hundepass — ${_esc(dog.name)}`, title: `Hundepass — ${UI.escape(dog.name)}`,
body: `<div id="pp-body" style="min-height:200px"> body: `<div id="pp-body" style="min-height:200px">
<div style="text-align:center;padding:var(--space-6)"> <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"> <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 { try {
data = await API.get(`/passport/${dog.id}`); data = await API.get(`/passport/${dog.id}`);
} catch (e) { } 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; return;
} }
@ -1666,25 +1655,25 @@ window.Page_dog_profile = (() => {
Bearbeiten Bearbeiten
</button> </button>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div> <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"> <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>
<div> <div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Allergien</div> <div class="text-xs-secondary">Allergien</div>
<div id="pp-meta-allergien" style="font-size:var(--text-sm)"> <div id="pp-meta-allergien" class="text-sm">
${_esc(meta.allergien) || '<span style="color:var(--c-text-muted)">keine</span>'} ${UI.escape(meta.allergien) || '<span class="text-muted">keine</span>'}
</div> </div>
</div> </div>
</div> </div>
${meta.besonderheiten ? ` ${meta.besonderheiten ? `
<div style="margin-top:var(--space-3)"> <div class="mt-3">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">Besonderheiten</div> <div class="text-xs-secondary">Besonderheiten</div>
<div id="pp-meta-besonderheiten" style="font-size:var(--text-sm)"> <div id="pp-meta-besonderheiten" class="text-sm">
${_esc(meta.besonderheiten)} ${UI.escape(meta.besonderheiten)}
</div> </div>
</div>` : ''} </div>` : ''}
</div> </div>
@ -1708,13 +1697,13 @@ window.Page_dog_profile = (() => {
: vaccs.map(v => ` : vaccs.map(v => `
<div class="pp-vacc-row" data-id="${v.id}" <div class="pp-vacc-row" data-id="${v.id}"
class="pp-data-row"> class="pp-data-row">
<div style="flex:1"> <div class="flex-1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(v.krankheit)}</div> <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"> <div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Gegeben: ${_fmt(v.datum)} Gegeben: ${_fmt(v.datum)}
${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''} ${v.naechste ? ` · Nächste: ${_fmt(v.naechste)}` : ''}
${v.tierarzt ? ` · ${_esc(v.tierarzt)}` : ''} ${v.tierarzt ? ` · ${UI.escape(v.tierarzt)}` : ''}
${v.charge_nr ? ` · Charge: ${_esc(v.charge_nr)}` : ''} ${v.charge_nr ? ` · Charge: ${UI.escape(v.charge_nr)}` : ''}
</div> </div>
</div> </div>
<button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}" <button class="btn btn-link btn-sm pp-vacc-del" data-id="${v.id}"
@ -1727,7 +1716,7 @@ window.Page_dog_profile = (() => {
</div> </div>
<!-- Medikamente --> <!-- 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; <div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-3)"> margin-bottom:var(--space-3)">
<span style="font-weight:700;font-size:var(--text-sm)"> <span style="font-weight:700;font-size:var(--text-sm)">
@ -1745,13 +1734,13 @@ window.Page_dog_profile = (() => {
: meds.map(m => ` : meds.map(m => `
<div class="pp-med-row" data-id="${m.id}" <div class="pp-med-row" data-id="${m.id}"
class="pp-data-row"> class="pp-data-row">
<div style="flex:1"> <div class="flex-1">
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(m.name)}</div> <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"> <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.von ? `Von ${_fmt(m.von)}` : ''}
${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''} ${m.bis ? ` bis ${_fmt(m.bis)}` : m.von ? ' · dauerhaft' : ''}
${m.notiz ? ` · ${_esc(m.notiz)}` : ''} ${m.notiz ? ` · ${UI.escape(m.notiz)}` : ''}
</div> </div>
</div> </div>
<button class="btn btn-link btn-sm pp-med-del" data-id="${m.id}" <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"> <div class="form-group">
<label class="form-label">Blutgruppe</label> <label class="form-label">Blutgruppe</label>
<input id="pp-meta-bg" class="form-control" type="text" <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Allergien</label> <label class="form-label">Allergien</label>
<textarea id="pp-meta-al" class="form-control" rows="2" <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Besonderheiten</label> <label class="form-label">Besonderheiten</label>
<textarea id="pp-meta-be" class="form-control" rows="2" <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>`, </div>`,
footer: ` footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end"> <div style="display:flex;gap:var(--space-2);justify-content:flex-end">
@ -1871,7 +1860,7 @@ window.Page_dog_profile = (() => {
<option value="DHPP (Kombi)"> <option value="DHPP (Kombi)">
</datalist> </datalist>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Datum *</label> <label class="form-label">Datum *</label>
<input id="pp-vacc-datum" class="form-control" type="date" value="${today}"> <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" <input id="pp-med-dosierung" class="form-control" type="text"
placeholder="z. B. 1× täglich, 5 mg"> placeholder="z. B. 1× täglich, 5 mg">
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Von</label> <label class="form-label">Von</label>
<input id="pp-med-von" class="form-control" type="date" value="${today}"> <input id="pp-med-von" class="form-control" type="date" value="${today}">
</div> </div>
<div class="form-group"> <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"> <input id="pp-med-bis" class="form-control" type="date">
</div> </div>
</div> </div>
@ -2001,7 +1990,7 @@ window.Page_dog_profile = (() => {
</p> </p>
<div style="display:flex;gap:var(--space-2);align-items:center"> <div style="display:flex;gap:var(--space-2);align-items:center">
<input id="pp-sharelink-input" class="form-control" type="text" readonly <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"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>
</button> </button>
@ -2037,7 +2026,7 @@ window.Page_dog_profile = (() => {
return; return;
} }
const name = _esc(data.dog_name); const name = UI.escape(data.dog_name);
const km = data.gesamt_km || 0; const km = data.gesamt_km || 0;
const konfetti = km > 100; 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> <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.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.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>` : ''} ${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: ${_esc(aktivitaet)}</div>` : ''} ${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${UI.escape(aktivitaet)}</div>` : ''}
`), `),
_card(` _card(`
<div style="font-size:2rem">🌡</div> <div style="font-size:2rem">🌡</div>
@ -2126,8 +2115,8 @@ window.Page_dog_profile = (() => {
</div> </div>
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative"> <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> <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-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:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;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>
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</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) { async function _showTimelineModal(dog) {
UI.modal.open({ 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)"> 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"> <svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#spinner-gap"></use> <use href="/icons/phosphor.svg#spinner-gap"></use>
@ -2314,7 +2303,7 @@ window.Page_dog_profile = (() => {
data = await API.get(`/dogs/${dog.id}/timeline`); data = await API.get(`/dogs/${dog.id}/timeline`);
} catch (e) { } catch (e) {
const b = document.getElementById('dp-timeline-body'); 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; return;
} }
@ -2351,14 +2340,14 @@ window.Page_dog_profile = (() => {
for (const ev of events) { for (const ev of events) {
const year = ev.datum ? ev.datum.substring(0, 4) : null; const year = ev.datum ? ev.datum.substring(0, 4) : null;
if (year && year !== lastYear) { if (year && year !== lastYear) {
html += `<div class="tl-year">${_esc(year)}</div>`; html += `<div class="tl-year">${UI.escape(year)}</div>`;
lastYear = year; lastYear = year;
} }
const kat = _KAT[ev.kategorie] || _KAT.tagebuch; const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
const big = ev.is_milestone; 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 === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`;
if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`; if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`;
if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${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> box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div>
<div class="tl-card"> <div class="tl-card">
${big && ev.foto_url ? ` ${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"> <div class="tl-meta">
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}"> <span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true"> <svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
<use href="/icons/phosphor.svg#${kat.icon}"></use> <use href="/icons/phosphor.svg#${kat.icon}"></use>
</svg> </svg>
${_esc(kat.label)} ${UI.escape(kat.label)}
</span> </span>
<span class="tl-date">${_fmtDate(ev.datum)}</span> <span class="tl-date">${_fmtDate(ev.datum)}</span>
</div> </div>
@ -2451,8 +2440,8 @@ window.Page_dog_profile = (() => {
if (!data || data.count === 0) return; if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || ''; const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1 const label = data.count === 1
? `1 anderer ${_esc(hauptRasse)}-Halter in der App` ? `1 anderer ${UI.escape(hauptRasse)}-Halter in der App`
: `${data.count} andere ${_esc(hauptRasse)}-Halter in der App`; : `${data.count} andere ${UI.escape(hauptRasse)}-Halter in der App`;
el.innerHTML = ` el.innerHTML = `
<button class="breed-community-chip" id="dp-breed-chip-btn"> <button class="breed-community-chip" id="dp-breed-chip-btn">
@ -2498,7 +2487,7 @@ window.Page_dog_profile = (() => {
</form>`, </form>`,
footer: ` footer: `
<div class="w3-btn-stack"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#heart"></use></svg>
Gedenkseite erstellen Gedenkseite erstellen
</button> </button>
@ -2550,22 +2539,22 @@ window.Page_dog_profile = (() => {
${d.km_total ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-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>` : ''} </div>` : ''}
${d.diary_count ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-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>` : ''} </div>` : ''}
${d.media_count ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-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>` : ''} </div>` : ''}
${d.gemeinsam_tage ? `<div class="card" style="padding:var(--space-3);text-align:center"> ${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> <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-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>` : ''}
</div>`; </div>`;
@ -2596,8 +2585,8 @@ window.Page_dog_profile = (() => {
Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos) Professionelle Hilfe bei Tiertrauer: <strong>Tiertrauer-Hotline 0800 111 0 111</strong> (kostenlos)
</div> </div>
</div> </div>
<div id="gedenk-ki-wrap" style="margin-top:var(--space-4)"> <div id="gedenk-ki-wrap" class="mt-4">
<button id="gedenk-ki-btn" class="btn btn-secondary" style="width:100%"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sparkle"></use></svg>
Persönlichen Abschiedstext erstellen Persönlichen Abschiedstext erstellen
</button> </button>

View file

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

View file

@ -33,15 +33,16 @@ window.Page_erste_hilfe = (() => {
flag: 'AT', flag: 'AT',
eintraege: [ eintraege: [
{ label: 'Vergiftungsinformationszentrale Wien', tel: '+4314064343', display: '+43 1 406 43 43' }, { label: 'Vergiftungsinformationszentrale Wien', tel: '+4314064343', display: '+43 1 406 43 43' },
{ label: 'Veterinärmedizinische Universität Wien (Notfallklinik)', tel: null, display: 'TODO: Nummer einfügen' }, { label: 'VetMedUni Wien — Kleintier-Notdienst (24h)', tel: '+431250776900', display: '+43 1 25077-6900' },
], ],
}, },
{ {
land: 'Schweiz', land: 'Schweiz',
flag: 'CH', flag: 'CH',
eintraege: [ eintraege: [
{ label: 'Tox Info Suisse (Tiergiftnotruf)', tel: null, display: 'TODO: Nummer einfügen (ggf. 145)' }, { label: 'Tox Info Suisse (in CH gratis)', tel: '145', display: '145 (in CH)' },
{ label: 'Tierspital Zürich', tel: null, display: 'TODO: Nummer einfügen' }, { 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> </div>
${KATEGORIEN.map(k => ` ${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('')} ${k.eintraege.map((e, i) => _renderEintrag(e, k.id, i, k.color)).join('')}
</div> </div>
`).join('')} `).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"> <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. Diese Inhalte ersetzen keinen Tierarztbesuch. Im Zweifel immer sofort zum Tierarzt oder den tierärztlichen Notdienst anrufen.
</div> </div>
</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)"> <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} ${g.flag} · ${g.land}
</div> </div>
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div class="flex-col-gap-2">
${g.eintraege.map(renderEintrag).join('')} ${g.eintraege.map(renderEintrag).join('')}
</div> </div>
</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> <svg class="ph-icon" style="width:20px;height:20px" aria-hidden="true"><use href="/icons/phosphor.svg#siren"></use></svg>
Tiergiftzentralen jetzt anrufen Tiergiftzentralen jetzt anrufen
</div> </div>
<div style="display:flex;flex-direction:column;gap:var(--space-3)"> <div class="flex-col-gap-3">
${gruppen} ${gruppen}
</div> </div>
<p style="margin-top:var(--space-3);font-size:var(--text-xs);color:rgba(255,255,255,0.8)"> <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 ` return `
<div class="card" style="padding:0;overflow:hidden;margin-bottom:var(--space-4)"> <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)"> <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 Schnellübersicht: Was tun bei
</div> </div>
<div style="overflow-x:auto"> <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"> display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div> <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-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> </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> <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> </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 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> <button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</div> </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>` : ''} ${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div> </div>
@ -102,7 +102,7 @@ window.Page_events = (() => {
</div> </div>
<div class="events-list" id="ev-list"></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); _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}" ${_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-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}" 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>` : ''} ${_icon('note-pencil')}</button>` : ''}
</div> </div>
</div> </div>
@ -248,8 +248,10 @@ window.Page_events = (() => {
await UI.loadLeaflet(true); // true = mit MarkerCluster await UI.loadLeaflet(true); // true = mit MarkerCluster
if (!_map) { if (!_map) {
_map = L.map('ev-map', { zoomControl: true }).setView([51.1657, 10.4515], 6); _map = await UI.map.create('ev-map', {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); center: [51.1657, 10.4515], zoom: 6,
zoomControl: true, attributionControl: false,
});
} }
// Cluster-Gruppe aufräumen und neu befüllen // 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 typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const d = new Date(ev.datum + 'T00:00:00'); const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' }); const datum = d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
// Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.leafletMarker() nicht anwendbar // Events nutzen rotierten Diamant-Marker (nicht Kreis) — UI.map.svgMarker mit custom HTML
const icon = L.divIcon({ 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>`;
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],
});
const popup = ` const popup = `
<div style="min-width:180px"> <div style="min-width:180px">
<strong>${UI.escape(ev.titel)}</strong><br> <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> style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
</div> </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); _clusterGroup.addLayer(m);
_markers.push(m); _markers.push(m);
bounds.push([ev.lat, ev.lon]); bounds.push([ev.lat, ev.lon]);
@ -496,7 +494,7 @@ window.Page_events = (() => {
<label class="form-label">GPS-Position</label> <label class="form-label">GPS-Position</label>
<div id="ev-location-picker"></div> <div id="ev-location-picker"></div>
</div> </div>
<div class="form-group" style="margin-top:var(--space-3)"> <div class="form-group mt-3">
<label class="form-label">Beschreibung</label> <label class="form-label">Beschreibung</label>
<textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea> <textarea class="form-control" name="beschreibung" rows="3">${ev?.beschreibung || ''}</textarea>
</div> </div>
@ -509,10 +507,10 @@ window.Page_events = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <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'} ${isEdit ? 'Speichern' : 'Event erstellen'}
</button> </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>` : ''} ${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> <button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div> </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; <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"> 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)"> <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> <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"> <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> <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 ''; if (dogs.length < 2) return '';
const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => ` const pills = [{ id: null, name: 'Alle' }, ...dogs].map(d => `
<button class="exp-dog-pill${_selectedDogId === d.id ? ' active' : ''}" data-dog="${d.id ?? ''}"> <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(''); </button>`).join('');
return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`; return `<div class="exp-dog-selector" id="exp-dog-selector">${pills}</div>`;
} }
@ -87,7 +87,7 @@ window.Page_expenses = (() => {
</div> </div>
${_dogSelectorHtml()} ${_dogSelectorHtml()}
<div id="exp-content"></div> <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')} ${UI.icon('plus')}
</button> </button>
`; `;
@ -162,7 +162,7 @@ window.Page_expenses = (() => {
<div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}"> <div class="exp-kachel-icon" style="background:${k.color}20;color:${k.color}">
${UI.icon(k.icon)} ${UI.icon(k.icon)}
</div> </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> <div class="exp-kachel-label">${k.label}</div>
${monatLine} ${monatLine}
<div class="exp-kachel-add">${UI.icon('plus')} eintragen</div> <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') const datum = new Date(e.datum + 'T00:00:00')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
const dogBadge = e.dog_name 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 const notiz = e.notiz
? `<span class="exp-entry-notiz">${_esc(e.notiz)}</span>` ? `<div class="list-item-text">${UI.escape(e.notiz)}</div>`
: ''; : '';
return ` return `
<div class="exp-entry" data-id="${e.id}"> <div class="list-item-card list-item-card--clickable exp-entry" data-id="${e.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}"> <div class="list-item-meta-badge" style="--meta-color:${k.color}">
${UI.icon(k.icon)} ${UI.icon(k.icon)}
</div> </div>
<div class="exp-entry-body"> <div class="list-item-body">
<div class="exp-entry-head"> <div class="list-item-title">${k.label}</div>
<span class="exp-entry-datum">${datum}</span>
<span class="exp-entry-kat">${k.label}</span>
${dogBadge}
</div>
${notiz} ${notiz}
<div class="list-item-meta-row">
<span>${datum}</span>
${dogBadge ? `· ${dogBadge}` : ''}
</div> </div>
<div class="exp-entry-right"> </div>
<div class="exp-entry-betrag">${_fmt(e.betrag)}</div> <div class="list-item-amount list-item-amount--negative">${_fmt(e.betrag)}</div>
<button class="exp-entry-del" data-del="${e.id}" title="Löschen" <div class="list-item-actions">
aria-label="Eintrag löschen"> <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')} ${UI.icon('trash')}
</button> </button>
</div> </div>
@ -313,15 +313,15 @@ window.Page_expenses = (() => {
return ` return `
<div class="exp-month-group"> <div class="exp-month-group">
<div class="exp-month-header"> <div class="list-group-header" style="display:flex;justify-content:space-between;align-items:baseline">
<span class="exp-month-title">${titel}</span> <span>${titel}</span>
<span class="exp-month-summe">${_fmt(summe)}</span> <span style="text-transform:none;font-weight:700;color:var(--c-text)">${_fmt(summe)}</span>
</div> </div>
${rows} ${rows}
</div>`; </div>`;
}).join(''); }).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) // Klick auf Zeile → Bearbeiten (nur wenn nicht Löschen-Button)
el.querySelectorAll('.exp-entry').forEach(row => { el.querySelectorAll('.exp-entry').forEach(row => {
@ -372,31 +372,27 @@ window.Page_expenses = (() => {
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) .toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '—'; : '—';
return ` return `
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}"> <div class="list-item-card${r.aktiv ? '' : ' list-item-card--inactive'}" data-rid="${r.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div> <div class="list-item-meta-badge" style="--meta-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="exp-entry-body"> <div class="list-item-body">
<div class="exp-entry-head"> <div class="list-item-title">${k.label}</div>
<span class="exp-entry-kat">${k.label}</span> ${r.notiz ? `<div class="list-item-text">${UI.escape(r.notiz)}</div>` : ''}
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span> <div class="list-item-meta-row">
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''} <span>${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
</div> · <span>${UI.icon('calendar')} ${naechste}</span>
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''} ${r.dog_name ? `· <span>${UI.icon('paw-print')} ${UI.escape(r.dog_name)}</span>` : ''}
<div class="exp-recurring-next"> ${!r.aktiv ? '· <span>Pausiert</span>' : ''}
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
</div> </div>
</div> </div>
<div class="exp-entry-right"> <div class="list-item-amount list-item-amount--negative">${_fmt(r.betrag)}</div>
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div> <div class="list-item-actions">
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)"> <button class="list-item-action-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}"> title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(r.aktiv ? 'pause' : 'play')} ${UI.icon(r.aktiv ? 'pause' : 'play')}
</button> </button>
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}" <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> title="Löschen">${UI.icon('trash')}</button>
</div> </div>
</div>
</div>`; </div>`;
}).join(''); }).join('');
@ -407,7 +403,7 @@ window.Page_expenses = (() => {
</button> </button>
</div> </div>
${recurring.length ${recurring.length
? `<div class="exp-list">${cards}</div>` ? `<div class="list-shell">${cards}</div>`
: UI.emptyState({ icon: UI.icon('arrows-clockwise'), : UI.emptyState({ icon: UI.icon('arrows-clockwise'),
title: 'Keine Daueraufträge', title: 'Keine Daueraufträge',
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })} 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(''); ].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
const dogOptions = (_appState.dogs || []).map(d => 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(''); ).join('');
const body = ` const body = `
@ -458,9 +454,8 @@ window.Page_expenses = (() => {
<select class="form-control" name="kategorie">${katOptions}</select> <select class="form-control" name="kategorie">${katOptions}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Betrag ()</label> <label class="form-label">Betrag</label>
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01" ${UI.moneyInput({ name: 'betrag', value: r?.betrag ?? '', required: true })}
value="${r?.betrag || ''}" placeholder="0,00" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Häufigkeit</label> <label class="form-label">Häufigkeit</label>
@ -477,15 +472,15 @@ window.Page_expenses = (() => {
</div> </div>
${dogOptions ? ` ${dogOptions ? `
<div class="form-group"> <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"> <select class="form-control" name="dog_id">
<option value="">Kein Hund</option>${dogOptions} <option value="">Kein Hund</option>${dogOptions}
</select> </select>
</div>` : ''} </div>` : ''}
<div class="form-group"> <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" <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> </div>
</form>`; </form>`;
@ -501,7 +496,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(e.target); const fd = UI.formData(e.target);
const payload = { const payload = {
kategorie: fd.kategorie, kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag), betrag: UI.parseMoney(fd.betrag),
haeufigkeit: fd.haeufigkeit, haeufigkeit: fd.haeufigkeit,
startdatum: fd.startdatum, startdatum: fd.startdatum,
notiz: fd.notiz || null, notiz: fd.notiz || null,
@ -688,13 +683,13 @@ window.Page_expenses = (() => {
const defaultDogId = entry?.dog_id ?? _selectedDogId; const defaultDogId = entry?.dog_id ?? _selectedDogId;
const dogOptions = (_appState.dogs || []).map(d => 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(''); ).join('');
// Kategorie-Kacheln statt Dropdown // Kategorie-Kacheln statt Dropdown
const katKacheln = KATEGORIEN.map(k => ` const katKacheln = KATEGORIEN.map(k => `
<label class="exp-kat-tile${selKat === k.id ? ' exp-kat-tile--sel' : ''}" data-kat="${k.id}"> <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-icon" style="color:${k.color}">${UI.icon(k.icon)}</span>
<span class="exp-kat-tile-label">${k.label}</span> <span class="exp-kat-tile-label">${k.label}</span>
</label>`).join(''); </label>`).join('');
@ -707,15 +702,10 @@ window.Page_expenses = (() => {
<div class="exp-kat-grid">${katKacheln}</div> <div class="exp-kat-grid">${katKacheln}</div>
</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"> <div class="form-group" style="margin-bottom:0">
<label class="form-label">Betrag</label> <label class="form-label">Betrag</label>
<div class="exp-betrag-wrap"> ${UI.moneyInput({ name: 'betrag', value: entry?.betrag ?? '', required: true })}
<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>
</div> </div>
<div class="form-group" style="margin-bottom:0"> <div class="form-group" style="margin-bottom:0">
<label class="form-label">Datum</label> <label class="form-label">Datum</label>
@ -735,7 +725,7 @@ window.Page_expenses = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label> <label class="form-label">Notiz <span class="form-label-hint">(optional)</span></label>
<input type="text" name="notiz" class="form-control" <input type="text" name="notiz" class="form-control"
value="${_esc(entry?.notiz || '')}" value="${UI.escape(entry?.notiz || '')}"
placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …"> placeholder="z.B. Hundesteuer 2026, Allianz Haftpflicht …">
</div> </div>
@ -810,7 +800,7 @@ window.Page_expenses = (() => {
const fd = UI.formData(ev.target); const fd = UI.formData(ev.target);
const payload = { const payload = {
kategorie: fd.kategorie, kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag), betrag: UI.parseMoney(fd.betrag),
datum: fd.datum, datum: fd.datum,
notiz: fd.notiz || null, notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null, dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
@ -862,14 +852,5 @@ window.Page_expenses = (() => {
return Math.round(val) + ' €'; 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 }; return { init, refresh };
})(); })();

View file

@ -39,11 +39,6 @@ window.Page_forum = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Helpers // 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 '—'; if (!iso) return '—';
const d = new Date(iso); const d = new Date(iso);
@ -99,7 +94,7 @@ window.Page_forum = (() => {
<h2 class="forum-header-title">Forum</h2> <h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions"> <div class="forum-header-actions">
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''} ${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> <button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
</div> </div>
</div> </div>
@ -108,7 +103,7 @@ window.Page_forum = (() => {
<div class="forum-category-tabs by-tabs" id="forum-tabs"> <div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => ` ${KATEGORIEN.map(k => `
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}" <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('')} `).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}" <button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button> 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)); .format(new Date(+year, +month - 1, 1));
const top = data.top?.[0]; const top = data.top?.[0];
const winnerLine = top const winnerLine = top
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}` ? `🥇 ${UI.escape(top.name)}${top.rasse ? ` · ${UI.escape(top.rasse)}` : ''}`
: 'Noch keine Stimmen'; : 'Noch keine Stimmen';
const metaLine = top const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}` ? `${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" id="forum-hdm-tile">
<div class="forum-hdm-tile-trophy">🏆</div> <div class="forum-hdm-tile-trophy">🏆</div>
<div class="forum-hdm-tile-body"> <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-winner">${winnerLine}</div>
<div class="forum-hdm-tile-meta">${metaLine}</div> <div class="forum-hdm-tile-meta">${metaLine}</div>
</div> </div>
@ -251,16 +246,16 @@ window.Page_forum = (() => {
? data.top.slice(0, 5).map((dog, i) => { ? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i]; const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
const av = dog.foto_url const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">` ? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return ` return `
<div class="hdm-top-entry"> <div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span> <span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div> <div class="hdm-top-av">${av}</div>
<div class="hdm-top-info"> <div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div> <div class="hdm-top-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''} ${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div> </div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div> <div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
@ -280,7 +275,7 @@ window.Page_forum = (() => {
<div class="hdm-kandidaten-search"> <div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control" <input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off" placeholder="Name oder Rasse suchen …" autocomplete="off"
style="font-size:var(--text-sm)"> class="text-sm">
</div> </div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid"> <div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)} ${UI.skeleton(3)}
@ -291,7 +286,7 @@ window.Page_forum = (() => {
<div class="hdm-header"> <div class="hdm-header">
<div class="hdm-trophy">🏆</div> <div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2> <h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div> <div class="hdm-monat">${UI.escape(monthName)}</div>
</div> </div>
${voteHint} ${voteHint}
<div class="hdm-section"> <div class="hdm-section">
@ -320,16 +315,16 @@ window.Page_forum = (() => {
grid.innerHTML = list.map(dog => { grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id; const isVoted = data.user_vote === dog.id;
const av = dog.foto_url const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">` ? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return ` return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}"> <div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div> <div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div> <div class="hdm-vote-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="hdm-vote-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''} ${vorname ? `<div class="hdm-vote-besitzer text-xs-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>` : ''} ${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" <button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}> data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'} ${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
@ -411,8 +406,8 @@ window.Page_forum = (() => {
el.innerHTML = ` el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> <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> <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> <p class="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"> <button class="btn btn-primary mt-4" id="forum-first-btn">
Ersten Beitrag erstellen Ersten Beitrag erstellen
</button> </button>
</div>`; </div>`;
@ -443,31 +438,31 @@ window.Page_forum = (() => {
function _threadCardHTML(t) { function _threadCardHTML(t) {
const preview = t.text_preview 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 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 lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview const fotoHtml = t.foto_preview
? /\.(mp4|mov|webm|m4v|avi)$/i.test(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>` ? `<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)}" : `<img class="forum-card-thumb" src="${UI.escape(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${_esc(t.foto_preview_url)} 800w" sizes="120px"` : ''} ${(t.foto_preview_url && t.foto_preview) ? `srcset="${UI.escape(t.foto_preview_url)} 800w" sizes="120px"` : ''}
alt="" loading="lazy" alt="" loading="lazy"
onerror="this.src='${_esc(t.foto_preview)}'">` onerror="this.src='${UI.escape(t.foto_preview)}'">`
: ''; : '';
return ` return `
<div class="forum-thread-card" data-id="${t.id}"> <div class="forum-thread-card" data-id="${t.id}">
<div class="forum-card-top"> <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} ${pinBadge}${lockBadge}
</div> </div>
<div class="forum-card-content"> <div class="forum-card-content">
<div class="forum-card-main"> <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>` : ''} ${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
<div class="forum-card-meta"> <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('calendar-dots')} ${_fmtDate(t.created_at)}</span>
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</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> <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 = ` document.getElementById('forum-main').innerHTML = `
<div style="text-align:center;padding:var(--space-8)"> <div style="text-align:center;padding:var(--space-8)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div> <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>`; </div>`;
return; return;
} }
@ -533,19 +528,19 @@ window.Page_forum = (() => {
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}"> <button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'} ${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</button> </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>` : ''; </div>` : '';
const _forumMediaHtml = (u) => { const _forumMediaHtml = (u) => {
if (u.endsWith('.pdf')) if (u.endsWith('.pdf'))
return `<a href="${_esc(u)}" target="_blank" rel="noopener" class="forum-pdf-card"> return `<a href="${UI.escape(u)}" target="_blank" rel="noopener" class="forum-pdf-card">
${UI.icon('file-text')} <span>${_esc(u.split('/').pop())}</span></a>`; ${UI.icon('file-text')} <span>${UI.escape(u.split('/').pop())}</span></a>`;
if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) { if (/\.(mp4|mov|webm|m4v|avi)$/i.test(u)) {
const poster = u.replace(/\.[^.]+$/, '_thumb.jpg'); 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>`; 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) const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>` ? `<div class="forum-foto-grid">${thread.foto_urls.map(_forumMediaHtml).join('')}</div>`
@ -565,7 +560,7 @@ window.Page_forum = (() => {
<div class="forum-reply-actions"> <div class="forum-reply-actions">
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen"> <label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
${UI.icon('camera')} ${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> </label>
<div id="forum-reply-previews" class="forum-upload-previews"></div> <div id="forum-reply-previews" class="forum-upload-previews"></div>
</div> </div>
@ -578,20 +573,20 @@ window.Page_forum = (() => {
<div class="forum-thread-detail"> <div class="forum-thread-detail">
${modToolbar} ${modToolbar}
<div class="forum-thread-header-row"> <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> <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_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''} ${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
</div> </div>
<div class="forum-thread-body"> <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} ${fotoGallery}
</div> </div>
<div class="forum-thread-author-row"> <div class="forum-thread-author-row">
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div> <div class="forum-avatar">${UI.escape(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span> <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>` : ''} ${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"> <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}"> <button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
@ -623,7 +618,7 @@ window.Page_forum = (() => {
</div> </div>
` : `<button type="button" class="btn btn-primary w-full" id="ft-close">Schließen</button>`; ` : `<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 // Close
document.getElementById('ft-close')?.addEventListener('click', UI.modal.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 isOwn = uid && uid === p.user_id;
const fotoHtml = (p.foto_urls?.length) const fotoHtml = (p.foto_urls?.length)
? `<div class="forum-foto-grid">${p.foto_urls.map(u => ? `<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>` ).join('')}</div>`
: ''; : '';
@ -788,13 +783,13 @@ window.Page_forum = (() => {
return ` return `
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}"> <div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
<div class="forum-post-header"> <div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div> <div class="forum-avatar forum-avatar--sm">${UI.escape(_initial(p.autor_name))}</div>
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span> <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>` : ''} ${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> <span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div> </div>
<div class="forum-post-body"> <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} ${fotoHtml}
</div> </div>
<div class="forum-post-actions"> <div class="forum-post-actions">
@ -803,7 +798,7 @@ window.Page_forum = (() => {
</button> </button>
${(!isOwn && uid) ? `<button class="forum-icon-btn forum-post-report" data-post-id="${p.id}" title="Melden">${UI.icon('flag')}</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"> <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>` : ''} ${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>
</div> </div>
@ -862,7 +857,7 @@ window.Page_forum = (() => {
try { try {
await API.forum.deletePost(postId); await API.forum.deletePost(postId);
if (postEl) { 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'; postEl.className = 'forum-post forum-post--deleted';
} }
const idx = _threads.findIndex(t => t.id === threadId); const idx = _threads.findIndex(t => t.id === threadId);
@ -991,7 +986,7 @@ window.Page_forum = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
function _showCreateForm() { function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k => 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(''); ).join('');
const body = ` const body = `
@ -1011,16 +1006,16 @@ window.Page_forum = (() => {
placeholder="Beschreibe dein Thema ausführlich…" required></textarea> placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div> </div>
<div class="form-group"> <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 id="forum-location-picker"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Fotos / Dateien (max. 5)</label> <label class="form-label">Fotos / Dateien (max. 5)</label>
<div class="forum-upload-area"> <div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('image')} Fotos / Video / PDF</label> <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>
<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> </div>
</form> </form>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)"> <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; background:var(--c-primary);color:#fff;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35); 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], iconSize: [32, 32], iconAnchor: [16, 16],
}); });
_clusterGroup.addLayer( _clusterGroup.addLayer(
L.marker([m.lat, m.lon], { icon }) L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`) .bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`)
); );
}); });
_map.addLayer(_clusterGroup); _map.addLayer(_clusterGroup);
@ -1295,14 +1290,14 @@ window.Page_forum = (() => {
? `<div class="forum-mod-reports"> ? `<div class="forum-mod-reports">
${reports.map(r => ` ${reports.map(r => `
<div class="forum-mod-report-item" data-id="${r.id}"> <div class="forum-mod-report-item" data-id="${r.id}">
<div style="font-size:var(--text-sm)"> <div class="text-sm">
<strong>${_esc(r.target_type)} #${r.target_id}</strong> <strong>${UI.escape(r.target_type)} #${r.target_id}</strong>
${_esc(r.grund)} ${UI.escape(r.grund)}
</div> </div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)"> <div class="text-xs-muted">
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)} von ${UI.escape(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</div> </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 ${UI.icon('check')} Erledigt
</button> </button>
</div>`).join('')} </div>`).join('')}
@ -1334,7 +1329,7 @@ window.Page_forum = (() => {
title: 'Antwort bearbeiten', title: 'Antwort bearbeiten',
body: `<form id="${id}"> body: `<form id="${id}">
<div class="form-group"> <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> </div>
</form>`, </form>`,
footer: ` footer: `
@ -1373,14 +1368,14 @@ window.Page_forum = (() => {
body: `<form id="${id}"> body: `<form id="${id}">
<div class="form-group"> <div class="form-group">
<label class="form-label">Titel</label> <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Text</label> <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>
<div class="form-group"> <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 id="forum-edit-location-picker"></div>
</div> </div>
</form>`, </form>`,
@ -1426,7 +1421,7 @@ window.Page_forum = (() => {
const lb = document.createElement('div'); 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.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"> 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()); lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb); document.body.appendChild(lb);
} }

View file

@ -51,17 +51,17 @@ window.Page_friends = (() => {
<div> <div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">Dein Freundes-Link</div> 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. Teile ihn der andere tippt drauf und findet dich sofort.
</div> </div>
</div> </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); <div style="flex:1;padding:var(--space-2) var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md); background:var(--c-surface-2);border-radius:var(--radius-md);
font-size:var(--text-xs);color:var(--c-text-secondary); font-size:var(--text-xs);color:var(--c-text-secondary);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
banyaro.app/#friends?suche=${_esc(encodeURIComponent(myName))} banyaro.app/#friends?suche=${UI.escape(encodeURIComponent(myName))}
</div> </div>
<button class="btn btn-ghost btn-sm" id="fr-copy-btn" title="Link kopieren"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#link"></use></svg>
@ -82,7 +82,7 @@ window.Page_friends = (() => {
</svg> </svg>
<input id="fr-search" type="search" autocomplete="off" <input id="fr-search" type="search" autocomplete="off"
placeholder="Namen eines Hundebesitzers suchen…" placeholder="Namen eines Hundebesitzers suchen…"
value="${_esc(prefill || '')}" value="${UI.escape(prefill || '')}"
style="width:100%;box-sizing:border-box; style="width:100%;box-sizing:border-box;
padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem; padding:var(--space-3) var(--space-3) var(--space-3) 2.5rem;
border:1.5px solid var(--c-border);border-radius:var(--radius-lg); border:1.5px solid var(--c-border);border-radius:var(--radius-lg);
@ -278,17 +278,19 @@ window.Page_friends = (() => {
const text = item.text || ''; const text = item.text || '';
const page = _ACTIVITY_PAGE[item.type] || ''; const page = _ACTIVITY_PAGE[item.type] || '';
const dogLabel = item.dog_name 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 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">` class="fr-activity-avatar">`
: item.avatar_url : 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">` class="fr-activity-avatar">`
: `<div class="fr-activity-avatar fr-activity-avatar--initial"> : `<div class="fr-activity-avatar fr-activity-avatar--initial">
${_esc((item.user_name || '?')[0].toUpperCase())} ${UI.escape((item.user_name || '?')[0].toUpperCase())}
</div>`; </div>`;
const tag = page ? `button type="button"` : `div`; const tag = page ? `button type="button"` : `div`;
@ -301,17 +303,17 @@ window.Page_friends = (() => {
${avatar} ${avatar}
<div class="fr-activity-icon-badge"> <div class="fr-activity-icon-badge">
<svg class="ph-icon" style="width:10px;height:10px" aria-hidden="true"> <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> </svg>
</div> </div>
</div> </div>
<div class="fr-activity-body"> <div class="fr-activity-body">
<div class="fr-activity-meta"> <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} ${dogLabel}
</div> </div>
${text ? `<div class="fr-activity-text">${_esc(text)}</div>` : ''} ${text ? `<div class="fr-activity-text">${UI.escape(text)}</div>` : ''}
<div class="fr-activity-time">${_esc(ago)}</div> <div class="fr-activity-time">${UI.escape(ago)}</div>
</div> </div>
</${page ? 'button' : 'div'}> </${page ? 'button' : 'div'}>
`; `;
@ -351,7 +353,7 @@ window.Page_friends = (() => {
<div style="flex:1;min-width:120px"> <div style="flex:1;min-width:120px">
<div style="font-weight:var(--weight-semibold);color:var(--c-text); <div style="font-weight:var(--weight-semibold);color:var(--c-text);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(r.requester_name)} ${UI.escape(r.requester_name)}
</div> </div>
${_dogPills(r.dogs, 2)} ${_dogPills(r.dogs, 2)}
</div> </div>
@ -390,12 +392,12 @@ window.Page_friends = (() => {
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-text-secondary); font-weight:var(--weight-bold);color:var(--c-text-secondary);
font-size:var(--text-sm);flex-shrink:0"> font-size:var(--text-sm);flex-shrink:0">
${_esc((r.addressee_name || '?')[0].toUpperCase())} ${UI.escape((r.addressee_name || '?')[0].toUpperCase())}
</div> </div>
<div style="flex:1"> <div class="flex-1">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${_esc(r.addressee_name)}</div> color:var(--c-text)">${UI.escape(r.addressee_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Anfrage ausstehend</div> <div class="text-xs-muted">Anfrage ausstehend</div>
</div> </div>
<button class="btn btn-ghost btn-sm" <button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen"> 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; <div class="card fr-card" style="padding:var(--space-4);margin-bottom:var(--space-3);cursor:pointer;
transition:box-shadow 0.15s" transition:box-shadow 0.15s"
data-friend-id="${f.friend_id}" data-friend-id="${f.friend_id}"
data-friend-name="${_esc(f.friend_name)}" data-friend-name="${UI.escape(f.friend_name)}"
data-dogs="${_esc(JSON.stringify(dogs))}" data-dogs="${UI.escape(JSON.stringify(dogs))}"
data-profile="${_esc(JSON.stringify(profile))}"> data-profile="${UI.escape(JSON.stringify(profile))}">
<div style="display:flex;align-items:center;gap:var(--space-3)"> <div style="display:flex;align-items:center;gap:var(--space-3)">
<!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) --> <!-- Avatar (User-Avatar, erstes Hunde-Foto oder Initiale) -->
${_userAvatar(f.friend_name, dogs[0], f.avatar_url)} ${_userAvatar(f.friend_name, dogs[0], f.avatar_url)}
<!-- Name + Infos + Hunde --> <!-- 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; <div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px;
margin-bottom:var(--space-1)"> margin-bottom:var(--space-1)">
<span style="font-weight:var(--weight-semibold);color:var(--c-text)"> <span style="font-weight:var(--weight-semibold);color:var(--c-text)">
${_esc(f.friend_name)} ${UI.escape(f.friend_name)}
</span> </span>
${_erfahrungSpan(f.erfahrung)} ${_erfahrungSpan(f.erfahrung)}
</div> </div>
@ -495,7 +497,7 @@ window.Page_friends = (() => {
? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center"> ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);align-items:center">
${_dogPills(dogs, 3)} ${_dogPills(dogs, 3)}
</div>` </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>
</div> </div>
@ -504,7 +506,7 @@ window.Page_friends = (() => {
<div style="display:flex;gap:var(--space-1);flex-shrink:0"> <div style="display:flex;gap:var(--space-1);flex-shrink:0">
<button class="btn btn-ghost btn-sm fr-note-btn" <button class="btn btn-ghost btn-sm fr-note-btn"
data-fr-note-id="${f.friend_id}" 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" title="Notiz"
onclick="event.stopPropagation()"> onclick="event.stopPropagation()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg> <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); <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)"> padding-top:var(--space-3);border-top:1px solid var(--c-border)">
${withPhotos.slice(0, 4).map(d => ` ${withPhotos.slice(0, 4).map(d => `
<div style="text-align:center"> <div class="text-center">
<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:44px;height:44px;border-radius:50%;object-fit:cover; style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-surface)"> border:2px solid var(--c-surface)">
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px; <div style="font-size:10px;color:var(--c-text-muted);margin-top:2px;
max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> max-width:44px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${_esc(d.name)} ${UI.escape(d.name)}
</div> </div>
</div> </div>
`).join('')} `).join('')}
@ -558,9 +561,10 @@ window.Page_friends = (() => {
? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr)); ? `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));
gap:var(--space-3);margin-top:var(--space-4)"> gap:var(--space-3);margin-top:var(--space-4)">
${dogs.map(d => ` ${dogs.map(d => `
<div style="text-align:center"> <div class="text-center">
${d.foto_url ${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; style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);margin-bottom:var(--space-2)">` border:2px solid var(--c-primary);margin-bottom:var(--space-2)">`
: `<div style="width:72px;height:72px;border-radius:50%; : `<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>` font-size:1.75rem;margin:0 auto var(--space-2)">🐕</div>`
} }
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <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 ${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> </div>
`).join('')} `).join('')}
@ -585,24 +589,24 @@ window.Page_friends = (() => {
if (profile.wohnort) { if (profile.wohnort) {
parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2); parts.push(`<div style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-sm);color:var(--c-text-secondary)"> font-size:var(--text-sm);color:var(--c-text-secondary)">
📍 ${_esc(profile.wohnort)} 📍 ${UI.escape(profile.wohnort)}
</div>`); </div>`);
} }
if (profile.erfahrung && _erfahrungBadge[profile.erfahrung]) { 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]} ${_erfahrungBadge[profile.erfahrung]}
</div>`); </div>`);
} }
if (profile.bio && profile.profil_sichtbarkeit !== 'private') { if (profile.bio && profile.profil_sichtbarkeit !== 'private') {
parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text); parts.push(`<div style="font-size:var(--text-sm);color:var(--c-text);
line-height:1.5;padding-top:var(--space-2)"> line-height:1.5;padding-top:var(--space-2)">
${_esc(profile.bio)} ${UI.escape(profile.bio)}
</div>`); </div>`);
} }
if (profile.social_link) { if (profile.social_link) {
parts.push(`<div style="font-size:var(--text-xs);word-break:break-all"> parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer" <a href="${UI.escape(profile.social_link)}" target="_blank" rel="noopener noreferrer"
style="color:var(--c-primary)">${_esc(profile.social_link)}</a> class="text-primary">${UI.escape(profile.social_link)}</a>
</div>`); </div>`);
} }
if (!parts.length) return ''; if (!parts.length) return '';
@ -623,7 +627,7 @@ window.Page_friends = (() => {
</div>` : ''; </div>` : '';
UI.modal.open({ UI.modal.open({
title: _esc(friendName), title: UI.escape(friendName),
body: ` body: `
<div> <div>
${badgesHTML} ${badgesHTML}
@ -638,7 +642,7 @@ window.Page_friends = (() => {
Nachricht schreiben Nachricht schreiben
</button> </button>
<button class="btn btn-ghost" id="modal-remove-btn" form="" <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user-minus"></use></svg>
Entfernen Entfernen
</button> </button>
@ -679,11 +683,11 @@ window.Page_friends = (() => {
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}"> ${i < results.length - 1 ? 'border-bottom:1px solid var(--c-border)' : ''}">
${_userAvatar(u.name, null, u.avatar_url)} ${_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; <div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px;
margin-bottom:2px"> margin-bottom:2px">
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <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_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>` : ''} ${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)} ${_erfahrungSpan(u.erfahrung)}
@ -693,12 +697,12 @@ window.Page_friends = (() => {
${u.dogs?.length ${u.dogs?.length
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:2px"> 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>`
: ''} : ''}
</div> </div>
<button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden" <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> <svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
</button> </button>
</div> </div>
@ -782,12 +786,14 @@ window.Page_friends = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
function _userAvatar(name, firstDog, avatarUrl) { function _userAvatar(name, firstDog, avatarUrl) {
if (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; style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`; border:2px solid var(--c-primary);flex-shrink:0">`;
} }
if (firstDog?.foto_url) { 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; style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`; border:2px solid var(--c-primary);flex-shrink:0">`;
} }
@ -797,7 +803,7 @@ window.Page_friends = (() => {
border:2px solid var(--c-primary); border:2px solid var(--c-primary);
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-weight:var(--weight-bold);color:var(--c-primary)"> font-weight:var(--weight-bold);color:var(--c-primary)">
${_esc((name || '?')[0].toUpperCase())} ${UI.escape((name || '?')[0].toUpperCase())}
</div>`; </div>`;
} }
@ -817,7 +823,7 @@ window.Page_friends = (() => {
function _wohnortLine(wohnort) { function _wohnortLine(wohnort) {
if (!wohnort) return ''; 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) { function _bioLine(bio, sichtbarkeit) {
@ -826,7 +832,7 @@ window.Page_friends = (() => {
return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary); return `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-top:var(--space-1);line-height:1.4; margin-top:var(--space-1);line-height:1.4;
overflow:hidden;display:-webkit-box;-webkit-line-clamp:2; 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) { function _dogPills(dogs, max) {
@ -838,7 +844,7 @@ window.Page_friends = (() => {
${visible.map(d => ` ${visible.map(d => `
<span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full); <span style="font-size:10px;padding:1px 6px;border-radius:var(--radius-full);
background:var(--c-surface-2);color:var(--c-text-secondary)"> 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> </span>
`).join('')} `).join('')}
${rest > 0 ? `<span style="font-size:10px;color:var(--c-text-muted)">+${rest}</span>` : ''} ${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 = '') { function _emptyState(icon, title, text, cta = '') {
return `<div class="empty-state"> return `<div class="empty-state">
<svg class="ph-icon empty-state-icon" aria-hidden="true"> <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"> display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div> <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-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> </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> <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> </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)"> <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? Unsere Partner treten gegeneinander an wer bringt die meisten Gründer?
</p> </p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div class="flex-col-gap-2">
${d.partners.map((p, i) => { ${d.partners.map((p, i) => {
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `#${i+1}`; 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; 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); padding:var(--space-3);border-radius:var(--radius-md);
background:${i === 0 ? 'linear-gradient(135deg,#fef9c3,#fef3c7)' : 'var(--c-surface-2)'}"> 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="font-size:22px;min-width:32px;text-align:center">${medal}</div>
<div style="flex:1;min-width:0"> <div class="flex-1-min">
<div style="font-weight:700;font-size:var(--text-sm)">${_esc(p.label)}</div> <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); <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"> height:6px;margin-top:var(--space-1);overflow:hidden">
<div style="background:#7c3aed;width:${barPct}%;height:100%; <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)"> 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-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"> <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> </span>
</div> </div>
`).join('')} `).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"> <span style="font-size:var(--text-xs);font-weight:800;color:var(--c-text-muted);min-width:28px">
#${d.total + i + 1} #${d.total + i + 1}
</span> </span>
<span style="font-size:var(--text-sm);color:var(--c-text-muted)">frei</span> <span class="text-sm-muted">frei</span>
</div> </div>
`).join('')} `).join('')}
</div> </div>
</div>` : ` </div>` : `
<div class="by-card" style="padding:var(--space-6);text-align:center"> <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! Noch keine Gründer sei der Erste!
</p> </p>
</div>`} </div>`}
`; `;
} }
function _esc(s) {
return String(s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
return { init, refresh, onDogChange }; 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>
<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0"> <p style="font-size:var(--text-sm);color:var(--c-text-muted);margin:0">
${_search ${_search
? `Zu "${_esc(_search)}" wurde nichts gefunden.` ? `Zu "${UI.escape(_search)}" wurde nichts gefunden.`
: 'Noch keine FAQ-Artikel vorhanden.'} : 'Noch keine FAQ-Artikel vorhanden.'}
</p> </p>
</div> </div>
@ -136,7 +136,7 @@ window.Page_hilfe = (() => {
color:var(--c-text-secondary);text-transform:uppercase; color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2); letter-spacing:0.08em;padding:var(--space-1) 0 var(--space-2);
margin-bottom:var(--space-1)"> margin-bottom:var(--space-1)">
${_esc(label)} ${UI.escape(label)}
</div> </div>
<div style="display:flex;flex-direction:column;gap:var(--space-1)"> <div style="display:flex;flex-direction:column;gap:var(--space-1)">
`; `;
@ -148,12 +148,12 @@ window.Page_hilfe = (() => {
// Highlight Suchtreffer in der Frage // Highlight Suchtreffer in der Frage
const frageHtml = _search const frageHtml = _search
? _highlight(a.frage, _search) ? _highlight(a.frage, _search)
: _esc(a.frage); : UI.escape(a.frage);
// Antwort: Zeilenumbrüche in <br> wandeln // Antwort: Zeilenumbrüche in <br> wandeln
const antwortHtml = _search const antwortHtml = _search
? _highlight(a.antwort, _search).replace(/\n/g, '<br>') ? _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 // Bei aktiver Suche: Antwort gleich aufgeklappt
const openByDefault = !!_search; const openByDefault = !!_search;
@ -169,7 +169,7 @@ window.Page_hilfe = (() => {
display:flex;align-items:flex-start;gap:var(--space-2); display:flex;align-items:flex-start;gap:var(--space-2);
font-size:var(--text-sm);font-weight:600; font-size:var(--text-sm);font-weight:600;
color:var(--c-text);line-height:1.4"> 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" <svg id="${chevronId}" class="ph-icon" aria-hidden="true"
style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px; style="width:1rem;height:1rem;flex-shrink:0;margin-top:2px;
color:var(--c-text-muted); 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) { function _highlight(text, term) {
if (!term) return text; if (!term) return text;
const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const safe = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${safe})`, 'gi'); 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>' '<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> 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)"> <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" 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. Oder nutze das Formular wir antworten in der Regel innerhalb von 24 Stunden.
</p> </p>
<form id="contact-form" style="display:flex;flex-direction:column;gap:var(--space-3)"> <form id="contact-form" class="flex-col-gap-3">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div> <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> <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" <input id="cf-name" type="text" required maxlength="100"

View file

@ -7,7 +7,6 @@ window.Page_jobs = (() => {
let _container = null; let _container = null;
let _appState = null; let _appState = null;
const _esc = s => UI.escape(s ?? '');
const _ph = (name, size = 22) => 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>`; `<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> </div>
<!-- Stellenbeschreibung --> <!-- Stellenbeschreibung -->
<div class="card" style="margin-bottom:var(--space-4)"> <div class="card mb-4">
<div style="padding:var(--space-5)"> <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> <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)"> <div style="display:grid;gap:var(--space-3)">
@ -76,7 +75,7 @@ window.Page_jobs = (() => {
</div> </div>
<!-- Wen wir suchen --> <!-- Wen wir suchen -->
<div class="card" style="margin-bottom:var(--space-4)"> <div class="card mb-4">
<div style="padding:var(--space-5)"> <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> <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)"> <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; const s = statusMap[app.status] || statusMap.pending;
return ` return `
<div class="card" style="padding:var(--space-5);text-align:center"> <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> <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>
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</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> </div>
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2); ${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); 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>`; </div>`;
} }
@ -147,13 +146,13 @@ window.Page_jobs = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Dein Name *</label> <label class="form-label">Dein Name *</label>
<input class="form-control" type="text" name="name" <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">E-Mail *</label> <label class="form-label">E-Mail *</label>
<input class="form-control" type="email" name="email" <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>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)"> <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> <label class="form-label">Anhänge (optional)</label>
<input class="form-control" type="file" name="files" id="jobs-files" <input class="form-control" type="file" name="files" id="jobs-files"
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov" 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)"> <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. Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB.
PDF, Bild oder Video. 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); padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4)"> margin-bottom:var(--space-4)">
💡 <b>Tipp:</b> Wenn du dich vorher 💡 <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. bekommst du sofort den 14-tägigen Luna-Probezugang.
</div>` : ''} </div>` : ''}

View file

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

View file

@ -14,7 +14,7 @@ window.Page_laeufi = (() => {
_appState = appState; _appState = appState;
if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) { if (!appState.user || !['breeder','admin'].includes(appState.user.rolle)) {
_container.innerHTML = `<div style="text-align:center;padding:var(--space-10)"> _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; return;
} }
API.breeder.status().then(s => { API.breeder.status().then(s => {
@ -53,7 +53,7 @@ window.Page_laeufi = (() => {
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)"> display:flex;align-items:center;gap:var(--space-3)">
${logoHtml} ${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; <h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden; color:var(--c-text);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;line-height:1.2">${UI.escape(zwinger)}</h2> 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"> <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> <use href="/icons/phosphor.svg#lock-key"></use>
</svg> </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> </div>
</div>`; </div>`;
@ -89,7 +89,7 @@ window.Page_laeufi = (() => {
_renderHundeList(); _renderHundeList();
} catch (err) { } catch (err) {
document.getElementById('laeufi-list').innerHTML = 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}" <div id="laeufi-toggle-${h.id}"
style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3); style="padding:var(--space-4);display:flex;align-items:center;gap:var(--space-3);
cursor:pointer;user-select:none"> 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"> <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> <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>` : ''} ${h.rufname ? `<span class="text-sm-muted">"${UI.escape(h.rufname)}"</span>` : ''}
${alter ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${alter}</span>` : ''} ${alter ? `<span class="text-xs-muted">${alter}</span>` : ''}
</div> </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 ? `<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(' · ')} ${[h.rasse_text, h.farbe].filter(Boolean).map(s => UI.escape(s)).join(' · ')}
</div>` : ''} </div>` : ''}
</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>
<div id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)"> <div id="laeufi-detail-${h.id}" style="display:none;border-top:1px solid var(--c-border)">
<div id="laeufi-content-${h.id}" <div id="laeufi-content-${h.id}"
style="padding:var(--space-4)"> class="p-4">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p> <p class="text-sm-muted">Lädt</p>
</div> </div>
</div> </div>
</div>`; </div>`;
@ -177,7 +177,7 @@ window.Page_laeufi = (() => {
]); ]);
_renderHundContent(el, hundId, laeufiList, deckList); _renderHundContent(el, hundId, laeufiList, deckList);
} catch (err) { } 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 => ` return list.map(l => `
<div style="background:var(--c-surface);border:1px solid var(--c-border);border-radius:var(--radius-md); <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"> 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"> <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> <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> ${l.ende ? `<span class="text-xs-muted">→ ${_fmtDate(l.ende)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''} <span class="text-xs-muted">${_daysDiff(l.beginn, l.ende)} Tage</span>` : ''}
</div> </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>` : ''} ${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> </div>
@ -286,7 +286,7 @@ window.Page_laeufi = (() => {
${UI.icon('pencil-simple')} ${UI.icon('pencil-simple')}
</button> </button>
<button class="btn btn-ghost btn-xs laeufi-delete-btn" data-id="${l.id}" <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')} ${UI.icon('trash')}
</button> </button>
</div> </div>
@ -314,7 +314,7 @@ window.Page_laeufi = (() => {
margin-bottom:var(--space-3);overflow:hidden"> margin-bottom:var(--space-3);overflow:hidden">
<!-- Deck-Header --> <!-- Deck-Header -->
<div style="padding:var(--space-3);display:flex;gap:var(--space-3);align-items:flex-start"> <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)"> <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="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; <span style="background:${tc.color}1a;color:${tc.color};border:1px solid ${tc.color}30;
@ -335,7 +335,7 @@ window.Page_laeufi = (() => {
</div> </div>
<div style="display:flex;gap:var(--space-1);flex-shrink:0"> <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-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>
</div> </div>
<!-- Meilensteine --> <!-- Meilensteine -->
@ -358,7 +358,7 @@ window.Page_laeufi = (() => {
color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px"> color:${m.vorbei ? 'white' : 'var(--c-text-muted)'};font-size:9px">
${m.vorbei ? '✓' : m.tag} ${m.vorbei ? '✓' : m.tag}
</span> </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'}"> <span style="color:${m.vorbei ? 'var(--c-text-muted)' : 'var(--c-text)'};font-weight:${m.vorbei ? '400' : '600'}">
${UI.escape(m.label)} ${UI.escape(m.label)}
</span> </span>
@ -377,8 +377,8 @@ window.Page_laeufi = (() => {
UI.modal.open({ UI.modal.open({
title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen', title: isEdit ? 'Läufigkeit bearbeiten' : 'Läufigkeit eintragen',
body: ` body: `
<form id="laeufi-form" style="display:flex;flex-direction:column;gap:var(--space-3)"> <form id="laeufi-form" class="flex-col-gap-3">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Beginn *</label> <label class="form-label">Beginn *</label>
<input class="form-control" type="date" name="beginn" required value="${v.beginn || today}"> <input class="form-control" type="date" name="beginn" required value="${v.beginn || today}">
@ -421,8 +421,8 @@ window.Page_laeufi = (() => {
UI.modal.open({ UI.modal.open({
title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen', title: isEdit ? 'Deckung bearbeiten' : 'Deckung eintragen',
body: ` body: `
<form id="deck-form" style="display:flex;flex-direction:column;gap:var(--space-3)"> <form id="deck-form" class="flex-col-gap-3">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Deckdatum *</label> <label class="form-label">Deckdatum *</label>
<input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}"> <input class="form-control" type="date" name="deckdatum" required value="${v.deckdatum || today}">
@ -435,7 +435,7 @@ window.Page_laeufi = (() => {
</select> </select>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Rüde</label> <label class="form-label">Rüde</label>
<input class="form-control" name="ruede_name" placeholder="Name des Deckrüden" <input class="form-control" name="ruede_name" placeholder="Name des Deckrüden"
@ -451,7 +451,7 @@ window.Page_laeufi = (() => {
</select> </select>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Trächtigkeitsstatus</label> <label class="form-label">Trächtigkeitsstatus</label>
<select class="form-control" name="traechtig"> <select class="form-control" name="traechtig">
@ -503,7 +503,7 @@ window.Page_laeufi = (() => {
async function _showProgModal(hundId, laeufi) { async function _showProgModal(hundId, laeufi) {
UI.modal.open({ UI.modal.open({
title: `Progesterontests — ${_fmtDate(laeufi.beginn)}`, 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: ` footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button> <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>`, <button class="btn btn-primary" id="prog-add-btn">${UI.icon('plus')} Test eintragen</button>`,
@ -535,7 +535,7 @@ window.Page_laeufi = (() => {
<tbody> <tbody>
${tests.map(t => ` ${tests.map(t => `
<tr style="border-top:1px solid var(--c-border)"> <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"> <td style="text-align:right;padding:var(--space-2);font-weight:600">
${t.wert != null ? `${t.wert} ${UI.escape(t.einheit)}` : '—'} ${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>` : ''} ${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);color:var(--c-text-secondary)">${t.labor ? UI.escape(t.labor) : '—'}</td>
<td style="padding:var(--space-2);text-align:right"> <td style="padding:var(--space-2);text-align:right">
<button class="btn btn-ghost btn-xs prog-delete-btn" data-id="${t.id}" <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> </td>
</tr>`).join('')} </tr>`).join('')}
</tbody> </tbody>
@ -572,8 +572,8 @@ window.Page_laeufi = (() => {
UI.modal.open({ UI.modal.open({
title: 'Progesterontest eintragen', title: 'Progesterontest eintragen',
body: ` body: `
<form id="prog-form" style="display:flex;flex-direction:column;gap:var(--space-3)"> <form id="prog-form" class="flex-col-gap-3">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Datum *</label> <label class="form-label">Datum *</label>
<input class="form-control" type="date" name="datum" required value="${today}"> <input class="form-control" type="date" name="datum" required value="${today}">
@ -586,7 +586,7 @@ window.Page_laeufi = (() => {
</select> </select>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Wert</label> <label class="form-label">Wert</label>
<input class="form-control" type="number" step="0.01" name="wert" placeholder="z.B. 8.5"> <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 ` return `
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> <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> <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> <h3 style="margin:0 0 var(--space-2)">${UI.escape(title)}</h3>
<p style="color:var(--c-text-secondary);margin:0">${_esc(text)}</p> <p style="color:var(--c-text-secondary);margin:0">${UI.escape(text)}</p>
</div>`; </div>`;
} }
function _esc(s) {
return UI.escape ? UI.escape(s || '') : (s || '').replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function _statusBadge(status) { function _statusBadge(status) {
const map = { const map = {
@ -37,7 +33,7 @@ window.Page_litters = (() => {
abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' }, abgeschlossen: { label: 'Abgeschlossen', cls: 'badge-muted' },
}; };
const s = map[status] || { label: status, 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) { function _fmtDate(iso) {
@ -59,7 +55,7 @@ window.Page_litters = (() => {
abgegeben: { label: 'Abgegeben', cls: 'badge-muted' }, abgegeben: { label: 'Abgegeben', cls: 'badge-muted' },
}; };
const s = map[status] || { label: status, 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 zwinger = _breederInfo?.zwingername || 'Mein Zwinger';
const logoUrl = _breederInfo?.logo_url || null; const logoUrl = _breederInfo?.logo_url || null;
const logoHtml = logoUrl 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; style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0" border:2px solid rgba(196,132,58,.5);flex-shrink:0"
onerror="this.style.display='none'">` onerror="this.style.display='none'">`
@ -118,15 +114,15 @@ window.Page_litters = (() => {
padding:var(--space-3) var(--space-4); padding:var(--space-3) var(--space-4);
display:flex;align-items:center;gap:var(--space-3)"> display:flex;align-items:center;gap:var(--space-3)">
${logoHtml} ${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; <h2 style="margin:0 0 2px;font-size:var(--text-lg);font-weight:700;
color:var(--c-text);white-space:nowrap;overflow:hidden; 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)"> <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"> <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> <use href="/icons/phosphor.svg#lock-key"></use>
</svg> </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> </div>
</div>`; </div>`;
@ -232,7 +228,7 @@ window.Page_litters = (() => {
el.innerHTML = ` el.innerHTML = `
<div style="text-align:center;padding:var(--space-8) var(--space-4); <div style="text-align:center;padding:var(--space-8) var(--space-4);
border:1px dashed var(--c-border);border-radius:var(--radius-lg)"> 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>`; </div>`;
return; return;
} }
@ -248,8 +244,8 @@ window.Page_litters = (() => {
el.innerHTML = ` el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)"> <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> <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> <p class="text-secondary">Noch keine Würfe angelegt.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="litters-first-btn"> <button class="btn btn-primary mt-4" id="litters-first-btn">
${UI.icon('plus')} Ersten Wurf anlegen ${UI.icon('plus')} Ersten Wurf anlegen
</button> </button>
</div>`; </div>`;
@ -315,7 +311,7 @@ window.Page_litters = (() => {
function _litterCardHTML(l) { function _litterCardHTML(l) {
const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?'; const verfuegbar = l.welpen_verfuegbar != null ? l.welpen_verfuegbar : '?';
const gesamt = l.welpen_gesamt != null ? l.welpen_gesamt : '?'; 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 // Datum + Countdown
let datumChip = ''; let datumChip = '';
@ -325,10 +321,10 @@ window.Page_litters = (() => {
const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`; const label = l.geburt_datum ? `Geburt ${_fmtDate(l.geburt_datum)}` : `Erwartet ${_fmtDate(l.erwartetes_datum)}`;
let countdownHtml = ''; let countdownHtml = '';
if (days !== null && !l.geburt_datum) { if (days !== null && !l.geburt_datum) {
const c = days < 0 ? `<span style="color:var(--c-danger)">überfällig</span>` const c = days < 0 ? `<span class="text-danger">überfällig</span>`
: days === 0 ? `<span style="color:var(--c-success)">heute!</span>` : days === 0 ? `<span class="text-success">heute!</span>`
: days <= 7 ? `<span style="color:var(--c-warning,#f59e0b)">${days}d</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}`; 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>`; 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 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 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 ` return `
@ -355,11 +351,11 @@ window.Page_litters = (() => {
<div style="min-width:0"> <div style="min-width:0">
${(l.wurf_rang || l.wurf_name) ? ` ${(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)"> <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_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)">${_esc(l.wurf_name)}</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>` : ''}
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-2)"> <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)} ${_statusBadge(l.status)}
${sichtbarChip} ${sichtbarChip}
</div> </div>
@ -390,21 +386,21 @@ window.Page_litters = (() => {
${UI.icon('pencil-simple')} ${UI.icon('pencil-simple')}
</button> </button>
<button class="btn btn-ghost btn-sm litters-delete-btn" data-id="${l.id}" title="Löschen" <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')} ${UI.icon('trash')}
</button> </button>
</div> </div>
</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> </div>
<!-- Welpen-Bereich --> <!-- Welpen-Bereich -->
<div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)"> <div id="puppies-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="puppies-inner-${l.id}"> <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> </div>
<button class="btn btn-secondary btn-sm litters-add-puppy-btn" data-id="${l.id}" <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 ${UI.icon('plus')} Welpen hinzufügen
</button> </button>
</div> </div>
@ -412,10 +408,10 @@ window.Page_litters = (() => {
<!-- Wartelisten-Bereich --> <!-- Wartelisten-Bereich -->
<div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)"> <div id="waitlist-wrap-${l.id}" style="display:none;padding:var(--space-3) var(--space-4)">
<div id="waitlist-inner-${l.id}"> <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> </div>
<button class="btn btn-secondary btn-sm litters-add-waitlist-btn" data-id="${l.id}" <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 ${UI.icon('plus')} Interessent eintragen
</button> </button>
</div> </div>
@ -455,13 +451,13 @@ window.Page_litters = (() => {
const puppies = await API.litters.puppies(litterId); const puppies = await API.litters.puppies(litterId);
_renderPuppies(inner, litterId, puppies); _renderPuppies(inner, litterId, puppies);
} catch (err) { } 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) { function _renderPuppies(container, litterId, puppies) {
if (!puppies.length) { 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; return;
} }
@ -469,10 +465,10 @@ window.Page_litters = (() => {
<div class="litters-puppy-row" data-puppy-id="${p.id}"> <div class="litters-puppy-row" data-puppy-id="${p.id}">
<div class="litters-puppy-info"> <div class="litters-puppy-info">
${_genderIcon(p.geschlecht)} ${_genderIcon(p.geschlecht)}
<span class="litters-puppy-name">${p.name ? _esc(p.name) : '<em style="color:var(--c-text-muted)">Unbenannt</em>'}</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)">${_esc(p.farbe)}</span>` : ''} ${p.farbe ? `<span style="color:var(--c-text-secondary);font-size:var(--text-xs)">${UI.escape(p.farbe)}</span>` : ''}
${_puppyStatusBadge(p.status)} ${_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>
<div class="litters-puppy-actions"> <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}" <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 puppyLabel = puppy.name || 'Welpe';
const body = ` const body = `
<div id="weight-history" style="margin-bottom:var(--space-3)"> <div id="weight-history" class="mb-3">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p> <p class="text-sm-muted">Lädt</p>
</div> </div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)"> <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"> <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> <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"> <input class="form-control" name="gewicht_g" type="number" min="1" max="99999" step="1" required placeholder="z. B. 420">
</div> </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> <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}"> <input class="form-control" name="gemessen_am" type="date" required value="${today}">
</div> </div>
@ -565,7 +561,7 @@ window.Page_litters = (() => {
`; `;
UI.modal.open({ UI.modal.open({
title: `${UI.icon('scales')} Gewichtsverlauf — ${_esc(puppyLabel)}`, title: `${UI.icon('scales')} Gewichtsverlauf — ${UI.escape(puppyLabel)}`,
body, body,
footer, footer,
}); });
@ -600,7 +596,7 @@ window.Page_litters = (() => {
try { try {
const weights = await API.get(`/litters/puppies/${puppyId}/weights`); const weights = await API.get(`/litters/puppies/${puppyId}/weights`);
if (!weights || !weights.length) { 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; return;
} }
@ -630,22 +626,22 @@ window.Page_litters = (() => {
el.innerHTML = ` el.innerHTML = `
<!-- Stats-Zeile --> <!-- Stats-Zeile -->
<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)"> <div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3)">
<div style="text-align:center"> <div class="text-center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Aktuell</div> <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 style="font-size:var(--text-base);font-weight:700;color:var(--c-primary)">${last} g</div>
</div> </div>
<div style="text-align:center"> <div class="text-center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Zunahme</div> <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)'}"> <div style="font-size:var(--text-base);font-weight:700;color:${gain >= 0 ? 'var(--c-success)' : 'var(--c-danger)'}">
${gain >= 0 ? '+' : ''}${gain} g ${gain >= 0 ? '+' : ''}${gain} g
</div> </div>
</div> </div>
<div style="text-align:center"> <div class="text-center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Ø tägl.</div> <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 style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${dailyGain} g</div>
</div> </div>
<div style="text-align:center"> <div class="text-center">
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">Messungen</div> <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 style="font-size:var(--text-base);font-weight:700;color:var(--c-text)">${weights.length}</div>
</div> </div>
</div> </div>
@ -695,7 +691,7 @@ window.Page_litters = (() => {
</tbody> </tbody>
</table>`; </table>`;
} catch (err) { } 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>` : ''}`; 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) { } 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="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> <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-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>`; </div>`;
return; return;
} }
container.innerHTML = header + ` container.innerHTML = header + `
<div style="display:flex;flex-direction:column;gap:var(--space-2)"> <div class="flex-col-gap-2">
${entries.map((e, i) => ` ${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-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="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)"> <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)} ${_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_geschlecht && e.wunsch_geschlecht !== 'egal' ? `<span class="text-xs-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_farbe ? `<span class="text-xs-secondary">${UI.escape(e.wunsch_farbe)}</span>` : ''}
</div> </div>
<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;font-size:var(--text-xs);color:var(--c-text-secondary)"> <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.email ? `<span>${UI.icon('envelope')} ${UI.escape(e.email)}</span>` : ''}
${e.telefon ? `<span>${UI.icon('phone')} ${_esc(e.telefon)}</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> <span>${UI.icon('calendar-dots')} ${e.created_at ? e.created_at.slice(0, 10) : '—'}</span>
</div> </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.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')} ${_esc(e.notiz)}</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>
<div style="display:flex;gap:var(--space-1);flex-shrink:0"> <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-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>
</div>`).join('')} </div>`).join('')}
</div>`; </div>`;
@ -820,22 +816,22 @@ window.Page_litters = (() => {
UI.modal.open({ UI.modal.open({
title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen', title: isEdit ? 'Interessent bearbeiten' : 'Interessent eintragen',
body: ` 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"> <div class="form-group">
<label class="form-label">Name *</label> <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>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">E-Mail</label> <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Telefon</label> <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> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Wunsch Geschlecht</label> <label class="form-label">Wunsch Geschlecht</label>
<select class="form-control" name="wunsch_geschlecht"> <select class="form-control" name="wunsch_geschlecht">
@ -846,14 +842,14 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Wunsch Farbe</label> <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> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Nachricht des Interessenten</label> <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>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Status</label> <label class="form-label">Status</label>
<select class="form-control" name="status"> <select class="form-control" name="status">
@ -867,7 +863,7 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Interne Notiz</label> <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> </div>
</form>`, </form>`,
footer: ` footer: `
@ -919,15 +915,15 @@ window.Page_litters = (() => {
const buildSelect = (name, idName, list, currentId, currentName, placeholder) => { const buildSelect = (name, idName, list, currentId, currentName, placeholder) => {
const opts = list.map(h => { const opts = list.map(h => {
const label = h.name + (h.rufname ? ` (${h.rufname})` : '') + (h.zuchtbuchnummer ? ` · ${h.zuchtbuchnummer}` : ''); 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(''); }).join('');
return ` 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> <option value=""> ${placeholder} </option>
${opts} ${opts}
</select> </select>
<input class="form-control" type="text" name="${name}" id="${name}-txt" <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 => 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> <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" <input class="form-control" type="text" name="wurf_name"
placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …" placeholder="z.B. Vatertags-Wurf, Frühlings-Wurf …"
value="${_esc(v.wurf_name || '')}"> value="${UI.escape(v.wurf_name || '')}">
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Vater</label> <label class="form-label">Vater</label>
${buildSelect('vater_name', 'vater_id', maennlich, v.vater_id, v.vater_name, 'Aus Zuchtkartei')} ${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"> <div class="form-group">
<label class="form-label">Erwarteter Geburtstermin <span style="font-weight:normal;color:var(--c-text-muted)">(geplant)</span></label> <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" <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> <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Geburtsdatum <span style="font-weight:normal;color:var(--c-text-muted)">(tatsächlich)</span></label> <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" <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> <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> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Welpen gesamt</label> <label class="form-label">Welpen gesamt</label>
<input class="form-control" type="number" name="welpen_gesamt" min="0" <input class="form-control" type="number" name="welpen_gesamt" min="0"
@ -1005,19 +1001,19 @@ window.Page_litters = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Preisspanne</label> <label class="form-label">Preisspanne</label>
<input class="form-control" type="text" name="preis_spanne" <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>
<div class="form-group"> <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" <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>
<div class="form-group"> <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" <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>
<div class="form-group"> <div class="form-group">
@ -1028,9 +1024,9 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <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" <input class="form-control" type="date" name="sichtbar_bis"
value="${_esc(v.sichtbar_bis || '')}"> value="${UI.escape(v.sichtbar_bis || '')}">
</div> </div>
</form> </form>
@ -1134,11 +1130,11 @@ window.Page_litters = (() => {
const body = ` const body = `
<form id="puppy-form" autocomplete="off"> <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"> <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" <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Geschlecht</label> <label class="form-label">Geschlecht</label>
@ -1153,7 +1149,7 @@ window.Page_litters = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Farbe / Fellzeichnung</label> <label class="form-label">Farbe / Fellzeichnung</label>
<input class="form-control" type="text" name="farbe" <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>
<div class="form-group"> <div class="form-group">
@ -1165,11 +1161,11 @@ window.Page_litters = (() => {
</select> </select>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)"> <div class="grid-2">
<div class="form-group"> <div class="form-group">
<label class="form-label">Chip-Nr.</label> <label class="form-label">Chip-Nr.</label>
<input class="form-control" type="text" name="chip_nr" <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>
<div class="form-group"> <div class="form-group">
<label class="form-label">Geburtsgewicht (g)</label> <label class="form-label">Geburtsgewicht (g)</label>
@ -1186,9 +1182,9 @@ window.Page_litters = (() => {
</div> </div>
<div class="form-group"> <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" <textarea class="form-control" name="notiz" rows="2"
placeholder="Interne Notizen…">${_esc(v.notiz || '')}</textarea> placeholder="Interne Notizen…">${UI.escape(v.notiz || '')}</textarea>
</div> </div>
</form> </form>
@ -1249,22 +1245,22 @@ window.Page_litters = (() => {
const body = ` const body = `
<form id="contract-form" autocomplete="off"> <form id="contract-form" autocomplete="off">
<div class="form-group"> <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 <input class="form-control" type="text" name="kaeufer_name" required
placeholder="Vor- und Nachname"> placeholder="Vor- und Nachname">
</div> </div>
<div class="form-group"> <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 <textarea class="form-control" name="kaeufer_adresse" rows="2" required
placeholder="Straße, PLZ, Ort"></textarea> placeholder="Straße, PLZ, Ort"></textarea>
</div> </div>
<div class="form-group"> <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" <input class="form-control" type="email" name="kaeufer_email"
placeholder="kaeufer@beispiel.de"> placeholder="kaeufer@beispiel.de">
</div> </div>
<div class="form-group"> <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" <input class="form-control" type="text" name="preis"
placeholder="z. B. 1.500 €"> placeholder="z. B. 1.500 €">
</div> </div>
@ -1279,7 +1275,7 @@ window.Page_litters = (() => {
`; `;
UI.modal.open({ UI.modal.open({
title: `${UI.icon('file-text')} Kaufvertrag — ${_esc(puppyLabel)}`, title: `${UI.icon('file-text')} Kaufvertrag — ${UI.escape(puppyLabel)}`,
body, body,
footer, footer,
}); });
@ -1317,11 +1313,11 @@ window.Page_litters = (() => {
const visOrder = ['public', 'inquiry', 'private']; const visOrder = ['public', 'inquiry', 'private'];
const body = ` const body = `
<div id="${galleryId}" style="margin-bottom:var(--space-4)"> <div id="${galleryId}" class="mb-4">
<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Lädt</p> <p class="text-sm-muted">Lädt</p>
</div> </div>
<hr style="margin:var(--space-3) 0;border:none;border-top:1px solid var(--c-border)"> <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)"> <label style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">
${UI.icon('upload-simple')} Foto hochladen ${UI.icon('upload-simple')} Foto hochladen
</label> </label>
@ -1336,7 +1332,7 @@ window.Page_litters = (() => {
`; `;
UI.modal.open({ UI.modal.open({
title: `${UI.icon('images')} Fotos — ${_esc(label)}`, title: `${UI.icon('images')} Fotos — ${UI.escape(label)}`,
body, body,
footer, footer,
}); });
@ -1348,7 +1344,7 @@ window.Page_litters = (() => {
try { try {
const photos = await API.breederPhotos.list(entityType, entityId); const photos = await API.breederPhotos.list(entityType, entityId);
if (!photos.length) { 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; return;
} }
el.innerHTML = ` el.innerHTML = `
@ -1358,21 +1354,21 @@ window.Page_litters = (() => {
const vis = visLabels[ph.visibility] || visLabels.private; const vis = visLabels[ph.visibility] || visLabels.private;
return ` return `
<div style="position:relative;border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--c-border);aspect-ratio:1"> <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"> <a href="${UI.escape(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${_esc(thumb)}" alt="${_esc(ph.caption || '')}" <img src="${UI.escape(thumb)}" alt="${UI.escape(ph.caption || '')}"
loading="lazy" loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block" style="width:100%;height:100%;object-fit:cover;display:block"
onerror="this.src='/static/img/placeholder.webp'"> onerror="this.src='/static/img/placeholder.webp'">
</a> </a>
<button class="photos-vis-btn" <button class="photos-vis-btn"
data-photo-id="${ph.id}" data-photo-id="${ph.id}"
data-vis="${_esc(ph.visibility)}" data-vis="${UI.escape(ph.visibility)}"
title="Sichtbarkeit ändern" title="Sichtbarkeit ändern"
style="position:absolute;bottom:0;left:0;right:0; style="position:absolute;bottom:0;left:0;right:0;
background:${vis.color};color:#fff; background:${vis.color};color:#fff;
border:none;cursor:pointer;font-size:10px;padding:2px 4px; border:none;cursor:pointer;font-size:10px;padding:2px 4px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis"> white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(vis.text)} ${UI.escape(vis.text)}
</button> </button>
<button class="photos-del-btn" <button class="photos-del-btn"
data-photo-id="${ph.id}" data-photo-id="${ph.id}"
@ -1418,7 +1414,7 @@ window.Page_litters = (() => {
} catch (err) { } catch (err) {
const el = document.getElementById(galleryId); 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 => ` const issueHTML = (welfare.issues || []).map(i => `
<div style="display:flex;gap:8px;padding:8px 0;border-bottom:1px solid rgba(0,0,0,.06)"> <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="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(''); </div>`).join('');
const okHTML = (welfare.ok_points || []).map(p => ` const okHTML = (welfare.ok_points || []).map(p => `
<div style="display:flex;gap:8px;padding:4px 0"> <div style="display:flex;gap:8px;padding:4px 0">
<span style="color:#16a34a;flex-shrink:0">${UI.icon('check')}</span> <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(''); </div>`).join('');
const isProblematic = welfare.level === 'warning' || welfare.level === 'critical'; const isProblematic = welfare.level === 'warning' || welfare.level === 'critical';
@ -1500,7 +1496,7 @@ window.Page_litters = (() => {
Trotzdem fortfahren Trotzdem fortfahren
</button> </button>
</div>` : ` </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 ${UI.icon('check')} Verstanden
</button>`, </button>`,
}); });
@ -1540,7 +1536,7 @@ window.Page_litters = (() => {
} catch (err) { } catch (err) {
UI.modal.open({ UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`, 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>`, footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
}); });
return; return;
@ -1548,7 +1544,7 @@ window.Page_litters = (() => {
UI.modal.open({ UI.modal.open({
title: `${UI.icon('sparkle')} KI-Wurfankündigung`, 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: ` footer: `
<button class="btn btn-secondary flex-1" id="ki-announce-copy"> <button class="btn btn-secondary flex-1" id="ki-announce-copy">
${UI.icon('clipboard-text')} Kopieren ${UI.icon('clipboard-text')} Kopieren

View file

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

View file

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

View file

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

View file

@ -88,7 +88,7 @@ window.Page_movies = (() => {
<div class="movies-search-row"> <div class="movies-search-row">
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg> <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" <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>
<div class="movies-filter-row"> <div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button> <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 === '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> <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>
<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 === '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 === '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> <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 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 _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 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 imdb = film.imdb_rating ? `<span class="text-xs-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 streaming = film.streaming ? `<span class="text-xs-muted">${UI.escape(film.streaming)}</span>` : '';
return ` 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-emoji">${film.bild_emoji}</div>
<div class="movie-card-body"> <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"> <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>
<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} ${tag}
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div> <div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
<div class="movie-card-stars">${stars}</div> <div class="movie-card-stars">${stars}</div>
@ -234,17 +234,17 @@ window.Page_movies = (() => {
const body = ` const body = `
<div class="movie-modal-emoji">${film.bild_emoji}</div> <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)"> <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 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> ${_esc(film.hund_rasse)}</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> <span class="badge">${film.jahr}</span>
</div> </div>
<div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</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> <p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${UI.escape(film.beschreibung)}</p>
<div style="margin-bottom:var(--space-2)"> <div class="mb-2">
<strong>Community-Bewertung:</strong> <strong>Community-Bewertung:</strong>
</div> </div>
<div id="modal-stars-${_esc(film.id)}">${stars}</div> <div id="modal-stars-${UI.escape(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-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 Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen
</div> </div>
${loginHint} ${loginHint}
@ -262,9 +262,9 @@ window.Page_movies = (() => {
const filled = Math.round(avg); const filled = Math.round(avg);
const stars = [1,2,3,4,5].map(i => { const stars = [1,2,3,4,5].map(i => {
const active = i <= (userRating || filled) ? ' movie-star--active' : ''; 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(''); }).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) { function _bindStarRatings(container) {
@ -339,9 +339,9 @@ window.Page_movies = (() => {
<div class="movie-promi-card"> <div class="movie-promi-card">
<div class="movie-promi-emoji">${p.emoji}</div> <div class="movie-promi-emoji">${p.emoji}</div>
<div class="movie-promi-body"> <div class="movie-promi-body">
<div class="movie-promi-name">${_esc(p.name)}</div> <div class="movie-promi-name">${UI.escape(p.name)}</div>
<div class="movie-promi-rasse">${_esc(p.rasse)}</div> <div class="movie-promi-rasse">${UI.escape(p.rasse)}</div>
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div> <div class="movie-promi-text">${UI.escape(p.bekannt_fuer)}</div>
</div> </div>
</div> </div>
`).join('')} `).join('')}
@ -370,13 +370,13 @@ window.Page_movies = (() => {
const voteCards = _appState.dogs.map(dog => { const voteCards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id; const isVoted = data.user_vote === dog.id;
const av = dog.foto_url const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">` ? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-vote-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
return ` return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}"> <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-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div> <div class="hdm-vote-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</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}"> <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'} ${isVoted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Gewählt' : 'Abstimmen'}
</button> </button>
@ -405,16 +405,16 @@ window.Page_movies = (() => {
? data.top.slice(0, 5).map((dog, i) => { ? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`; const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`;
const av = dog.foto_url const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">` ? `<img src="${UI.escape(dog.foto_url)}" alt="${UI.escape(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`; : `<span class="hdm-top-av-placeholder">${UI.escape(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : ''; const vorname = dog.besitzer_name ? UI.escape(dog.besitzer_name.split(' ')[0]) : '';
return ` return `
<div class="hdm-top-entry"> <div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span> <span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div> <div class="hdm-top-av">${av}</div>
<div class="hdm-top-info"> <div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div> <div class="hdm-top-name">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="hdm-top-rasse">${UI.escape(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''} ${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</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> <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-header">
<div class="hdm-trophy">🏆</div> <div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2> <h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div> <div class="hdm-monat">${UI.escape(monthName)}</div>
</div> </div>
${voteSection} ${voteSection}
@ -465,15 +465,6 @@ window.Page_movies = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// HELPER // 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 // PUBLIC
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -47,14 +47,6 @@ window.Page_notes = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// Hilfsfunktionen // 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) { function _formatTime(isoStr) {
if (!isoStr) return ''; if (!isoStr) return '';
@ -76,7 +68,10 @@ window.Page_notes = (() => {
} catch (_) { return 'Älteres'; } } 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 ''; if (!str) return '';
return str.length > max ? str.slice(0, max) + '…' : str; return str.length > max ? str.slice(0, max) + '…' : str;
} }
@ -125,7 +120,7 @@ window.Page_notes = (() => {
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([label, items]) => ` .map(([label, items]) => `
<div class="notes-group"> <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('')} ${items.map(_noteCard).join('')}
</div> </div>
`).join(''); `).join('');
@ -166,9 +161,9 @@ window.Page_notes = (() => {
<div class="notes-filter-chips"> <div class="notes-filter-chips">
${RUBRIKEN.map(r => ` ${RUBRIKEN.map(r => `
<button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}" <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}` : ''}"> style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}">
${_esc(r.label)} ${UI.escape(r.label)}
</button> </button>
`).join('')} `).join('')}
</div> </div>
@ -178,7 +173,7 @@ window.Page_notes = (() => {
<div class="notes-search-wrap"> <div class="notes-search-wrap">
<svg class="ph-icon notes-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg> <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" <input id="notes-search" type="search" class="notes-search-input"
placeholder="Suche…" value="${_esc(_searchQ)}"> placeholder="Suche…" value="${UI.escape(_searchQ)}">
</div> </div>
<div class="notes-sort-btns"> <div class="notes-sort-btns">
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}" <button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
@ -243,21 +238,32 @@ window.Page_notes = (() => {
/* Gruppen */ /* Gruppen */
.notes-group { display: flex; flex-direction: column; gap: var(--space-2); } .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; } .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 */ /* Karten — Notes-spezifischer Override: vertikales Layout statt horizontalem .list-item-card */
.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 { flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); } .notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; }
.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; } /* 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-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); } /* TODO nach Migration entfernen: ersetzt durch .list-item-meta-row */
.notes-card-actions { display: flex; gap: var(--space-2); margin-left: auto; flex-shrink: 0; } /* .notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); } */
.notes-card-text { font-size: var(--text-sm); color: var(--c-text); line-height: 1.55; white-space: pre-wrap; margin: 0; } /* Notes-Override: Actions in Top-Zeile rechts ausrichten (statt align-self:center bei list-item-actions) */
.notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); } .notes-card-actions { margin-left: auto; align-self: flex-start; }
.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-Override: Newlines (pre-wrap) + max 5 Zeilen mit "…", Rest in Detail-Modal */
.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-card-text { line-height: 1.55; white-space: pre-wrap; margin: 0; color: var(--c-text);
.notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); } display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden; }
.notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); } /* 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); } .notes-list { display: flex; flex-direction: column; gap: var(--space-4); }
@keyframes spin { to { transform: rotate(360deg); } } @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' : ''}> <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'} ${_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> </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 ? ` ${_kiSuggestions ? `
<div class="notes-ki-suggestions"> <div class="notes-ki-suggestions">
<ul> <ul>
${_kiSuggestions.map(s => `<li>${_esc(s)}</li>`).join('')} ${_kiSuggestions.map(s => `<li>${UI.escape(s)}</li>`).join('')}
</ul> </ul>
</div> </div>
` : ''} ` : ''}
@ -314,43 +320,42 @@ window.Page_notes = (() => {
const hasLocation = !!note.location_name; const hasLocation = !!note.location_name;
return ` 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 --> <!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons -->
<div class="notes-card-top"> <div class="notes-card-top">
<span class="notes-rubrik-chip" <span class="list-item-chip" style="--chip-color:${rb.color}">
style="background:${rb.color}22;color:${rb.color}">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${rb.icon}"></use></svg>
${_esc(rb.label)} ${UI.escape(rb.label)}
</span> </span>
${note.parent_label ${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"> <div class="list-item-actions notes-card-actions">
<button class="notes-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg>
</button> </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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button> </button>
</div> </div>
</div> </div>
<!-- Notiztext --> <!-- 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 --> <!-- Micro-Badges -->
${microBadges.length ? ` ${microBadges.length ? `
<div class="notes-micro-badges"> <div class="list-item-micro-badges">
${microBadges.map(b => `<span class="notes-micro-badge">${_esc(b)}</span>`).join('')} ${microBadges.map(b => `<span class="list-item-micro-badge">${UI.escape(b)}</span>`).join('')}
</div> </div>
` : ''} ` : ''}
<!-- Meta: Zeit + Ort --> <!-- 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> <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))} ${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> ${_esc(note.location_name)}` : ''} ${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>
</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> <h3 style="font-size:var(--text-base);font-weight:700;margin:0 0 var(--space-4)">Neue Notiz</h3>
<!-- Kategorie-Auswahl --> <!-- 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> <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)"> <div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
${ERSTELL_RUBRIKEN.map(r => ` ${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)'}; 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)'}; background:${_selType===r.type ? r.color+'22' : 'var(--c-surface-2)'};
color:${_selType===r.type ? r.color : 'var(--c-text-secondary)'};cursor:pointer"> color:${_selType===r.type ? r.color : 'var(--c-text-secondary)'};cursor:pointer">
${_esc(r.label)} ${UI.escape(r.label)}
</button>`).join('')} </button>`).join('')}
</div> </div>
</div> </div>
<!-- Text --> <!-- 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> <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…" <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); 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> box-sizing:border-box"></textarea>
</div> </div>
<div style="display:flex;gap:var(--space-3)"> <div class="flex-gap-3">
<button id="nc-cancel" class="btn btn-ghost" style="flex:1">Abbrechen</button> <button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
<button id="nc-save" class="btn btn-primary" style="flex:1">Speichern</button> <button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
</div> </div>
</div>`; </div>`;
}; };
@ -601,7 +664,7 @@ window.Page_notes = (() => {
<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs); <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; font-weight:var(--weight-semibold);padding:2px var(--space-2);border-radius:999px;
background:${rb.color}22;color:${rb.color}"> 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> </span>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0"> <h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
Notiz bearbeiten Notiz bearbeiten
@ -619,7 +682,7 @@ window.Page_notes = (() => {
border-radius:var(--radius-md);font-size:var(--text-sm); border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface); font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;line-height:1.5; 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> </div>
${note.parent_type === 'training_session' ? ` ${note.parent_type === 'training_session' ? `
@ -627,7 +690,7 @@ window.Page_notes = (() => {
<div> <div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold); <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> 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 => ` ${[1,2,3,4,5].map(n => `
<button type="button" class="notes-pfote" data-val="${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); 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> <div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold); <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> 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]) => ` ${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => `
<button type="button" class="notes-umgebung" data-val="${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); 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> <div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold); <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> 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]) => ` ${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => `
<button type="button" class="notes-stimmung" data-val="${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); 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() { function _step1() {
return ` return `
<div style="text-align:center"> <div class="text-center">
<!-- Logo --> <!-- Logo -->
<div style="margin-bottom:var(--space-6)"> <div style="margin-bottom:var(--space-6)">
@ -133,19 +133,19 @@ window.Page_onboarding = (() => {
<div> <div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text)">${title}</div> 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>
</div> </div>
`).join('')} `).join('')}
</div> </div>
<!-- Buttons --> <!-- Buttons -->
<div style="display:flex;flex-direction:column;gap:var(--space-3)"> <div class="flex-col-gap-3">
<button class="btn btn-primary" id="ob-next-btn" style="width:100%"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-right"></use></svg>
Los geht's Los geht's
</button> </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 Überspringen
</button> </button>
</div> </div>
@ -222,7 +222,7 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
<span id="ob-photo-label">Foto auswählen</span> <span id="ob-photo-label">Foto auswählen</span>
<input type="file" name="foto" id="ob-photo-input" <input type="file" name="foto" id="ob-photo-input"
accept="image/*" style="display:none"> accept="image/*" class="hidden">
</label> </label>
</div> </div>
@ -234,13 +234,13 @@ window.Page_onboarding = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button> </button>
<button type="submit" form="ob-dog-form" class="btn btn-primary" id="ob-save-btn" <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
Hund anlegen Hund anlegen
</button> </button>
</div> </div>
<div style="text-align:center;margin-top:var(--space-3)"> <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 Ohne Hund fortfahren
</button> </button>
</div> </div>
@ -255,7 +255,7 @@ window.Page_onboarding = (() => {
function _step3() { function _step3() {
const dogName = _appState.activeDog?.name; const dogName = _appState.activeDog?.name;
return ` return `
<div style="text-align:center"> <div class="text-center">
<!-- Erfolgs-Icon --> <!-- Erfolgs-Icon -->
<div style="margin-bottom:var(--space-6)"> <div style="margin-bottom:var(--space-6)">
@ -276,7 +276,7 @@ window.Page_onboarding = (() => {
${dogName ? ` ${dogName ? `
<p style="font-size:var(--text-base);color:var(--c-text-secondary); <p style="font-size:var(--text-base);color:var(--c-text-secondary);
line-height:1.6;margin:0 0 var(--space-3)"> 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 Du kannst jetzt Einträge im Tagebuch anlegen, die Gesundheit pflegen
und viele weitere Funktionen nutzen. und viele weitere Funktionen nutzen.
</p> </p>
@ -294,13 +294,13 @@ window.Page_onboarding = (() => {
</p> </p>
<!-- CTA --> <!-- CTA -->
<div style="display:flex;flex-direction:column;gap:var(--space-3)"> <div class="flex-col-gap-3">
<button class="btn btn-primary" id="ob-diary-btn" style="width:100%"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
Zum Tagebuch Zum Tagebuch
</button> </button>
${dogName ? ` ${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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
Profil vervollständigen Profil vervollständigen
</button> </button>
@ -416,7 +416,7 @@ window.Page_onboarding = (() => {
} }
App.renderDogSwitcher(); App.renderDogSwitcher();
UI.toast.success(`${_esc(dog.name)} wurde angelegt!`); UI.toast.success(`${UI.escape(dog.name)} wurde angelegt!`);
_step = 3; _step = 3;
_render(); _render();
@ -452,9 +452,6 @@ window.Page_onboarding = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// HELPER // HELPER
// ---------------------------------------------------------- // ----------------------------------------------------------
function _esc(s) {
return UI.escape(s || '');
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// PUBLIC // 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 --> <!-- Fortschritt -->
<div style="padding:var(--space-4) var(--space-4) 0"> <div style="padding:var(--space-4) var(--space-4) 0">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"> <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} Frage ${_current + 1} von ${FRAGEN.length}
</span> </span>
<span style="font-size:var(--text-xs);font-weight:600;color:var(--c-primary)">${pct}%</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 ` return `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> <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> <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: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 style="height:100%;width:${pct}%;background:${tp.color};border-radius:4px;transition:width .6s"></div>
</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; <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; color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:0.05em;
border-bottom:1px solid var(--c-border)">Dein Profil</div> 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> </div>
<!-- Teilen + Nochmal --> <!-- Teilen + Nochmal -->

View file

@ -281,8 +281,8 @@ window.Page_places = (() => {
</div> </div>
</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.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.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 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.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>` : ''} ${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> <div id="place-rating-${place.id}"></div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)"> <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 ? ` 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-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> <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>
<div class="form-group"> <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" <input class="form-control" type="text" name="adresse"
value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt"> value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
</div> </div>
<div class="form-group"> <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" <input class="form-control" type="url" name="website"
value="${UI.escape(place?.website || '')}" placeholder="https://…"> value="${UI.escape(place?.website || '')}" placeholder="https://…">
</div> </div>
<div class="form-group"> <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" <input class="form-control" type="tel" name="telefon"
value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456"> value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456">
</div> </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 class="form-label">Hundefreundlichkeit</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer"> <label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}> <input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
@ -386,10 +386,10 @@ window.Page_places = (() => {
const footer = ` const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%"> <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'} ${isEdit ? 'Speichern' : 'Ort hinzufügen'}
</button> </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>` : ''} ${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> <button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
</div> </div>

View file

@ -15,11 +15,6 @@ window.Page_playdate = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Helpers // 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 ''; if (!iso) return '';
const d = new Date(iso.replace(' ', 'T')); const d = new Date(iso.replace(' ', 'T'));
@ -27,9 +22,9 @@ window.Page_playdate = (() => {
} }
function _dogAvatar(foto_url, name, size = 48) { 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) { 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;" 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>'">`; 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"> <div class="playdate-layout">
<!-- Tabs --> <!-- 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 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="listings">Meine Inserate</button>
<button class="by-tab" data-tab="requests"> <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-3);margin-bottom:var(--space-4);flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:var(--space-2)"> <div style="display:flex;align-items:center;gap:var(--space-2)">
${UI.icon('map-pin')} ${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'} ${_userPos ? 'Standort bekannt' : 'Kein Standort'}
</span> </span>
</div> </div>
@ -245,34 +240,34 @@ window.Page_playdate = (() => {
function _nearbyCard(d) { function _nearbyCard(d) {
return ` 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)"> <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)} ${_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); <div style="font-weight:var(--weight-semibold);font-size:var(--text-base);
color:var(--c-text)">${_esc(d.dog_name)}</div> color:var(--c-text)">${UI.escape(d.dog_name)}</div>
${d.rasse ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${_esc(d.rasse)}</div>` : ''} ${d.rasse ? `<div class="text-sm-secondary">${UI.escape(d.rasse)}</div>` : ''}
${d.alter ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(d.alter)}</div>` : ''} ${d.alter ? `<div class="text-xs-muted">${UI.escape(d.alter)}</div>` : ''}
</div> </div>
</div> </div>
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-3);flex-wrap:wrap"> <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)"> <span style="display:flex;align-items:center;gap:4px;font-size:var(--text-xs);color:var(--c-text-secondary)">
${UI.icon('map-pin')} ${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> </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> </div>
${d.beschreibung ? ` ${d.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary); <p style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin:0 0 var(--space-3);line-height:1.5"> margin:0 0 var(--space-3);line-height:1.5">
${_esc(d.beschreibung)} ${UI.escape(d.beschreibung)}
</p>` : ''} </p>` : ''}
<button class="btn btn-primary btn-sm playdate-anfrage-btn" <button class="btn btn-primary btn-sm playdate-anfrage-btn"
data-dog-id="${d.dog_id}" 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 ${UI.icon('paw-print')} Spielkamerad anfragen
</button> </button>
</div> </div>
@ -389,12 +384,12 @@ window.Page_playdate = (() => {
function _listingCard(dog, listing) { function _listingCard(dog, listing) {
const isAktiv = listing && listing.aktiv; const isAktiv = listing && listing.aktiv;
return ` 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)"> <div style="display:flex;gap:var(--space-3);align-items:center;margin-bottom:var(--space-3)">
${_dogAvatar(dog.foto_url, dog.name, 44)} ${_dogAvatar(dog.foto_url, dog.name, 44)}
<div style="flex:1;min-width:0"> <div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(dog.name)}</div> <div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(dog.name)}</div>
${dog.rasse ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">${_esc(dog.rasse)}</div>` : ''} ${dog.rasse ? `<div class="text-xs-secondary">${UI.escape(dog.rasse)}</div>` : ''}
</div> </div>
<span style="font-size:var(--text-xs);font-weight:600; <span style="font-size:var(--text-xs);font-weight:600;
padding:2px 10px;border-radius:999px; padding:2px 10px;border-radius:999px;
@ -407,12 +402,12 @@ window.Page_playdate = (() => {
${isAktiv ? ` ${isAktiv ? `
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)"> <div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-3)">
${UI.icon('map-pin')} ${UI.icon('map-pin')}
${listing.ort_name ? _esc(listing.ort_name) + ' · ' : ''} ${listing.ort_name ? UI.escape(listing.ort_name) + ' · ' : ''}
Radius: ${listing.radius_km} km Radius: ${listing.radius_km} km
</div> </div>
${listing.beschreibung ? ` ${listing.beschreibung ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary); <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)"> <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. Noch kein Inserat trage dich ein, damit andere dich finden können.
@ -442,10 +437,10 @@ window.Page_playdate = (() => {
<form id="${formId}"> <form id="${formId}">
<div class="form-group"> <div class="form-group">
<label class="form-label">Ort / Standort</label> <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" <input type="text" id="listing-ort" class="form-control"
placeholder="z.B. München" 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" <button type="button" class="btn btn-ghost btn-sm" id="listing-gps-btn"
title="GPS-Standort ermitteln"> title="GPS-Standort ermitteln">
${UI.icon('crosshair')} ${UI.icon('crosshair')}
@ -472,7 +467,7 @@ window.Page_playdate = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label">Beschreibung (optional)</label> <label class="form-label">Beschreibung (optional)</label>
<textarea id="listing-beschreibung" class="form-control" rows="3" maxlength="400" <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> </div>
</form> </form>
`, `,
@ -578,7 +573,7 @@ window.Page_playdate = (() => {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em; color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Eingehende Anfragen</h3> 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('')} ${incoming.map(r => _incomingCard(r)).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
@ -588,7 +583,7 @@ window.Page_playdate = (() => {
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold); <h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em; color:var(--c-text-muted);text-transform:uppercase;letter-spacing:0.05em;
margin:0 0 var(--space-3)">Ausgehende Anfragen</h3> 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('')} ${outgoing.map(r => _outgoingCard(r)).join('')}
</div> </div>
</div>` : ''} </div>` : ''}
@ -631,17 +626,17 @@ window.Page_playdate = (() => {
function _incomingCard(r) { function _incomingCard(r) {
const isPending = r.status === 'pending'; const isPending = r.status === 'pending';
return ` 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)"> <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)} ${_dogAvatar(r.from_dog_foto, r.from_dog_name, 44)}
<div style="flex:1;min-width:0"> <div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.from_dog_name)}</div> <div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.from_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)"> <div class="text-xs-secondary">
${r.from_dog_rasse ? _esc(r.from_dog_rasse) + ' · ' : ''} ${r.from_dog_rasse ? UI.escape(r.from_dog_rasse) + ' · ' : ''}
${r.alter ? _esc(r.alter) + ' · ' : ''} ${r.alter ? UI.escape(r.alter) + ' · ' : ''}
von ${_esc(r.from_user_name)} von ${UI.escape(r.from_user_name)}
</div> </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> </div>
${_statusBadge(r.status)} ${_statusBadge(r.status)}
</div> </div>
@ -651,11 +646,11 @@ window.Page_playdate = (() => {
background:var(--c-surface-2);border-radius:var(--radius-md); background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3); padding:var(--space-2) var(--space-3);margin-bottom:var(--space-3);
line-height:1.5"> line-height:1.5">
"${_esc(r.nachricht)}" "${UI.escape(r.nachricht)}"
</div>` : ''} </div>` : ''}
${isPending ? ` ${isPending ? `
<div style="display:flex;gap:var(--space-2)"> <div class="flex-gap-2">
<button class="btn btn-primary btn-sm req-accept-btn" <button class="btn btn-primary btn-sm req-accept-btn"
data-req-id="${r.id}" data-status="accepted"> data-req-id="${r.id}" data-status="accepted">
${UI.icon('check')} Annehmen ${UI.icon('check')} Annehmen
@ -676,23 +671,23 @@ window.Page_playdate = (() => {
function _outgoingCard(r) { function _outgoingCard(r) {
return ` 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)"> <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)} ${_dogAvatar(r.to_dog_foto, r.to_dog_name, 44)}
<div style="flex:1;min-width:0"> <div class="flex-1-min">
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">${_esc(r.to_dog_name)}</div> <div style="font-weight:var(--weight-semibold);color:var(--c-text)">${UI.escape(r.to_dog_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)"> <div class="text-xs-secondary">
${r.to_dog_rasse ? _esc(r.to_dog_rasse) + ' · ' : ''} ${r.to_dog_rasse ? UI.escape(r.to_dog_rasse) + ' · ' : ''}
von ${_esc(r.to_user_name)} von ${UI.escape(r.to_user_name)}
</div> </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> </div>
${_statusBadge(r.status)} ${_statusBadge(r.status)}
</div> </div>
${r.nachricht ? ` ${r.nachricht ? `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)"> <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>` : ''} </p>` : ''}
${r.status === 'accepted' ? ` ${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"> <a href="tel:110" class="btn btn-secondary" style="flex:1;text-align:center;text-decoration:none">
${UI.icon('phone')} <strong>110</strong> Polizei ${UI.icon('phone')} <strong>110</strong> Polizei
</a> </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 ${UI.icon('first-aid')} Erste Hilfe & Tiergift
</button> </button>
</div> </div>
@ -94,8 +94,7 @@ window.Page_poison = (() => {
document.getElementById('poison-btn-erstehilfe') document.getElementById('poison-btn-erstehilfe')
?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' })); ?.addEventListener('click', () => App.navigate('erste-hilfe', true, { tab: 'lebensgefahr' }));
await UI.loadLeaflet(); await _initMap();
_initMap();
// Leaflet muss nach CSS-Load die Container-Größe neu berechnen // Leaflet muss nach CSS-Load die Container-Größe neu berechnen
setTimeout(() => _map?.invalidateSize(), 100); setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad(); await _locateAndLoad();
@ -104,17 +103,16 @@ window.Page_poison = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// KARTE INITIALISIEREN // KARTE INITIALISIEREN
// ---------------------------------------------------------- // ----------------------------------------------------------
function _initMap() { async function _initMap() {
const mapEl = document.getElementById('poison-map'); const mapEl = document.getElementById('poison-map');
if (!mapEl || !window.L || _map) return; if (!mapEl || _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);
_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>' : ''} ${r.beschreibung ? UI.escape(r.beschreibung.slice(0, 80)) + '<br>' : ''}
<small>📍 ${distStr} entfernt</small><br> <small>📍 ${distStr} entfernt</small><br>
<small>📅 ${_fmtDate(r.created_at)}</small> <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)); marker.on('click', () => _openDetail(r));
@ -276,13 +274,13 @@ window.Page_poison = (() => {
border-left:4px solid ${typ.color}"> border-left:4px solid ${typ.color}">
<div style="display:flex;gap:var(--space-3);align-items:flex-start"> <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="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); <div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap"> margin-bottom:var(--space-1);flex-wrap:wrap">
<span class="badge" <span class="badge"
style="background:${typ.color};color:#fff">${typ.label}</span> style="background:${typ.color};color:#fff">${typ.label}</span>
${r.bestaetigt ${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); <span style="margin-left:auto;color:var(--c-text-secondary);
font-size:var(--text-sm);white-space:nowrap"> 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 ? '…' : ''} ${UI.escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>` </p>`
: ''} : ''}
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)"> <div class="text-xs-secondary">
Gemeldet ${_fmtDate(r.created_at)} · Gemeldet ${_fmtDate(r.created_at)} ·
läuft ab ${_fmtDate(r.expires_at)} läuft ab ${_fmtDate(r.expires_at)}
</div> </div>
@ -336,7 +334,7 @@ window.Page_poison = (() => {
<span class="badge" style="background:${typ.color};color:#fff"> <span class="badge" style="background:${typ.color};color:#fff">
${UI.icon(typ.icon)} ${typ.label} ${UI.icon(typ.icon)} ${typ.label}
</span> </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> </div>
${r.beschreibung ${r.beschreibung
@ -353,7 +351,7 @@ window.Page_poison = (() => {
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap"> <div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
${!r.bestaetigt && _appState.user && !isOwnEntry ${!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> <button class="btn btn-secondary flex-1" id="detail-show-map">🗺 Auf Karte</button>
${isOwnEntry || isAdmin ${isOwnEntry || isAdmin
@ -472,7 +470,7 @@ window.Page_poison = (() => {
<div class="form-group"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Beschreibung Beschreibung
<span style="color:var(--c-text-secondary)">(optional)</span> <span class="text-secondary">(optional)</span>
</label> </label>
<textarea class="form-control" name="beschreibung" rows="3" <textarea class="form-control" name="beschreibung" rows="3"
placeholder="z. B. Wurstköder mit Nadeln, liegt beim Eingang Hundeparkplatz, linke Seite…"></textarea> 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"> <div class="form-group">
<label class="form-label"> <label class="form-label">
Foto Foto
<span style="color:var(--c-text-secondary)">(optional)</span> <span class="text-secondary">(optional)</span>
</label> </label>
<input class="form-control" type="file" name="photo" <input class="form-control" type="file" name="photo"
accept="image/*" capture="environment"> accept="image/*" capture="environment">
@ -593,7 +591,7 @@ window.Page_poison = (() => {
title: 'Danke für deine Meldung!', title: 'Danke für deine Meldung!',
body: ` body: `
<div style="text-align:center;padding:var(--space-2) 0 var(--space-4)"> <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> <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> </div>
<p style="color:var(--c-text);font-size:var(--text-base);line-height:1.7;margin:0"> <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