Compare commits
3 commits
203da50e1d
...
f7370028da
| Author | SHA1 | Date | |
|---|---|---|---|
| f7370028da | |||
| 51aad6cf1b | |||
| e86d89f3d9 |
32 changed files with 1449 additions and 165 deletions
|
|
@ -18,7 +18,7 @@ _Stand: 2026-06-09_
|
|||
| Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot |
|
||||
| Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
|
||||
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE |
|
||||
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) |
|
||||
| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht — in ~Tagen auf „indexiert" prüfen; llms.txt aktuell. Nächster echter Hebel: Backlinks (Blog-Testberichte) |
|
||||
| Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features |
|
||||
| App Store (iOS) | 🟢 **LIVE im App Store** (09.06., Apple-ID 6775012705) | Landing bewirbt „Ban Yaro Go" (Hero + iOS-Abschnitt `#ios-app`) + Profil-Hinweis (Settings → App installieren). Offizielles „Laden im App Store"-Badge nachgebaut als `/img/appstore-badge-de.svg` (brauner Rand #C4843A). **LIVE auf Produktion v1276** (banyaro.app/.de, 09.06.) — Hero-Badge bewusst weggelassen (sonst Eindruck: ganze App im Store) |
|
||||
| Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen |
|
||||
|
|
@ -41,7 +41,9 @@ Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee ·
|
|||
## ✅ Erledigt
|
||||
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026
|
||||
- [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) — Details im Repo `banyaro-ios`
|
||||
- [x] Landing-Promotion für „Ban Yaro Go" gebaut (Hero-Badge + iOS-Abschnitt) — 09.06., develop (URL-Platzhalter offen)
|
||||
- [x] Landing-Promotion für „Ban Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) — 09.06., Prod v1278
|
||||
- [x] Datenschutz v4 + AGB v3 (iOS-App-Verarbeitung, kein App-Store-IAP) — 09.06., Prod
|
||||
- [x] Rechtsseiten crawlbar gemacht (/datenschutz /agb /impressum, einzige Quelle static/*.html) + 3 URLs in GSC zur Indexierung eingereicht — 09.06., Prod v1278
|
||||
- [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026
|
||||
- [x] SEO-Grundlagen (llms.txt, Landing About-Section)
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1278
|
||||
1292
|
||||
|
|
@ -1303,6 +1303,23 @@ def _migrate(conn_factory):
|
|||
""")
|
||||
logger.info("Migration: notes Tabelle bereit.")
|
||||
|
||||
# Notizen: mehrere Mediendateien pro Notiz (Bild/Video/Audio/Datei)
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS note_media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL DEFAULT 'image', -- image|video|audio|pdf|file
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
img_width INTEGER,
|
||||
img_height INTEGER,
|
||||
duration_s INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_media_note ON note_media(note_id, sort_order);
|
||||
""")
|
||||
logger.info("Migration: note_media Tabelle bereit.")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ki_health_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ KI_MODE = os.getenv("KI_MODE", "local") # off | local | cl
|
|||
LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1")
|
||||
LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it")
|
||||
CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6")
|
||||
VISION_MODEL = os.getenv("KI_VISION_MODEL", "claude-opus-4-8") # Bild-Analyse (Rassenerkennung)
|
||||
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20"))
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=(self)"
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
|
|
@ -114,6 +114,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||
"worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker
|
||||
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"media-src 'self' blob:; " # Audio/Video-Wiedergabe + lokale blob:-Vorschau (Sprachnotizen)
|
||||
"connect-src 'self' https:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ from typing import Tuple
|
|||
|
||||
_HEIC_EXTS = {".heic", ".heif"}
|
||||
_VIDEO_EXTS = {".mov", ".avi", ".m4v"}
|
||||
# Audio-Endungen, die bereits AAC in einem MP4-Container sind (iOS-Recorder
|
||||
# liefert audio/mp4) — keine Transkodierung nötig.
|
||||
_AUDIO_AAC_EXTS = {".m4a", ".aac", ".mp4"}
|
||||
|
||||
# Magic-Byte-Signaturen erlaubter Medientypen
|
||||
_IMAGE_MAGIC = [
|
||||
|
|
@ -51,6 +54,34 @@ def validate_upload(data: bytes, filename: str) -> None:
|
|||
# HEIC, MOV, AVI, M4V: Pillow/FFmpeg prüfen beim Konvertieren
|
||||
|
||||
|
||||
def validate_audio(data: bytes, content_type: str) -> None:
|
||||
"""
|
||||
Prüft Magic Bytes einer Audio-Datei gegen den vom Client gemeldeten content_type.
|
||||
Wirft ValueError bei Mismatch. WICHTIG: Audio-WebM und Video-WebM teilen sich
|
||||
dieselbe Magic-Byte-Signatur (Matroska) — die Unterscheidung ist NUR über den
|
||||
content_type möglich, deshalb diese eigene Funktion (validate_upload hat keinen).
|
||||
"""
|
||||
if not data:
|
||||
raise ValueError("Leere Audiodatei.")
|
||||
ct = (content_type or "").lower().split(";")[0].strip()
|
||||
if ct in ("audio/mp4", "audio/aac", "audio/x-m4a", "audio/m4a"):
|
||||
if not (len(data) >= 8 and data[4:8] in (b'ftyp', b'mdat', b'moov', b'free')):
|
||||
raise ValueError("Datei ist kein gültiges MP4/AAC-Audio.")
|
||||
elif ct == "audio/webm":
|
||||
if not data[:4] == b'\x1a\x45\xdf\xa3':
|
||||
raise ValueError("Datei ist kein gültiges WebM-Audio.")
|
||||
elif ct in ("audio/ogg", "audio/opus"):
|
||||
if not data[:4] == b'OggS':
|
||||
raise ValueError("Datei ist kein gültiges Ogg-Audio.")
|
||||
elif ct == "audio/mpeg":
|
||||
if not (data[:3] == b'ID3' or (len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0)):
|
||||
raise ValueError("Datei ist kein gültiges MP3.")
|
||||
elif ct in ("audio/wav", "audio/x-wav", "audio/wave"):
|
||||
if not (data[:4] == b'RIFF' and data[8:12] == b'WAVE'):
|
||||
raise ValueError("Datei ist kein gültiges WAV.")
|
||||
# Andere/unbekannte Audio-Typen: ffmpeg prüft beim Transkodieren.
|
||||
|
||||
|
||||
def safe_media_path(media_dir: str, url: str) -> str | None:
|
||||
"""
|
||||
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
|
||||
|
|
@ -69,6 +100,21 @@ def safe_media_path(media_dir: str, url: str) -> str | None:
|
|||
return candidate
|
||||
|
||||
|
||||
def delete_media_files(media_dir: str, urls) -> None:
|
||||
"""Löscht mehrere Mediendateien samt _preview.webp/_thumb.jpg-Leichen von Disk
|
||||
(best-effort, Path-Traversal-sicher). Für Cascade-Cleanup bei Lösch-Operationen."""
|
||||
for url in urls or []:
|
||||
fp = safe_media_path(media_dir, url)
|
||||
if not fp:
|
||||
continue
|
||||
base = os.path.splitext(fp)[0]
|
||||
for path in (fp, base + "_preview.webp", base + "_thumb.jpg"):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def to_jpeg_if_heic(data: bytes, filename: str) -> Tuple[bytes, str]:
|
||||
"""Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types."""
|
||||
ext = os.path.splitext(filename or "")[1].lower()
|
||||
|
|
@ -122,6 +168,44 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
|
|||
pass
|
||||
|
||||
|
||||
def to_m4a(data: bytes, src_ext: str) -> Tuple[bytes, str]:
|
||||
"""Transkodiert Audio nach m4a/AAC — universell abspielbar, auch auf iOS.
|
||||
Nötig, weil Chrome/Firefox Opus-in-WebM/Ogg aufnehmen, das iOS Safari NICHT
|
||||
abspielen kann. Bereits-AAC-Container (.m4a/.aac/.mp4, u.a. iOS-Recorder)
|
||||
werden ohne Transkodierung durchgereicht. Bei ffmpeg-Fehler: Original zurück."""
|
||||
ext = (src_ext or "").lower()
|
||||
if not ext.startswith("."):
|
||||
ext = ("." + ext) if ext else ".webm"
|
||||
if ext in _AUDIO_AAC_EXTS:
|
||||
return data, ".m4a"
|
||||
src_path = dst_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as src:
|
||||
src.write(data)
|
||||
src_path = src.name
|
||||
dst_path = src_path[: -len(ext)] + ".m4a"
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-i", src_path,
|
||||
"-vn", "-c:a", "aac", "-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
"-y", dst_path],
|
||||
capture_output=True, timeout=120,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(dst_path, "rb") as f:
|
||||
return f.read(), ".m4a"
|
||||
return data, ext
|
||||
except Exception:
|
||||
return data, ext
|
||||
finally:
|
||||
for p in [src_path, dst_path]:
|
||||
if p:
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_gps_from_exif(data: bytes) -> tuple | None:
|
||||
"""EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)):
|
|||
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
|
||||
# Notiz-Medien: erst Dateien von Disk, dann DB-Zeilen (note_media + notes).
|
||||
import os as _os
|
||||
from media_utils import delete_media_files
|
||||
_nm_urls = [r["url"] for r in conn.execute(
|
||||
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
|
||||
(uid,)
|
||||
).fetchall()]
|
||||
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _nm_urls)
|
||||
conn.execute("DELETE FROM note_media WHERE note_id IN (SELECT id FROM notes WHERE user_id=?)", (uid,))
|
||||
conn.execute("DELETE FROM notes WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
|
||||
|
||||
|
|
@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)):
|
|||
@router.get("/ki/status")
|
||||
async def ki_status(user=Depends(require_mod)):
|
||||
import httpx
|
||||
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
|
||||
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY
|
||||
|
||||
result = {
|
||||
"mode": KI_MODE,
|
||||
|
|
@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)):
|
|||
"local_reachable": False,
|
||||
"local_model_loaded": None,
|
||||
"cloud_model": CLOUD_MODEL,
|
||||
"vision_model": VISION_MODEL,
|
||||
"cloud_key_set": bool(ANTHROPIC_KEY),
|
||||
}
|
||||
|
||||
|
|
@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
|
|||
async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)):
|
||||
from scraper.breed_evaluator import evaluate_enrichment
|
||||
sample = max(5, min(sample, 50))
|
||||
return await evaluate_enrichment(sample_size=sample)
|
||||
return await evaluate_enrichment(sample_size=sample, user_id=user["id"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
|
|||
def _sync_call():
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
return client.messages.create(
|
||||
model="claude-opus-4-7",
|
||||
model=ki_module.VISION_MODEL,
|
||||
max_tokens=500,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
"""BAN YARO — Notizen Routes"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Any, List
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from timeutils import safe_client_time
|
||||
from media_utils import (convert_media, extract_video_thumb, safe_media_path,
|
||||
validate_upload, validate_audio, to_m4a,
|
||||
generate_preview, get_image_size)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class NoteCreate(BaseModel):
|
||||
text: str = Field(..., min_length=1, max_length=5000)
|
||||
# Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht)
|
||||
# wird zuerst leer angelegt, dann werden die Medien angehängt.
|
||||
text: str = Field("", max_length=5000)
|
||||
meta_json: Optional[Any] = None
|
||||
location_name: Optional[str] = Field(None, max_length=300)
|
||||
parent_label: Optional[str] = Field(None, max_length=200)
|
||||
|
|
@ -35,16 +44,81 @@ class NoteUpdate(BaseModel):
|
|||
# ------------------------------------------------------------------
|
||||
# Hilfsfunktionen
|
||||
# ------------------------------------------------------------------
|
||||
def _serialize(row) -> dict:
|
||||
def _serialize(row, media_map: Optional[dict] = None) -> dict:
|
||||
d = dict(row)
|
||||
if d.get("meta_json") and isinstance(d["meta_json"], str):
|
||||
try:
|
||||
d["meta_json"] = json.loads(d["meta_json"])
|
||||
except Exception:
|
||||
pass
|
||||
if media_map is not None:
|
||||
d["media_items"] = media_map.get(d["id"], [])
|
||||
return d
|
||||
|
||||
|
||||
def _fetch_note_media(conn, note_ids: list) -> dict:
|
||||
"""Lädt alle Medien zu den gegebenen Notiz-IDs als {note_id: [items]}."""
|
||||
if not note_ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" * len(note_ids))
|
||||
rows = conn.execute(
|
||||
f"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
|
||||
FROM note_media WHERE note_id IN ({placeholders})
|
||||
ORDER BY sort_order, id""",
|
||||
note_ids
|
||||
).fetchall()
|
||||
out: dict = {}
|
||||
for r in rows:
|
||||
out.setdefault(r["note_id"], []).append(dict(r))
|
||||
return out
|
||||
|
||||
|
||||
def _guess_note_media_type(content_type: str, filename: str) -> str:
|
||||
ct = (content_type or "").lower()
|
||||
if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"):
|
||||
return "pdf"
|
||||
if ct.startswith("audio/"):
|
||||
return "audio"
|
||||
if ct.startswith("video/"):
|
||||
return "video"
|
||||
if ct.startswith("image/"):
|
||||
return "image"
|
||||
ext = os.path.splitext(filename or "")[1].lower()
|
||||
if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}:
|
||||
return "video"
|
||||
if ext in {".m4a", ".aac", ".mp3", ".ogg", ".oga", ".wav", ".opus"}:
|
||||
return "audio"
|
||||
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}:
|
||||
return "image"
|
||||
return "file"
|
||||
|
||||
|
||||
def _delete_note_media_file(url: str) -> None:
|
||||
"""Löscht eine Mediendatei + zugehörige Preview/Thumb-Leichen von Disk."""
|
||||
file_path = safe_media_path(MEDIA_DIR, url)
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
base = os.path.splitext(file_path)[0]
|
||||
for leftover in (base + "_preview.webp", base + "_thumb.jpg"):
|
||||
try:
|
||||
os.remove(leftover)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _own_note(note_id: int, user_id: int, conn):
|
||||
row = conn.execute(
|
||||
"SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
||||
return row
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/notes — Gesamt-Notizblock mit Filtern
|
||||
# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
|
||||
|
|
@ -97,8 +171,9 @@ async def list_all_notes_filtered(
|
|||
f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
|
||||
params
|
||||
).fetchall()
|
||||
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||
|
||||
return [_serialize(r) for r in rows]
|
||||
return [_serialize(r, media_map) for r in rows]
|
||||
|
||||
|
||||
@router.get("/all/0")
|
||||
|
|
@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)):
|
|||
"SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
return [_serialize(r) for r in rows]
|
||||
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||
return [_serialize(r, media_map) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)):
|
|||
return {"suggestions": suggestions, "note_count": note_count}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Medien-Anhänge an Notizen (Bild/Video/Audio/Datei)
|
||||
# WICHTIG: Diese Routen MÜSSEN vor /{parent_type}/{parent_id} stehen,
|
||||
# sonst matcht POST /123/media als parent_type=123, parent_id="media"!
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/{note_id}/media")
|
||||
async def upload_note_media(note_id: int,
|
||||
file: UploadFile = File(...),
|
||||
user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_own_note(note_id, user["id"], conn)
|
||||
|
||||
ct = (file.content_type or "").lower().split(";")[0].strip()
|
||||
raw_data = await file.read()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if ct.startswith("audio/"):
|
||||
media_type = "audio"
|
||||
try:
|
||||
validate_audio(raw_data, ct)
|
||||
except ValueError as e:
|
||||
raise HTTPException(415, str(e))
|
||||
src_ext = os.path.splitext(file.filename or "")[1].lower() or ".webm"
|
||||
raw_data, ext = await loop.run_in_executor(None, lambda: to_m4a(raw_data, src_ext))
|
||||
else:
|
||||
# Bild/Video/PDF/sonstige Datei — gleiche Pipeline wie Tagebuch.
|
||||
try:
|
||||
validate_upload(raw_data, file.filename or "")
|
||||
except ValueError as e:
|
||||
raise HTTPException(415, str(e))
|
||||
media_type = _guess_note_media_type(ct, file.filename or "")
|
||||
raw_data, ext = await loop.run_in_executor(
|
||||
None, lambda: convert_media(raw_data, file.filename or "")
|
||||
)
|
||||
if not ext:
|
||||
ext = ".bin"
|
||||
|
||||
filename = f"note_{note_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
path = os.path.join(MEDIA_DIR, "notes", filename)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
def _write_bytes(p: str, data: bytes) -> None:
|
||||
with open(p, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data))
|
||||
|
||||
img_size = None
|
||||
if media_type == "video":
|
||||
await loop.run_in_executor(None, lambda: extract_video_thumb(path))
|
||||
elif media_type == "image":
|
||||
preview_bytes = await loop.run_in_executor(None, lambda: generate_preview(raw_data, ext))
|
||||
if preview_bytes:
|
||||
preview_path = os.path.splitext(path)[0] + "_preview.webp"
|
||||
await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes))
|
||||
img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data))
|
||||
|
||||
media_url = f"/media/notes/{filename}"
|
||||
with db() as conn:
|
||||
max_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) FROM note_media WHERE note_id=?",
|
||||
(note_id,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"""INSERT INTO note_media (note_id, url, media_type, sort_order, img_width, img_height)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(note_id, media_url, media_type, max_order + 1,
|
||||
img_size[0] if img_size else None, img_size[1] if img_size else None)
|
||||
)
|
||||
row = conn.execute(
|
||||
"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s
|
||||
FROM note_media WHERE note_id=? ORDER BY id DESC LIMIT 1""",
|
||||
(note_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/{note_id}/media/{media_id}", status_code=204)
|
||||
async def delete_note_media(note_id: int, media_id: int,
|
||||
user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_own_note(note_id, user["id"], conn)
|
||||
row = conn.execute(
|
||||
"SELECT id, url FROM note_media WHERE id=? AND note_id=?",
|
||||
(media_id, note_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Medium nicht gefunden.")
|
||||
_delete_note_media_file(row["url"])
|
||||
conn.execute("DELETE FROM note_media WHERE id=?", (media_id,))
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/notes/{parent_type}/{parent_id}
|
||||
# parent_id kann ein Integer oder ein String-Schlüssel sein.
|
||||
|
|
@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str,
|
|||
ORDER BY created_at DESC""",
|
||||
(user["id"], parent_type, parent_id)
|
||||
).fetchall()
|
||||
return [_serialize(r) for r in rows]
|
||||
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||
return [_serialize(r, media_map) for r in rows]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str,
|
|||
@router.post("/{parent_type}/{parent_id}", status_code=201)
|
||||
async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
|
||||
user=Depends(get_current_user)):
|
||||
if not data.text.strip():
|
||||
raise HTTPException(400, "Notiz darf nicht leer sein.")
|
||||
|
||||
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
|
||||
now = safe_client_time(data.client_time)
|
||||
|
||||
|
|
@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
|
|||
"SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
|
||||
(user["id"], parent_type, parent_id)
|
||||
).fetchone()
|
||||
return _serialize(row)
|
||||
return _serialize(row, {}) # frisch erstellt → media_items=[]; Upload folgt separat
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate,
|
|||
|
||||
updates = {}
|
||||
if data.text is not None:
|
||||
if not data.text.strip():
|
||||
raise HTTPException(400, "Notiz darf nicht leer sein.")
|
||||
# Leer erlaubt — Medien können die Notiz tragen.
|
||||
updates["text"] = data.text.strip()
|
||||
if data.meta_json is not None:
|
||||
updates["meta_json"] = json.dumps(data.meta_json)
|
||||
|
|
@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate,
|
|||
updates["parent_label"] = data.parent_label
|
||||
|
||||
if not updates:
|
||||
return _serialize(note)
|
||||
media_map = _fetch_note_media(conn, [note_id])
|
||||
return _serialize(note, media_map)
|
||||
|
||||
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
values = list(updates.values()) + [note_id]
|
||||
conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
|
||||
row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
|
||||
return _serialize(row)
|
||||
media_map = _fetch_note_media(conn, [note_id])
|
||||
return _serialize(row, media_map)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)):
|
|||
).fetchone()
|
||||
if not note:
|
||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
||||
# Medien-Dateien von Disk räumen (FK-Cascade löscht nur die DB-Zeilen).
|
||||
for m in conn.execute("SELECT url FROM note_media WHERE note_id=?", (note_id,)).fetchall():
|
||||
_delete_note_media_file(m["url"])
|
||||
conn.execute("DELETE FROM notes WHERE id=?", (note_id,))
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)):
|
|||
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
|
||||
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
|
||||
|
||||
# note_media-Dateien von Disk räumen — der FK-Cascade beim users-DELETE
|
||||
# entfernt nur die DB-Zeilen, nicht die Dateien.
|
||||
import os as _os
|
||||
from media_utils import delete_media_files
|
||||
_note_media_urls = [r["url"] for r in conn.execute(
|
||||
"SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?",
|
||||
(uid,)
|
||||
).fetchall()]
|
||||
delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls)
|
||||
|
||||
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
return {"status": "deleted"}
|
||||
|
|
|
|||
|
|
@ -989,6 +989,7 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str:
|
|||
from datetime import date, timedelta
|
||||
|
||||
since = (date.today() - timedelta(days=7)).isoformat()
|
||||
week_num = date.today().isocalendar()[1]
|
||||
name = dog["name"]
|
||||
rasse = dog.get("rasse") or "Hund"
|
||||
|
||||
|
|
@ -1029,16 +1030,30 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str:
|
|||
else:
|
||||
aktivitaet_text = ", ".join(aktivitaet_parts)
|
||||
|
||||
# W\u00f6chentlich rotierender Fokus \u2192 die KI klingt nicht jede Woche gleich.
|
||||
_toene = [
|
||||
"Betone die enge Verbundenheit zwischen {name} und dir.",
|
||||
"Hebe die kleinen Abenteuer und besonderen Momente hervor.",
|
||||
"W\u00fcrdige die sch\u00f6ne gemeinsame Routine und Verl\u00e4sslichkeit.",
|
||||
"Feiere das gemeinsame Wachsen und die Fortschritte.",
|
||||
"Betone Ruhe, Geborgenheit und Vertrauen.",
|
||||
"Schreibe verspielt und mit einem Augenzwinkern.",
|
||||
]
|
||||
ton = _toene[week_num % len(_toene)].replace("{name}", name)
|
||||
|
||||
prompt = f"""Du bist ein warmer, wohlwollender Begleiter f\u00fcr Hundebesitzer. Schreibe eine kurze pers\u00f6nliche Lob-Nachricht (2-3 S\u00e4tze) f\u00fcr die vergangene Woche.
|
||||
|
||||
Hund: {name} ({rasse})
|
||||
Letzte 7 Tage: {aktivitaet_text}
|
||||
Dabei seit: {stats.get('weeks_total', 1)} Wochen
|
||||
|
||||
Fokus dieser Woche: {ton}
|
||||
|
||||
Regeln (unbedingt einhalten):
|
||||
- Nur loben, NIEMALS Ratschl\u00e4ge geben oder auf Fehlendes hinweisen
|
||||
- Sprich \u00fcber den Hund: "{name} hatte eine tolle Woche" \u2014 nicht \u00fcber den Besitzer
|
||||
- Auch bei 0 Aktivit\u00e4ten: positive Formulierung (\u201eAuch ruhige Wochen geh\u00f6ren dazu\u201c)
|
||||
- Variiere Einstieg und Wortwahl \u2014 klinge NICHT wie letzte Woche
|
||||
- Erw\u00e4hne KEINE Wochenzahl und keine nackten Statistik-Zahlen
|
||||
- Maximal 3 kurze S\u00e4tze
|
||||
- Warm, pers\u00f6nlich, keine Floskeln
|
||||
- Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen"""
|
||||
|
|
@ -1051,11 +1066,24 @@ Regeln (unbedingt einhalten):
|
|||
)
|
||||
return text.strip()
|
||||
except Exception:
|
||||
# Fallback wenn KI nicht verfügbar
|
||||
if aktivitaet_parts:
|
||||
return f"{name} hatte eine aktive Woche \u2014 {aktivitaet_text}. Das ist toll! \U0001f43e"
|
||||
else:
|
||||
return f"Auch ruhige Wochen geh\u00f6ren dazu. {name} wei\u00df, dass du f\u00fcr ihn da bist. \U0001f43e"
|
||||
# Fallback wenn KI nicht verfügbar — Varianten-Pool, deterministisch pro
|
||||
# Woche+Hund gewählt, damit der Text nicht jede Woche identisch klingt.
|
||||
aktiv_varianten = [
|
||||
f"{name} hatte eine richtig aktive Woche — {aktivitaet_text}. Stark! 🐾",
|
||||
f"Was für eine Woche, {name}! {aktivitaet_text} — das kann sich sehen lassen. 🌟",
|
||||
f"{name} war diese Woche voll dabei: {aktivitaet_text}. Weiter so! 🐶",
|
||||
f"Tolle Woche mit {name} — {aktivitaet_text}. Ihr seid ein super Team! 🐾",
|
||||
f"{aktivitaet_text} — dafür hat sich {name} eine extra Streicheleinheit verdient. ✨",
|
||||
]
|
||||
ruhig_varianten = [
|
||||
f"Auch ruhige Wochen gehören dazu. {name} weiß, dass du für ihn da bist. 🐾",
|
||||
f"Diese Woche war's gemütlich — und das ist völlig okay. {name} genießt die Zeit mit dir. 🌿",
|
||||
f"Nicht jede Woche muss voll sein. {name} fühlt sich bei dir einfach wohl. ☀️",
|
||||
f"Eine entspannte Woche mit {name} — manchmal ist genau das das Schönste. 🐾",
|
||||
f"{name} und du — auch ohne großes Programm seid ihr ein eingespieltes Team. 🐶",
|
||||
]
|
||||
pool = aktiv_varianten if aktivitaet_parts else ruhig_varianten
|
||||
return pool[(week_num + dog["id"]) % len(pool)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -360,30 +360,47 @@ async def _fetch_wikimedia_photo(name: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
async def _haiku_complete(prompt: str) -> str:
|
||||
"""Claude Haiku direkt aufrufen (immer Cloud, für maximale Genauigkeit)."""
|
||||
import anthropic
|
||||
async def _haiku_complete(prompt: str) -> tuple[str, str]:
|
||||
"""
|
||||
Fakten-Extraktion. Bevorzugt Claude Haiku (günstig + genau); ist kein
|
||||
Cloud-Key gesetzt oder die Cloud nicht erreichbar, fällt es sauber auf das
|
||||
lokale Modell (LM Studio) zurück, statt hart abzubrechen.
|
||||
|
||||
Returns (text, model) — model fließt in wiki_rassen.ki_model, damit der
|
||||
Evaluator lokal-angereicherte Rassen weiterhin zur QC erkennt.
|
||||
"""
|
||||
key = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
if not key:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt")
|
||||
|
||||
def _call():
|
||||
client = anthropic.Anthropic(api_key=key)
|
||||
return client.messages.create(
|
||||
model=_HAIKU_MODEL,
|
||||
max_tokens=700,
|
||||
system=[{
|
||||
"type": "text",
|
||||
"text": _SYSTEM,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}],
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
# 1. Bevorzugt: Claude Haiku direkt (günstigstes Cloud-Modell)
|
||||
if key:
|
||||
try:
|
||||
import anthropic
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
resp = await loop.run_in_executor(None, _call)
|
||||
return resp.content[0].text.strip()
|
||||
def _call():
|
||||
client = anthropic.Anthropic(api_key=key)
|
||||
return client.messages.create(
|
||||
model=_HAIKU_MODEL,
|
||||
max_tokens=700,
|
||||
system=[{
|
||||
"type": "text",
|
||||
"text": _SYSTEM,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}],
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
resp = await loop.run_in_executor(None, _call)
|
||||
return resp.content[0].text.strip(), _HAIKU_MODEL
|
||||
except Exception as e:
|
||||
logger.warning("Haiku (Cloud) nicht erreichbar, Fallback lokal: %s", e)
|
||||
|
||||
# 2. Fallback: lokales Modell über die zentrale KI-Abstraktion
|
||||
import ki
|
||||
if ki.KI_MODE == "off":
|
||||
raise RuntimeError("Kein Cloud-Key und KI_MODE=off — Anreicherung nicht möglich.")
|
||||
text = await ki._local_complete(prompt, _SYSTEM, max_tokens=700, json_mode=False)
|
||||
return text, ki.LOCAL_MODEL
|
||||
|
||||
|
||||
async def _enrich_one(rasse, dry_run: bool = False) -> bool:
|
||||
|
|
@ -411,12 +428,12 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool:
|
|||
logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text))
|
||||
return True
|
||||
|
||||
# 2. Haiku extrahiert Fakten aus dem Quelltext
|
||||
# 2. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback)
|
||||
prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text)
|
||||
try:
|
||||
raw = await _haiku_complete(prompt)
|
||||
raw, used_model = await _haiku_complete(prompt)
|
||||
except Exception as e:
|
||||
logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e)
|
||||
logger.error("KI-Anfrage fehlgeschlagen für %s: %s", name, e)
|
||||
await asyncio.sleep(3)
|
||||
return False
|
||||
|
||||
|
|
@ -435,7 +452,7 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool:
|
|||
if "temperament" in updates:
|
||||
updates["temperament"] = translate_temperament(updates["temperament"])
|
||||
updates["ki_enriched"] = 1
|
||||
updates["ki_model"] = _HAIKU_MODEL
|
||||
updates["ki_model"] = used_model
|
||||
updates["ki_source"] = f"wikipedia_{wiki_lang}"
|
||||
|
||||
cols = ", ".join(f"{k}=?" for k in updates)
|
||||
|
|
|
|||
|
|
@ -43,19 +43,23 @@ Aktivität zur Erfahrung)?
|
|||
'''
|
||||
|
||||
|
||||
async def evaluate_enrichment(sample_size: int = 20) -> dict:
|
||||
async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) -> dict:
|
||||
"""
|
||||
Bewertet `sample_size` zufällig gewählte angereicherte Rassen via Claude.
|
||||
Bewertet `sample_size` zufällig gewählte angereicherte Rassen als LLM-as-Judge.
|
||||
|
||||
Läuft über die zentrale KI-Abstraktion (ki.complete). Admins/Moderatoren werden
|
||||
dort Cloud-priorisiert (Claude); ist die Cloud nicht erreichbar, fällt die
|
||||
Bewertung sauber auf das lokale Modell zurück, statt hart abzubrechen.
|
||||
|
||||
Returns dict mit aggregierten Scores und Einzelergebnissen.
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from database import db
|
||||
import ki
|
||||
|
||||
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||
if not ANTHROPIC_KEY:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt — Evaluierung benötigt Cloud.")
|
||||
if ki.KI_MODE == "off":
|
||||
raise RuntimeError("KI ist deaktiviert (KI_MODE=off) — Evaluierung nicht möglich.")
|
||||
|
||||
with db() as conn:
|
||||
rassen = conn.execute(
|
||||
|
|
@ -65,8 +69,7 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
|
|||
wohnung_geeignet, temperament, ki_model
|
||||
FROM wiki_rassen
|
||||
WHERE ki_enriched = 1
|
||||
AND ki_model IS NOT NULL
|
||||
AND ki_model NOT LIKE 'claude%'
|
||||
AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%')
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?""",
|
||||
(sample_size,),
|
||||
|
|
@ -75,10 +78,10 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
|
|||
if not rassen:
|
||||
return {"error": "Keine angereicherten Rassen gefunden."}
|
||||
|
||||
import anthropic
|
||||
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
|
||||
_EVAL_SYSTEM = "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON."
|
||||
|
||||
results = []
|
||||
sources = set()
|
||||
totals = {"vollstaendigkeit": 0, "korrektheit": 0,
|
||||
"sprachqualitaet": 0, "konsistenz": 0, "gesamt": 0}
|
||||
|
||||
|
|
@ -102,22 +105,17 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
|
|||
data=json.dumps(data, ensure_ascii=False, indent=2),
|
||||
)
|
||||
try:
|
||||
def _call():
|
||||
return client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=256,
|
||||
system=[{
|
||||
"type": "text",
|
||||
"text": "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON.",
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}],
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
resp = await loop.run_in_executor(None, _call)
|
||||
raw = resp.content[0].text.strip()
|
||||
raw, source = await ki.complete(
|
||||
prompt,
|
||||
system=_EVAL_SYSTEM,
|
||||
max_tokens=256,
|
||||
json_mode=True,
|
||||
user_id=user_id,
|
||||
return_source=True,
|
||||
)
|
||||
sources.add(source)
|
||||
|
||||
# JSON extrahieren
|
||||
# JSON extrahieren (lokale Modelle wrappen gern in ```json … ```)
|
||||
import re
|
||||
match = re.search(r"\{[\s\S]+\}", raw)
|
||||
scores = json.loads(match.group(0)) if match else {}
|
||||
|
|
@ -136,9 +134,12 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
|
|||
count = len([r for r in results if "error" not in r])
|
||||
averages = {k: round(v / count, 2) for k, v in totals.items()} if count else {}
|
||||
|
||||
judge_source = "/".join(sorted(sources)) if sources else "unbekannt"
|
||||
|
||||
return {
|
||||
"sample_size": len(rassen),
|
||||
"evaluated": count,
|
||||
"averages": averages,
|
||||
"judge_source": judge_source, # "cloud" (Claude) oder "local" (LM Studio)
|
||||
"results": results,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3088,12 +3088,23 @@ html.modal-open {
|
|||
}
|
||||
.rdr-play svg { width: 14px; height: 14px; }
|
||||
.rdr-play:active { background: var(--c-border); }
|
||||
.rdr-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; }
|
||||
.rdr-track-wrap { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; }
|
||||
.rdr-slider { width: 100%; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; }
|
||||
/* "Jetzt"-Markierung in der Mitte der Zeitleiste (Fangpunkt) */
|
||||
.rdr-now-tick {
|
||||
position: absolute; left: 50%; top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 2px; height: 13px;
|
||||
background: var(--c-primary); opacity: 0.45;
|
||||
border-radius: 1px; pointer-events: none; z-index: 1;
|
||||
}
|
||||
.rdr-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px; font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 74px; text-align: right; color: var(--c-text-secondary);
|
||||
width: 112px; /* FESTE Breite → Regler bleibt immer gleich lang */
|
||||
white-space: nowrap; overflow: hidden;
|
||||
text-align: right; color: var(--c-text-secondary);
|
||||
}
|
||||
.rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@
|
|||
<li><strong>Accountdaten:</strong> Benutzername, E-Mail-Adresse, Passphrase (verschlüsselt gespeichert)</li>
|
||||
<li><strong>Hundeprofil:</strong> Name, Rasse, Alter, Foto (freiwillig)</li>
|
||||
<li><strong>Gesundheitsdaten deines Hundes:</strong> Gewicht, Impfungen, Tierarztbesuche, Medikamente (freiwillig, nur für dich sichtbar)</li>
|
||||
<li><strong>Tagebuch & Notizen:</strong> Texte, Fotos, Stimmungseinträge (privat, nur für dich)</li>
|
||||
<li><strong>Tagebuch & Notizen:</strong> Texte, Fotos, Videos, Sprachnachrichten, Stimmungseinträge (privat, nur für dich)</li>
|
||||
<li><strong>Standortdaten:</strong> Nur nach expliziter Browser-Freigabe — für Karte, Gassi-Treffen,
|
||||
Giftköder-Meldungen, Nearby-Alerts und Routenaufzeichnung. Standortdaten werden nicht dauerhaft
|
||||
gespeichert, außer du speicherst selbst eine Route oder Meldung.</li>
|
||||
|
|
@ -94,6 +94,9 @@
|
|||
<li><strong>Fotos & EXIF-Daten:</strong> Beim Hochladen von Bildern können GPS-Koordinaten
|
||||
in den EXIF-Metadaten enthalten sein. Diese werden serverseitig ausgelesen, um Fotos auf der
|
||||
Karte zu verorten — sofern vorhanden. Die Rohdaten werden nicht separat gespeichert.</li>
|
||||
<li><strong>Mikrofon (Sprachnachrichten):</strong> Nur nach expliziter Browser-Freigabe und nur,
|
||||
während du in einer Notiz aktiv eine Sprachnachricht aufnimmst. Die Aufnahme wird ausschließlich
|
||||
auf unseren eigenen Servern gespeichert (kein Drittanbieter), ist privat und nur für dich sichtbar.</li>
|
||||
<li><strong>Inhalte:</strong> Forenbeiträge, Chatnachrichten, öffentliche Gassi-Treffen</li>
|
||||
<li><strong>Technische Daten:</strong> IP-Adresse (für Sicherheit und Rate-Limiting, max. 30 Tage),
|
||||
Browser-Typ</li>
|
||||
|
|
@ -508,7 +511,7 @@
|
|||
</section>
|
||||
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
||||
Stand: Juni 2026 · Version 5
|
||||
Stand: Juni 2026 · Version 6
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1278"></script>
|
||||
<script src="/js/boot-early.js?v=1292"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1292">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -620,12 +620,12 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1278"></script>
|
||||
<script src="/js/ui.js?v=1278"></script>
|
||||
<script src="/js/app.js?v=1278"></script>
|
||||
<script src="/js/worlds.js?v=1278"></script>
|
||||
<script src="/js/offline-indicator.js?v=1278"></script>
|
||||
<script src="/js/contact-form.js?v=1278"></script>
|
||||
<script src="/js/api.js?v=1292"></script>
|
||||
<script src="/js/ui.js?v=1292"></script>
|
||||
<script src="/js/app.js?v=1292"></script>
|
||||
<script src="/js/worlds.js?v=1292"></script>
|
||||
<script src="/js/offline-indicator.js?v=1292"></script>
|
||||
<script src="/js/contact-form.js?v=1292"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -635,7 +635,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1278"></script>
|
||||
<script src="/js/boot.js?v=1292"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -675,6 +675,12 @@ const API = (() => {
|
|||
delete(id) {
|
||||
return del(`/notes/${id}`);
|
||||
},
|
||||
uploadMedia(noteId, formData) {
|
||||
return upload(`/notes/${noteId}/media`, formData);
|
||||
},
|
||||
deleteMedia(noteId, mediaId) {
|
||||
return del(`/notes/${noteId}/media/${mediaId}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1278'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1292'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
|
||||
window.APP_VERSION = APP_VERSION;
|
||||
|
|
|
|||
|
|
@ -1419,7 +1419,10 @@ window.Page_admin = (() => {
|
|||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
res.textContent = `✓ Bewertung abgeschlossen`;
|
||||
const judge = d.judge_source === 'cloud' ? 'Claude (Cloud)'
|
||||
: d.judge_source === 'local' ? 'lokales Modell ⚠︎'
|
||||
: (d.judge_source || '–');
|
||||
res.textContent = `✓ Bewertung abgeschlossen — Prüfer: ${judge}`;
|
||||
} catch (err) {
|
||||
res.textContent = '✗ Fehler: ' + (err.message || err);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -283,6 +283,11 @@ window.Page_diary = (() => {
|
|||
|
||||
if (!data?.praise) return;
|
||||
|
||||
// Weggeklickten Wochenrückblick nicht erneut zeigen (pro Kalenderwoche).
|
||||
// Nächste Woche (neuer week_key) erscheint er wieder.
|
||||
const praiseKey = data.week_key || '';
|
||||
if (praiseKey && localStorage.getItem('by_diary_praise_dismissed') === praiseKey) return;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.id = 'diary-praise-card';
|
||||
card.style.cssText = `
|
||||
|
|
@ -316,6 +321,7 @@ window.Page_diary = (() => {
|
|||
if (list && list.parentNode) list.parentNode.insertBefore(card, list);
|
||||
|
||||
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
|
||||
if (praiseKey) { try { localStorage.setItem('by_diary_praise_dismissed', praiseKey); } catch (_) {} }
|
||||
card.style.opacity = '0';
|
||||
card.style.transition = 'opacity .2s';
|
||||
setTimeout(() => card.remove(), 200);
|
||||
|
|
|
|||
|
|
@ -451,6 +451,9 @@ window.Page_map = (() => {
|
|||
let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit)
|
||||
let _radarPlaying = false;
|
||||
let _radarPlayTimer = null;
|
||||
let _radarLayerKind = null; // 'rv' (RainViewer-PNG) | 'dwd' (pmtiles) — für sauberen Layer-Wechsel
|
||||
let _rdrPendingIdx = null; // Regler-Entprellung: zuletzt gewünschter Frame
|
||||
let _rdrRaf = null; // requestAnimationFrame-Handle für die Koaleszenz
|
||||
|
||||
async function _toggleRadar() {
|
||||
if (!App.hasPro(_appState?.user)) {
|
||||
|
|
@ -461,7 +464,9 @@ window.Page_map = (() => {
|
|||
if (_radarActive) {
|
||||
_radarActive = false;
|
||||
_radarPause();
|
||||
if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; }
|
||||
if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; _radarLayerKind = null; }
|
||||
if (_rdrRaf != null) { cancelAnimationFrame(_rdrRaf); _rdrRaf = null; }
|
||||
_rdrPendingIdx = null;
|
||||
clearInterval(_radarTimer);
|
||||
document.getElementById('map-radar-timeline')?.remove();
|
||||
btn?.classList.remove('active');
|
||||
|
|
@ -602,67 +607,107 @@ window.Page_map = (() => {
|
|||
async function _loadRadar() {
|
||||
if (!_radarActive || !_map) return;
|
||||
try {
|
||||
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
|
||||
// Cache-Buster: sonst liefert der Service-Worker u. U. einen alten RainViewer-Stand
|
||||
// (Frames hingen ~50 min nach → DWD-Frische-Check fiel durch, Gerätetest 2026-06-09).
|
||||
const resp = await fetch(`https://api.rainviewer.com/public/weather-maps.json?_t=${Date.now()}`, { cache: 'no-store' });
|
||||
const data = await resp.json();
|
||||
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
|
||||
if (!past.length && !nowcast.length) return;
|
||||
_radarHost = data.host || _radarHost;
|
||||
const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`;
|
||||
|
||||
// Default: RainViewer komplett (~2h Vergangenheit + ~30 min Nowcast)
|
||||
let frames = [...past, ...nowcast].map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
let nowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame
|
||||
// Symmetrische ±2h-Zeitleiste: letzte 2 h (RainViewer) | jetzt | nächste 2 h (DWD/Nowcast)
|
||||
const WINDOW = 2 * 60 * 60; // 2 h je Seite
|
||||
const nowSec = Math.floor(Date.now() / 1000); // Echtzeit-Referenz (Geräteuhr ist zuverlässig)
|
||||
|
||||
// DWD-Vorhersage (0–120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast,
|
||||
// Vergangenheit bleibt RainViewer (docs/DWD_RAIN_FORECAST_PLAN.md).
|
||||
// Vergangenheit: RainViewer der letzten 2 h
|
||||
let pastFrames = past
|
||||
.filter(f => f.time >= nowSec - WINDOW && f.time <= nowSec)
|
||||
.map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
|
||||
// "Jetzt" + Zukunft — Default: RainViewer-Nowcast (~30 min)
|
||||
let nowFrame = null;
|
||||
let futureFrames = nowcast
|
||||
.filter(f => f.time > nowSec && f.time <= nowSec + WINDOW)
|
||||
.map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
|
||||
// DWD-Vorhersage (0–120 min, 5-Min-Schritte) bevorzugt (docs/DWD_RAIN_FORECAST_PLAN.md)
|
||||
if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) {
|
||||
try {
|
||||
const r = await fetch('/radar/manifest.json', { cache: 'no-store' });
|
||||
if (r.ok) {
|
||||
const man = await r.json();
|
||||
const runT = Math.floor(Date.parse(man.run_time_utc) / 1000);
|
||||
// Nur wenn der Lauf frisch ist (< 30 min) — sonst RainViewer-Fallback
|
||||
if (man.frames?.length && (Date.now() / 1000 - runT) < 1800) {
|
||||
const pastRv = past.filter(f => f.time <= runT).map(f => ({ url: rvUrl(f), time: f.time }));
|
||||
// Frische des DWD-Laufs gegen die ECHTZEIT prüfen (< 30 min) — NICHT gegen den
|
||||
// jüngsten RainViewer-Frame, der deutlich nachhängen kann (sonst fällt DWD raus).
|
||||
if (man.frames?.length && Math.abs(nowSec - runT) < 1800) {
|
||||
const dwd = man.frames.map(fr => ({
|
||||
url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`,
|
||||
time: runT + fr.lead_min * 60,
|
||||
lead: fr.lead_min,
|
||||
dwd: true,
|
||||
}));
|
||||
frames = [...pastRv, ...dwd];
|
||||
nowIdx = pastRv.length; // DWD lead 0 = "jetzt"
|
||||
nowFrame = dwd.find(f => f.lead === 0) || null; // lead 0 = "jetzt"
|
||||
futureFrames = dwd.filter(f => f.lead > 0 && f.time <= runT + WINDOW);
|
||||
pastFrames = pastFrames.filter(f => f.time < runT); // Überlappung mit DWD-"jetzt" vermeiden
|
||||
}
|
||||
}
|
||||
} catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ }
|
||||
}
|
||||
|
||||
_radarFrames = frames;
|
||||
_radarNowIdx = nowIdx;
|
||||
// Kein DWD-"jetzt"? → jüngsten Vergangenheits-Frame (sonst ältesten Zukunfts-Frame) als "jetzt"
|
||||
if (!nowFrame) {
|
||||
if (pastFrames.length) nowFrame = pastFrames.pop();
|
||||
else if (futureFrames.length) nowFrame = futureFrames.shift();
|
||||
}
|
||||
if (!nowFrame) return;
|
||||
|
||||
_radarFrames = [...pastFrames, nowFrame, ...futureFrames];
|
||||
_radarNowIdx = pastFrames.length; // "jetzt" liegt direkt nach der Vergangenheit
|
||||
if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
|
||||
_showRadarFrame(_radarIdx);
|
||||
_buildRadarTimeline();
|
||||
} catch { /* still */ }
|
||||
}
|
||||
|
||||
function _radarUrl(idx) {
|
||||
return _radarFrames[idx].url;
|
||||
}
|
||||
|
||||
// Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu.
|
||||
function _showRadarFrame(idx) {
|
||||
if (!_radarActive || !_radarFrames[idx]) return;
|
||||
_radarIdx = idx;
|
||||
const url = _radarUrl(idx);
|
||||
const f = _radarFrames[idx];
|
||||
const url = f.url;
|
||||
const kind = f.dwd ? 'dwd' : 'rv';
|
||||
const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar');
|
||||
if (src && src.setTiles) {
|
||||
// setTiles nur innerhalb DESSELBEN Quell-Typs (png↔png bzw. pmtiles↔pmtiles).
|
||||
// Beim Wechsel RainViewer↔DWD den Layer komplett neu aufbauen — sonst bleiben die
|
||||
// alten Kacheln stehen (DWD "neutralisiert" die RainViewer-Wolken nicht).
|
||||
if (src && src.setTiles && kind === _radarLayerKind) {
|
||||
src.setTiles([url]);
|
||||
} else {
|
||||
if (_radarLayer) _wxRemoveRaster(_radarLayer);
|
||||
_radarLayer = _wxAddRaster('radar', url, 0.7, 7);
|
||||
_radarLayerKind = kind;
|
||||
}
|
||||
_updateRadarTimelineUI();
|
||||
}
|
||||
|
||||
// Slider-Position (0–1000) ↔ Frame-Index. "jetzt" liegt fix bei 500 (Mitte):
|
||||
// Vergangenheit nutzt die linke, Vorhersage die rechte Hälfte — unabhängig von der
|
||||
// Frame-Anzahl je Seite. So sitzt "jetzt" optisch mittig (Fangpunkt).
|
||||
const RDR_MID = 500, RDR_SNAP = 28;
|
||||
function _radarPosToIdx(pos) {
|
||||
const now = _radarNowIdx, last = _radarFrames.length - 1;
|
||||
if (pos <= RDR_MID) return now > 0 ? Math.round((pos / RDR_MID) * now) : 0;
|
||||
const fut = last - now;
|
||||
return fut > 0 ? now + Math.round(((pos - RDR_MID) / RDR_MID) * fut) : now;
|
||||
}
|
||||
function _radarIdxToPos(idx) {
|
||||
const now = _radarNowIdx, last = _radarFrames.length - 1;
|
||||
if (idx <= now) return now > 0 ? Math.round((idx / now) * RDR_MID) : RDR_MID;
|
||||
const fut = last - now;
|
||||
return fut > 0 ? RDR_MID + Math.round(((idx - now) / fut) * RDR_MID) : RDR_MID;
|
||||
}
|
||||
|
||||
function _buildRadarTimeline() {
|
||||
if (!_radarFrames.length) return;
|
||||
let el = document.getElementById('map-radar-timeline');
|
||||
|
|
@ -674,17 +719,27 @@ window.Page_map = (() => {
|
|||
<button id="rdr-play" class="rdr-play" type="button" aria-label="Abspielen">
|
||||
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#play"></use></svg>
|
||||
</button>
|
||||
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="${_radarFrames.length - 1}" value="${_radarIdx}" step="1" aria-label="Radar-Zeit">
|
||||
<div class="rdr-track-wrap">
|
||||
<span class="rdr-now-tick" aria-hidden="true"></span>
|
||||
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="1000" value="${_radarIdxToPos(_radarIdx)}" step="1" aria-label="Radar-Zeit">
|
||||
</div>
|
||||
<span id="rdr-time" class="rdr-time"></span>`;
|
||||
document.getElementById('central-map')?.appendChild(el);
|
||||
el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay);
|
||||
el.querySelector('#rdr-slider').addEventListener('input', e => {
|
||||
const idx = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück
|
||||
let pos = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück
|
||||
if (Math.abs(pos - RDR_MID) <= RDR_SNAP) { pos = RDR_MID; e.target.value = RDR_MID; } // Fangpunkt "jetzt"
|
||||
_radarPause();
|
||||
_showRadarFrame(idx);
|
||||
// Entprellen: pro Animationsframe nur EIN setTiles, egal wie schnell gezogen wird
|
||||
// (sonst bricht jeder neue Frame die laufenden Kachel-Requests ab → AbortError-Spam).
|
||||
_rdrPendingIdx = _radarPosToIdx(pos);
|
||||
if (_rdrRaf == null) {
|
||||
_rdrRaf = requestAnimationFrame(() => {
|
||||
_rdrRaf = null;
|
||||
if (_rdrPendingIdx != null) { _showRadarFrame(_rdrPendingIdx); _rdrPendingIdx = null; }
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
el.querySelector('#rdr-slider').max = _radarFrames.length - 1;
|
||||
}
|
||||
// Breite an die Status-Pill angleichen → gleiche linke + rechte Kante.
|
||||
const pill = document.querySelector('.map-statusbar');
|
||||
|
|
@ -696,7 +751,7 @@ window.Page_map = (() => {
|
|||
const slider = document.getElementById('rdr-slider');
|
||||
const timeEl = document.getElementById('rdr-time');
|
||||
const playBtn = document.getElementById('rdr-play');
|
||||
if (slider) slider.value = _radarIdx;
|
||||
if (slider) slider.value = _radarIdxToPos(_radarIdx);
|
||||
if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`);
|
||||
const f = _radarFrames[_radarIdx];
|
||||
if (timeEl && f) {
|
||||
|
|
@ -704,8 +759,8 @@ window.Page_map = (() => {
|
|||
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60);
|
||||
const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`);
|
||||
timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`;
|
||||
timeEl.classList.toggle('is-forecast', diffMin > 0);
|
||||
timeEl.textContent = `${hhmm} · ${rel}`; // feste Breite (CSS) → Regler springt nicht
|
||||
timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1008,6 +1063,14 @@ window.Page_map = (() => {
|
|||
center, zoom, attributionControl: false,
|
||||
maxZoom: 19, dragRotate: false, pitchWithRotate: false,
|
||||
});
|
||||
// setTiles bricht beim schnellen Regler-Ziehen laufende Kachel-Requests ab → harmloser
|
||||
// AbortError. Eigener error-Handler verschluckt ihn, lässt echte Fehler aber durch.
|
||||
_map.on('error', (e) => {
|
||||
const err = e && e.error;
|
||||
const msg = (err && ((err.name || '') + ' ' + (err.message || ''))) || String(e || '');
|
||||
if (/abort/i.test(msg)) return;
|
||||
console.warn('MapLibre:', err || e);
|
||||
});
|
||||
// Zwei-Finger-Rotation aus → Pinch ist reines Zoom (weniger moveend, klarere Geste).
|
||||
_map.touchZoomRotate.disableRotation();
|
||||
_map.touchPitch.disable();
|
||||
|
|
|
|||
|
|
@ -305,6 +305,34 @@ window.Page_notes = (() => {
|
|||
`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Medien-Helfer: Preview-URL ableiten + Indikator-Strip für die Karte
|
||||
// ----------------------------------------------------------
|
||||
function _notePreview(url) {
|
||||
if (!url) return url;
|
||||
const dot = url.lastIndexOf('.');
|
||||
if (dot < 0) return url;
|
||||
if (/\.(mp4|webm|mov|avi|m4v)$/i.test(url)) return url.slice(0, dot) + '_thumb.jpg';
|
||||
if (/\.(m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url;
|
||||
return url.slice(0, dot) + '_preview.webp';
|
||||
}
|
||||
|
||||
function _noteMediaStrip(note) {
|
||||
const items = note.media_items || [];
|
||||
if (!items.length) return '';
|
||||
const n = { image: 0, video: 0, audio: 0, pdf: 0, file: 0 };
|
||||
items.forEach(m => { n[m.media_type] = (n[m.media_type] || 0) + 1; });
|
||||
const parts = [];
|
||||
if (n.image) parts.push(['image', n.image]);
|
||||
if (n.video) parts.push(['video-camera', n.video]);
|
||||
if (n.audio) parts.push(['microphone', n.audio]);
|
||||
if (n.pdf + n.file) parts.push(['paperclip', n.pdf + n.file]);
|
||||
if (!parts.length) return '';
|
||||
return `<div class="notes-media-strip" style="display:flex;align-items:center;gap:12px;margin-top:6px;font-size:12px;color:var(--c-text-secondary)">
|
||||
${parts.map(([icon, count]) => `<span style="display:inline-flex;align-items:center;gap:4px">${UI.icon(icon)} ${count}</span>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Notiz-Karte HTML
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -342,7 +370,9 @@ window.Page_notes = (() => {
|
|||
</div>
|
||||
|
||||
<!-- Notiztext -->
|
||||
<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>
|
||||
${note.text ? `<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>` : ''}
|
||||
|
||||
${_noteMediaStrip(note)}
|
||||
|
||||
<!-- Micro-Badges -->
|
||||
${microBadges.length ? `
|
||||
|
|
@ -495,7 +525,20 @@ window.Page_notes = (() => {
|
|||
${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>
|
||||
${note.text ? `<p class="notes-detail-text">${UI.escape(note.text)}</p>` : ''}
|
||||
|
||||
${(note.media_items && note.media_items.length) ? `
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
${note.media_items.map(m => {
|
||||
if (m.media_type === 'image')
|
||||
return `<img class="notes-detail-media-img" data-full="${m.url}" src="${_notePreview(m.url)}" alt="" style="width:100%;max-height:320px;object-fit:cover;border-radius:var(--radius-md);cursor:pointer">`;
|
||||
if (m.media_type === 'video')
|
||||
return `<video src="${m.url}" controls playsinline style="width:100%;max-height:320px;border-radius:var(--radius-md);background:#000"></video>`;
|
||||
if (m.media_type === 'audio')
|
||||
return `<audio controls src="${m.url}" style="width:100%"></audio>`;
|
||||
return `<a href="${m.url}" target="_blank" rel="noopener" class="btn btn-secondary" style="justify-content:flex-start;gap:8px">${UI.icon('file-text')} ${m.media_type === 'pdf' ? 'PDF öffnen' : 'Datei öffnen'}</a>`;
|
||||
}).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
${microBadges.length ? `
|
||||
<div class="list-item-micro-badges">
|
||||
|
|
@ -523,6 +566,16 @@ window.Page_notes = (() => {
|
|||
UI.modal.close();
|
||||
_openEditModal(note);
|
||||
});
|
||||
|
||||
// Bild-Thumbnails → Lightbox; Preview→Original-Fallback (CSP-konform)
|
||||
const _imgItems = (note.media_items || []).filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' }));
|
||||
document.querySelectorAll('.notes-detail-media-img').forEach(img => {
|
||||
img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true });
|
||||
img.addEventListener('click', () => {
|
||||
const idx = _imgItems.findIndex(it => it.url === img.dataset.full);
|
||||
UI.lightbox?.show(_imgItems, Math.max(0, idx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -587,6 +640,12 @@ window.Page_notes = (() => {
|
|||
box-sizing:border-box"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Medien -->
|
||||
<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)">Medien</label>
|
||||
<div id="nc-media"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-gap-3">
|
||||
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
|
||||
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
|
||||
|
|
@ -597,40 +656,52 @@ window.Page_notes = (() => {
|
|||
overlay.innerHTML = _buildContent();
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const _rebind = () => {
|
||||
overlay.querySelectorAll('.nc-cat').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_selType = btn.dataset.type;
|
||||
overlay.innerHTML = _buildContent();
|
||||
_rebind();
|
||||
overlay.querySelector('#nc-text')?.focus();
|
||||
const _media = UI.noteMediaAttacher({ containerId: 'nc-media' });
|
||||
const _remove = () => { _media.destroy(); overlay.remove(); };
|
||||
|
||||
// Kategorie-Wechsel: nur Auswahl + Button-Styles aktualisieren — KEIN
|
||||
// innerHTML-Rebuild, sonst gingen eingegebener Text & angehängte Medien
|
||||
// (und eine laufende Sprachaufnahme) verloren.
|
||||
overlay.querySelectorAll('.nc-cat').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_selType = btn.dataset.type;
|
||||
overlay.querySelectorAll('.nc-cat').forEach(b => {
|
||||
const r = _rubrik(b.dataset.type);
|
||||
const active = b.dataset.type === _selType;
|
||||
b.style.borderColor = active ? r.color : 'var(--c-border)';
|
||||
b.style.background = active ? r.color + '22' : 'var(--c-surface-2)';
|
||||
b.style.color = active ? r.color : 'var(--c-text-secondary)';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
overlay.querySelector('#nc-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||||
overlay.querySelector('#nc-cancel')?.addEventListener('click', _remove);
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) _remove(); });
|
||||
|
||||
overlay.querySelector('#nc-save')?.addEventListener('click', async () => {
|
||||
const text = overlay.querySelector('#nc-text')?.value?.trim();
|
||||
if (!text) { UI.toast.warning('Bitte einen Text eingeben.'); return; }
|
||||
const btn = overlay.querySelector('#nc-save');
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const rb = _rubrik(_selType);
|
||||
await API.notes.create(_selType, 'standalone', {
|
||||
text,
|
||||
parent_label: rb.label,
|
||||
});
|
||||
overlay.remove();
|
||||
_filterType = _selType;
|
||||
await _reload();
|
||||
UI.toast.success('Notiz gespeichert.');
|
||||
overlay.querySelector('#nc-save')?.addEventListener('click', async () => {
|
||||
const text = overlay.querySelector('#nc-text')?.value?.trim();
|
||||
if (!text && !_media.hasPending()) {
|
||||
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
|
||||
return;
|
||||
}
|
||||
const btn = overlay.querySelector('#nc-save');
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const rb = _rubrik(_selType);
|
||||
const created = await API.notes.create(_selType, 'standalone', {
|
||||
text: text || '',
|
||||
parent_label: rb.label,
|
||||
});
|
||||
if (created?.id && _media.hasPending()) {
|
||||
await _media.uploadAll(created.id, (d, t) => { btn.textContent = `${d}/${t} hochgeladen…`; });
|
||||
}
|
||||
_remove();
|
||||
_filterType = _selType;
|
||||
await _reload();
|
||||
UI.toast.success('Notiz gespeichert.');
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100);
|
||||
};
|
||||
|
||||
_rebind();
|
||||
setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -732,6 +803,13 @@ window.Page_notes = (() => {
|
|||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Medien -->
|
||||
<div>
|
||||
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text);margin-bottom:var(--space-2)">Medien</label>
|
||||
<div id="notes-edit-media"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
|
|
@ -760,6 +838,12 @@ window.Page_notes = (() => {
|
|||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const _media = UI.noteMediaAttacher({
|
||||
containerId: 'notes-edit-media',
|
||||
noteId: note.id,
|
||||
existingMedia: note.media_items || [],
|
||||
});
|
||||
|
||||
let selErfolgsquote = meta.erfolgsquote || null;
|
||||
let selUmgebung = meta.umgebung || null;
|
||||
let selStimmung = meta.hund_stimmung || null;
|
||||
|
|
@ -796,14 +880,17 @@ window.Page_notes = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
function _close() { overlay.remove(); }
|
||||
function _close() { _media.destroy(); overlay.remove(); }
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
|
||||
overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close);
|
||||
|
||||
// Speichern
|
||||
overlay.querySelector('#notes-edit-save').addEventListener('click', async () => {
|
||||
const text = overlay.querySelector('#notes-edit-text').value.trim();
|
||||
if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; }
|
||||
if (!text && !_media.hasPending() && !(note.media_items || []).length) {
|
||||
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = overlay.querySelector('#notes-edit-save');
|
||||
saveBtn.disabled = true;
|
||||
|
|
@ -819,6 +906,10 @@ window.Page_notes = (() => {
|
|||
text,
|
||||
meta_json: Object.keys(metaObj).length > 0 ? metaObj : null,
|
||||
});
|
||||
if (_media.hasPending()) {
|
||||
const { uploaded } = await _media.uploadAll(note.id, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; });
|
||||
updated.media_items = (updated.media_items || []).concat(uploaded);
|
||||
}
|
||||
const idx = _notes.findIndex(n => n.id === note.id);
|
||||
if (idx >= 0) _notes[idx] = updated;
|
||||
_render();
|
||||
|
|
|
|||
|
|
@ -1996,6 +1996,15 @@ window.Page_routes = (() => {
|
|||
// alles grün, 99 % ab Start (Praxistest René 2026-06-07, Gassirunde Siegenhofen).
|
||||
// Global nur beim ersten Fix oder wenn verloren (Fenster-Treffer > 300 m entfernt).
|
||||
let _navIdxInit = false;
|
||||
// Runde erkennen: Start ≈ Ende (< 60 m). An einem solchen Start/Ende-Knoten ist der
|
||||
// ENDPUNKT oft ein paar Meter näher als der Startpunkt — die globale Erst-Suche sprang
|
||||
// dann sofort ans Track-ENDE → 100 % / 0 km ab Sekunde 1, kein Bellen, alles grün, und
|
||||
// der gelaufene-Weg-Eintrag wurde fälschlich als komplett gespeichert. Der alte 25-m-
|
||||
// Gleichstand reichte nicht, wenn der Start >28 m weg lag (Siegenhofen René 2026-06-07,
|
||||
// Deining Angie 2026-06-09).
|
||||
const _navIsLoop = track.length > 2 &&
|
||||
_haversineKm(track[0].lat, track[0].lon,
|
||||
track[track.length - 1].lat, track[track.length - 1].lon) < 0.06;
|
||||
const _closestIdx = (lat, lon) => {
|
||||
const search = (from, to) => {
|
||||
let best = from, bestD = Infinity;
|
||||
|
|
@ -2007,8 +2016,16 @@ window.Page_routes = (() => {
|
|||
};
|
||||
if (!_navIdxInit) {
|
||||
_navIdxInit = true;
|
||||
// Erster Fix: global, aber bei Quasi-Gleichstand (< 25 m) den START bevorzugen (Loop!)
|
||||
const g = search(0, track.length - 1);
|
||||
if (_navIsLoop) {
|
||||
// Runde: steht man irgendwo in Startnähe (< 150 m), bei 0 % beginnen statt ans
|
||||
// nahe Track-Ende zu springen. Erst wer weit vom Start steht, ist mitten in die
|
||||
// Runde eingestiegen → globaler Treffer. Startfenster = erste 15 % (mind. 30 Pkt.).
|
||||
const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15)));
|
||||
const s = search(0, win);
|
||||
return s.bestD < 0.15 ? s.best : g.best;
|
||||
}
|
||||
// Punkt-zu-Punkt: bei Quasi-Gleichstand (< 25 m) den START bevorzugen.
|
||||
const s = search(0, Math.min(track.length - 1, 30));
|
||||
return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1706,6 +1706,270 @@ const UI = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// NOTE-MEDIA-ATTACHER — wiederverwendbarer Medien-Anhang für Notizen.
|
||||
// Buttons (Mediathek/Aufnehmen/Datei/Sprachnachricht), Liste anhängiger
|
||||
// Dateien, Sprachaufnahme (MediaRecorder) und bereits gespeicherte Medien
|
||||
// (mit Löschen). Genutzt von noteModal (ui.js) UND der Notizblock-Seite
|
||||
// (pages/notes.js) — eine Quelle statt Duplikat.
|
||||
// const m = UI.noteMediaAttacher({ containerId, noteId, existingMedia });
|
||||
// …im Submit nach create/update: await m.uploadAll(noteId, onProgress);
|
||||
// ----------------------------------------------------------
|
||||
function noteMediaAttacher({ containerId, noteId = null, existingMedia = [] } = {}) {
|
||||
const container = document.getElementById(containerId);
|
||||
const _noop = { uploadAll: async () => ({ uploaded: [], failed: 0 }), hasPending: () => false, destroy: () => {} };
|
||||
if (!container) return _noop;
|
||||
|
||||
const p = containerId.replace(/[^a-z0-9]/gi, '-');
|
||||
const ids = {
|
||||
bar: `${p}-bar`, pending: `${p}-pending`, existing: `${p}-existing`,
|
||||
rec: `${p}-rec`, recTimer: `${p}-rec-timer`, recStop: `${p}-rec-stop`,
|
||||
recCancel: `${p}-rec-cancel`, mic: `${p}-mic`,
|
||||
};
|
||||
|
||||
let _noteId = noteId;
|
||||
const _pending = []; // [{ file, url }]
|
||||
let _existing = (existingMedia || []).slice();
|
||||
const RECORDER_OK = !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||
let _stream = null, _rec = null, _chunks = [], _tick = null, _recStart = 0, _recMax = null, _recCancelled = false;
|
||||
|
||||
const BTN = 'display:inline-flex;align-items:center;gap:6px;font-size:var(--text-xs);font-weight:600;' +
|
||||
'padding:7px 12px;border-radius:var(--radius-full);border:1.5px solid var(--c-border);' +
|
||||
'background:var(--c-surface-2);color:var(--c-text-secondary);cursor:pointer';
|
||||
const DEL = 'flex-shrink:0;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.08);' +
|
||||
'color:var(--c-text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px;line-height:1';
|
||||
const ROW = 'display:flex;align-items:center;gap:10px;padding:6px;border:1px solid var(--c-border);border-radius:var(--radius-md)';
|
||||
|
||||
container.innerHTML = `
|
||||
<div id="${ids.existing}"></div>
|
||||
<div id="${ids.bar}" style="display:flex;flex-wrap:wrap;gap:8px;margin:6px 0">
|
||||
<button type="button" id="${p}-gallery" style="${BTN}">${_svgIcon('paperclip')} Foto / Video / Datei</button>
|
||||
${RECORDER_OK ? `<button type="button" id="${ids.mic}" style="${BTN}">${_svgIcon('microphone')} Sprachnachricht</button>` : ''}
|
||||
</div>
|
||||
<div id="${ids.rec}" style="display:none;align-items:center;gap:10px;margin:6px 0;padding:8px 12px;border-radius:var(--radius-md);background:var(--c-danger-subtle,rgba(220,53,53,.12))">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:var(--c-danger,#dc3535);flex-shrink:0"></span>
|
||||
<span id="${ids.recTimer}" style="font-variant-numeric:tabular-nums;font-weight:600;color:var(--c-text)">0:00</span>
|
||||
<span style="flex:1"></span>
|
||||
<button type="button" id="${ids.recCancel}" style="${BTN}">Verwerfen</button>
|
||||
<button type="button" id="${ids.recStop}" style="${BTN};border-color:var(--c-danger,#dc3535);color:var(--c-danger,#dc3535)">${_svgIcon('stop')} Stop</button>
|
||||
</div>
|
||||
<div id="${ids.pending}" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
|
||||
`;
|
||||
|
||||
// ---- Datei-Picker (nativer Browser-Dialog) ----
|
||||
function _openPicker(opts = {}) {
|
||||
const tmp = document.createElement('input');
|
||||
tmp.type = 'file';
|
||||
tmp.multiple = true;
|
||||
tmp.accept = 'image/*,video/*';
|
||||
tmp.style.display = 'none';
|
||||
if (opts.capture) tmp.setAttribute('capture', opts.capture);
|
||||
if (opts.noAccept) tmp.removeAttribute('accept');
|
||||
tmp.addEventListener('change', () => { if (tmp.files.length) _addFiles(tmp.files); tmp.remove(); });
|
||||
document.body.appendChild(tmp);
|
||||
tmp.click();
|
||||
}
|
||||
|
||||
function _addFiles(list) {
|
||||
for (const f of list) _pending.push({ file: f, url: URL.createObjectURL(f) });
|
||||
_renderPending();
|
||||
}
|
||||
|
||||
function _renderPending() {
|
||||
const grid = document.getElementById(ids.pending);
|
||||
if (!grid) return;
|
||||
grid.innerHTML = _pending.map((it, i) => {
|
||||
const f = it.file, t = f.type || '';
|
||||
let preview, label = '';
|
||||
if (t.startsWith('image/')) {
|
||||
preview = `<img src="${it.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0">`;
|
||||
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Bild')}</span>`;
|
||||
} else if (t.startsWith('video/')) {
|
||||
preview = `<video src="${it.url}" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0" muted playsinline></video>`;
|
||||
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Video')}</span>`;
|
||||
} else if (t.startsWith('audio/')) {
|
||||
preview = `<audio controls src="${it.url}" style="height:36px;flex:1;min-width:0"></audio>`;
|
||||
} else {
|
||||
preview = `<svg class="ph-icon" style="width:30px;height:30px;color:var(--c-text-secondary);flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>`;
|
||||
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Datei')}</span>`;
|
||||
}
|
||||
return `<div style="${ROW}" data-pidx="${i}">${preview}${label}<button type="button" data-pidx="${i}" aria-label="Entfernen" style="${DEL}">✕</button></div>`;
|
||||
}).join('');
|
||||
grid.querySelectorAll('button[data-pidx]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const i = parseInt(btn.dataset.pidx, 10);
|
||||
if (_pending[i]) { try { URL.revokeObjectURL(_pending[i].url); } catch (_) {} _pending.splice(i, 1); }
|
||||
_renderPending();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _previewOf(url) {
|
||||
if (!url) return url;
|
||||
if (/\.(mp4|webm|mov|avi|m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url;
|
||||
const dot = url.lastIndexOf('.');
|
||||
return dot > 0 ? url.slice(0, dot) + '_preview.webp' : url;
|
||||
}
|
||||
|
||||
function _renderExisting() {
|
||||
const wrap = document.getElementById(ids.existing);
|
||||
if (!wrap) return;
|
||||
if (!_existing.length) { wrap.innerHTML = ''; return; }
|
||||
wrap.innerHTML = `<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:4px">${_existing.map(m => {
|
||||
let preview, label = '';
|
||||
if (m.media_type === 'image') {
|
||||
preview = `<img src="${_previewOf(m.url)}" data-full="${m.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;cursor:pointer" data-full-open="${m.url}">`;
|
||||
} else if (m.media_type === 'video') {
|
||||
preview = `<video src="${m.url}" controls playsinline style="height:48px;border-radius:6px;flex:1;min-width:0"></video>`;
|
||||
} else if (m.media_type === 'audio') {
|
||||
preview = `<audio controls src="${m.url}" style="height:36px;flex:1;min-width:0"></audio>`;
|
||||
} else {
|
||||
preview = `<a href="${m.url}" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;color:var(--c-text-secondary);text-decoration:none"><svg class="ph-icon" style="width:28px;height:28px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span style="font-size:var(--text-xs);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape((m.media_type === 'pdf' ? 'PDF' : 'Datei'))}</span></a>`;
|
||||
}
|
||||
return `<div style="${ROW}" data-mid="${m.id}">${preview}${label}<button type="button" data-mid="${m.id}" aria-label="Löschen" style="${DEL}">✕</button></div>`;
|
||||
}).join('')}</div>`;
|
||||
// CSP-konformer Preview→Original-Fallback
|
||||
wrap.querySelectorAll('img[data-full]').forEach(img => {
|
||||
img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true });
|
||||
});
|
||||
wrap.querySelectorAll('img[data-full-open]').forEach(img => {
|
||||
img.addEventListener('click', () => {
|
||||
const imgs = _existing.filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' }));
|
||||
const idx = imgs.findIndex(it => it.url === img.dataset.fullOpen);
|
||||
if (UI.lightbox) UI.lightbox.show(imgs, Math.max(0, idx));
|
||||
});
|
||||
});
|
||||
wrap.querySelectorAll('button[data-mid]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const mid = parseInt(btn.dataset.mid, 10);
|
||||
if (_noteId == null) { _existing = _existing.filter(m => m.id !== mid); _renderExisting(); return; }
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.notes.deleteMedia(_noteId, mid);
|
||||
} catch (e) {
|
||||
if (e?.status !== 404) { btn.disabled = false; toast.error(e.message || 'Löschen fehlgeschlagen.'); return; }
|
||||
}
|
||||
_existing = _existing.filter(m => m.id !== mid);
|
||||
_renderExisting();
|
||||
toast.success('Medium entfernt.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Sprachaufnahme ----
|
||||
function _stopTracks() { if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; } }
|
||||
|
||||
function _setRecState(on) {
|
||||
const rec = document.getElementById(ids.rec);
|
||||
const bar = document.getElementById(ids.bar);
|
||||
if (rec) rec.style.display = on ? 'flex' : 'none';
|
||||
if (bar) { bar.style.opacity = on ? '.4' : ''; bar.querySelectorAll('button').forEach(b => b.disabled = on); }
|
||||
}
|
||||
|
||||
function _updateTimer() {
|
||||
const el = document.getElementById(ids.recTimer);
|
||||
if (!el) return;
|
||||
const s = Math.floor((Date.now() - _recStart) / 1000);
|
||||
el.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function _startRecording() {
|
||||
try {
|
||||
_stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (_) {
|
||||
toast.error('Kein Mikrofon-Zugriff. Bitte in den Geräte-Einstellungen erlauben.');
|
||||
return;
|
||||
}
|
||||
const cands = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];
|
||||
const mime = cands.find(t => { try { return MediaRecorder.isTypeSupported(t); } catch (_) { return false; } }) || '';
|
||||
try { _rec = mime ? new MediaRecorder(_stream, { mimeType: mime }) : new MediaRecorder(_stream); }
|
||||
catch (_) { _rec = new MediaRecorder(_stream); }
|
||||
_chunks = [];
|
||||
_recCancelled = false;
|
||||
_rec.addEventListener('dataavailable', e => { if (e.data && e.data.size) _chunks.push(e.data); });
|
||||
_rec.addEventListener('stop', () => {
|
||||
_stopTracks();
|
||||
if (_tick) { clearInterval(_tick); _tick = null; }
|
||||
if (_recMax) { clearTimeout(_recMax); _recMax = null; }
|
||||
_setRecState(false);
|
||||
if (_recCancelled) { _chunks = []; return; }
|
||||
const type = _rec.mimeType || mime || 'audio/webm';
|
||||
const ext = type.includes('mp4') ? '.m4a' : type.includes('ogg') ? '.ogg' : '.webm';
|
||||
const blob = new Blob(_chunks, { type });
|
||||
_chunks = [];
|
||||
if (blob.size > 0) _addFiles([new File([blob], `sprachnachricht${ext}`, { type })]);
|
||||
});
|
||||
_rec.start();
|
||||
_recStart = Date.now();
|
||||
_setRecState(true);
|
||||
_updateTimer();
|
||||
_tick = setInterval(_updateTimer, 250);
|
||||
_recMax = setTimeout(() => { if (_rec && _rec.state === 'recording') { _recCancelled = false; try { _rec.stop(); } catch (_) {} } }, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
function _stopRecording(cancel) {
|
||||
_recCancelled = !!cancel;
|
||||
if (_rec && _rec.state !== 'inactive') { try { _rec.stop(); } catch (_) {} }
|
||||
else { _stopTracks(); _setRecState(false); }
|
||||
}
|
||||
|
||||
// ---- Event-Bindung ----
|
||||
// Ein Button ohne accept: iOS zeigt im Aktionsblatt von sich aus Mediathek,
|
||||
// Kamera UND Datei-Auswahl — separate Buttons dafür sind überflüssig.
|
||||
document.getElementById(`${p}-gallery`)?.addEventListener('click', () => _openPicker({ noAccept: true }));
|
||||
if (RECORDER_OK) document.getElementById(ids.mic)?.addEventListener('click', _startRecording);
|
||||
document.getElementById(ids.recStop)?.addEventListener('click', () => _stopRecording(false));
|
||||
document.getElementById(ids.recCancel)?.addEventListener('click', () => _stopRecording(true));
|
||||
|
||||
_renderExisting();
|
||||
_renderPending();
|
||||
|
||||
// ---- Öffentliche API ----
|
||||
async function uploadAll(noteIdArg, onProgress) {
|
||||
if (noteIdArg != null) _noteId = noteIdArg;
|
||||
if (!_pending.length) return { uploaded: [], failed: 0 };
|
||||
const total = _pending.length;
|
||||
let done = 0;
|
||||
onProgress?.(0, total);
|
||||
const results = await Promise.all(_pending.map(async (it) => {
|
||||
try {
|
||||
const toUpload = await API.compressImage(it.file); // nur Bilder werden komprimiert
|
||||
const fd = new FormData();
|
||||
fd.append('file', toUpload);
|
||||
const m = await API.notes.uploadMedia(_noteId, fd);
|
||||
onProgress?.(++done, total);
|
||||
return { ok: true, m };
|
||||
} catch (_) {
|
||||
onProgress?.(++done, total);
|
||||
return { ok: false };
|
||||
}
|
||||
}));
|
||||
const uploaded = results.filter(r => r.ok).map(r => r.m);
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
if (failed) toast.warning(`${failed} Medi${failed > 1 ? 'en' : 'um'} konnte${failed > 1 ? 'n' : ''} nicht hochgeladen werden.`);
|
||||
_pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} });
|
||||
_pending.length = 0;
|
||||
_renderPending();
|
||||
_existing = _existing.concat(uploaded);
|
||||
return { uploaded, failed };
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
_stopRecording(true);
|
||||
_stopTracks();
|
||||
if (_tick) { clearInterval(_tick); _tick = null; }
|
||||
if (_recMax) { clearTimeout(_recMax); _recMax = null; }
|
||||
_pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} });
|
||||
_pending.length = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
uploadAll,
|
||||
hasPending: () => _pending.length > 0 || (_rec && _rec.state === 'recording'),
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// NOTE-MODAL — Notiz zu einem beliebigen Objekt (parentType/parentId)
|
||||
// erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat.
|
||||
|
|
@ -1735,6 +1999,7 @@ const UI = (() => {
|
|||
placeholder="Notiz eingeben…"
|
||||
style="width:100%;resize:vertical"></textarea>
|
||||
</form>
|
||||
<div id="by-note-media" style="margin-top:var(--space-3)"></div>
|
||||
</div>
|
||||
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
|
||||
display:flex;gap:var(--space-2);flex-shrink:0">
|
||||
|
|
@ -1752,17 +2017,27 @@ const UI = (() => {
|
|||
const closeBtn = document.getElementById('by-note-close');
|
||||
|
||||
let existingNoteId = null;
|
||||
let existingNote = null;
|
||||
try {
|
||||
const existing = await API.notes.get(parentType, parentId);
|
||||
if (existing?.id) {
|
||||
existingNoteId = existing.id;
|
||||
textarea.value = existing.text || '';
|
||||
const res = await API.notes.get(parentType, parentId);
|
||||
// GET /notes/{type}/{id} liefert ein Array (neueste zuerst) — die jüngste
|
||||
// Notiz bearbeiten statt bei jedem Öffnen eine neue anzulegen (Duplikate).
|
||||
existingNote = Array.isArray(res) ? res[0] : res;
|
||||
if (existingNote?.id) {
|
||||
existingNoteId = existingNote.id;
|
||||
textarea.value = existingNote.text || '';
|
||||
}
|
||||
} catch (_) { /* keine Notiz vorhanden — ok */ }
|
||||
|
||||
const _media = noteMediaAttacher({
|
||||
containerId: 'by-note-media',
|
||||
noteId: existingNoteId,
|
||||
existingMedia: existingNote?.media_items || [],
|
||||
});
|
||||
|
||||
setTimeout(() => textarea.focus(), 100);
|
||||
|
||||
const _close = () => overlay.remove();
|
||||
const _close = () => { _media.destroy(); overlay.remove(); };
|
||||
closeBtn.addEventListener('click', _close);
|
||||
cancelBtn.addEventListener('click', _close);
|
||||
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
|
||||
|
|
@ -1770,13 +2045,24 @@ const UI = (() => {
|
|||
document.getElementById('by-note-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const text = textarea.value.trim();
|
||||
// Reine Medien-Notiz (nur Foto/Sprachnachricht) ist erlaubt — nur ganz
|
||||
// leer (kein Text, keine Medien) verhindern.
|
||||
if (!text && !_media.hasPending() && !existingNoteId) {
|
||||
toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
|
||||
return;
|
||||
}
|
||||
setLoading(saveBtn, true);
|
||||
try {
|
||||
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
|
||||
if (existingNoteId) {
|
||||
await API.notes.update(existingNoteId, payload);
|
||||
let noteId = existingNoteId;
|
||||
if (noteId) {
|
||||
await API.notes.update(noteId, payload);
|
||||
} else {
|
||||
await API.notes.create(parentType, parentId, payload);
|
||||
const created = await API.notes.create(parentType, parentId, payload);
|
||||
noteId = created?.id;
|
||||
}
|
||||
if (noteId && _media.hasPending()) {
|
||||
await _media.uploadAll(noteId, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; });
|
||||
}
|
||||
toast.success('Notiz gespeichert.');
|
||||
_close();
|
||||
|
|
@ -1787,10 +2073,62 @@ const UI = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LIGHTBOX — Vollbild-Viewer für Bilder/Videos mit Vor/Zurück.
|
||||
// items: [{ url, type }] (type 'image'|'video', Default 'image') oder
|
||||
// reine URL-Strings. Global, damit Notizen, Tagebuch & Co. EINEN Viewer
|
||||
// teilen (app.js erwartet UI.lightbox.show bereits).
|
||||
// ----------------------------------------------------------
|
||||
const lightbox = (() => {
|
||||
function show(items, startIdx = 0) {
|
||||
const list = (Array.isArray(items) ? items : [items])
|
||||
.map(it => (typeof it === 'string' ? { url: it } : it))
|
||||
.filter(it => it && it.url);
|
||||
if (!list.length) return;
|
||||
let idx = Math.min(Math.max(0, startIdx | 0), list.length - 1);
|
||||
|
||||
const lb = document.createElement('div');
|
||||
lb.id = 'by-lightbox';
|
||||
lb.style.cssText = 'position:fixed;inset:0;z-index:3000;background:#000;display:flex;flex-direction:column';
|
||||
|
||||
const render = () => {
|
||||
const m = list[idx];
|
||||
const media = (m.type === 'video')
|
||||
? `<video src="${escape(m.url)}" controls autoplay playsinline style="max-width:100%;max-height:100%;display:block"></video>`
|
||||
: `<img src="${escape(m.url)}" alt="" style="max-width:100%;max-height:100%;object-fit:contain;display:block">`;
|
||||
lb.innerHTML = `
|
||||
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">${media}</div>
|
||||
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding-top:10px;
|
||||
padding-bottom:calc(env(safe-area-inset-bottom,0px) + 12px);
|
||||
padding-left:calc(env(safe-area-inset-left,0px) + 16px);
|
||||
padding-right:calc(env(safe-area-inset-right,0px) + 16px);
|
||||
flex-shrink:0;background:rgba(0,0,0,.5);gap:8px">
|
||||
<button id="by-lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:24px;height:48px;
|
||||
color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;padding:0 16px;
|
||||
font-size:15px;font-weight:500">${_svgIcon('arrow-left')} Schließen</button>
|
||||
<span style="color:rgba(255,255,255,.7);font-size:14px;text-align:center;white-space:nowrap">${list.length > 1 ? `${idx + 1} / ${list.length}` : ''}</span>
|
||||
${list.length > 1 ? `
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button id="by-lb-prev" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
|
||||
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === 0 ? ';opacity:.3;pointer-events:none' : ''}">‹</button>
|
||||
<button id="by-lb-next" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
|
||||
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === list.length - 1 ? ';opacity:.3;pointer-events:none' : ''}">›</button>
|
||||
</div>` : '<div></div>'}
|
||||
</div>`;
|
||||
lb.querySelector('#by-lb-close').addEventListener('click', () => lb.remove());
|
||||
lb.querySelector('#by-lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } });
|
||||
lb.querySelector('#by-lb-next')?.addEventListener('click', () => { if (idx < list.length - 1) { idx++; render(); } });
|
||||
};
|
||||
render();
|
||||
document.body.appendChild(lb);
|
||||
}
|
||||
return { show };
|
||||
})();
|
||||
|
||||
// Öffentliche API
|
||||
return {
|
||||
toast, modal,
|
||||
noteModal,
|
||||
noteModal, noteMediaAttacher, lightbox,
|
||||
setLoading, asyncButton,
|
||||
formData, setFormError, clearFormErrors,
|
||||
emptyState, errorState, time, text, money,
|
||||
|
|
|
|||
|
|
@ -1791,6 +1791,146 @@ window.Worlds = (() => {
|
|||
{ t:'Kein Psychiater der Welt kann so gut zuhören wie ein Hund.', a:'Unbekannt' },
|
||||
{ t:'Wo Hunde sind, da ist das Zuhause.', a:'Unbekannt' },
|
||||
{ t:'Der Hund hat keinen Begriff von Vergangenheit oder Zukunft. Er lebt.', a:'Milan Kundera' },
|
||||
{ t:"Wenn du einen verhungernden Hund aufnimmst und es ihm gutgehen lässt, wird er dich nicht beißen. Das ist der wesentliche Unterschied zwischen Hund und Mensch.", a:"Mark Twain" },
|
||||
{ t:"Der Hund ist ein Gentleman. Ich hoffe, in seinen Himmel zu kommen, nicht in den der Menschen.", a:"Mark Twain" },
|
||||
{ t:"Je mehr ich über die Menschen lerne, desto mehr liebe ich meinen Hund.", a:"Mark Twain" },
|
||||
{ t:"Der Himmel vergibt nach Gunst. Ginge es nach Verdienst, bliebest du draußen und dein Hund käme hinein.", a:"Mark Twain" },
|
||||
{ t:"Die Treue eines Hundes ist ein kostbares Geschenk, das nicht weniger bindende Pflichten auferlegt als die Freundschaft eines Menschen.", a:"Konrad Lorenz" },
|
||||
{ t:"Es gibt keine Treue, die nicht schon einmal gebrochen worden wäre, außer der eines wahrhaft treuen Hundes.", a:"Konrad Lorenz" },
|
||||
{ t:"Die Bindung an einen echten Hund ist so beständig, wie es Bande auf dieser Erde nur sein können.", a:"Konrad Lorenz" },
|
||||
{ t:"Der Wunsch, einen Hund zu halten, entspringt der uralten Sehnsucht des zivilisierten Menschen nach dem verlorenen Paradies.", a:"Konrad Lorenz" },
|
||||
{ t:"Wenn ich daran denke, dass mein Hund mich mehr liebt, als ich ihn, dann beschämt mich das.", a:"Konrad Lorenz" },
|
||||
{ t:"Der Hund ist, mit Recht, das Sinnbild der Treue.", a:"Arthur Schopenhauer" },
|
||||
{ t:"Woran sollte man sich von der Falschheit der Menschen erholen, wenn die Hunde nicht wären, in deren ehrliches Gesicht man ohne Misstrauen blicken kann.", a:"Arthur Schopenhauer" },
|
||||
{ t:"Der Anblick jedes Tieres erfreut mich unmittelbar und mir geht dabei das Herz auf, am meisten bei dem der Hunde.", a:"Arthur Schopenhauer" },
|
||||
{ t:"Hunde lieben ihre Freunde und beißen ihre Feinde, ganz anders als Menschen, die niemals rein lieben, sondern stets Liebe und Hass vermengen.", a:"Sigmund Freud" },
|
||||
{ t:"Hunde schenken Zuneigung ohne Zwiespalt und die Schönheit eines Daseins, das ganz in sich ruht.", a:"Sigmund Freud" },
|
||||
{ t:"Gibt es im Himmel keine Hunde, dann will ich, wenn ich sterbe, dorthin gehen, wo sie hingekommen sind.", a:"Will Rogers" },
|
||||
{ t:"Für seinen Hund ist jeder Mensch ein Napoleon, daher die anhaltende Beliebtheit der Hunde.", a:"Aldous Huxley" },
|
||||
{ t:"Bevor du einen Hund hast, kannst du dir kaum vorstellen, wie das Leben mit ihm wäre. Danach kannst du dir kein anderes Leben mehr vorstellen.", a:"Caroline Knapp" },
|
||||
{ t:"Wer einmal einen wundervollen Hund hatte, dessen Leben ist ohne einen ärmer.", a:"Dean Koontz" },
|
||||
{ t:"Einen Hund zu streicheln und zu kraulen kann den Geist so beruhigen wie tiefe Meditation und ist fast so gut für die Seele wie ein Gebet.", a:"Dean Koontz" },
|
||||
{ t:"Tausendmal hat er mir gesagt, dass ich sein Grund zu leben bin, durch die Art, wie er sich an mein Bein lehnt.", a:"Gene Hill" },
|
||||
{ t:"Hunde sind unser Bindeglied zum Paradies. Sie kennen weder Bosheit noch Neid noch Unzufriedenheit.", a:"Milan Kundera" },
|
||||
{ t:"Mit einem Hund an einem schönen Nachmittag auf einem Hügel zu sitzen ist wie eine Rückkehr nach Eden.", a:"Milan Kundera" },
|
||||
{ t:"Hunde sprechen sehr wohl, doch nur zu denen, die zu lauschen verstehen.", a:"Orhan Pamuk" },
|
||||
{ t:"Tiere sind so angenehme Freunde, sie stellen keine Fragen und üben keine Kritik.", a:"George Eliot" },
|
||||
{ t:"Das größte Vergnügen mit einem Hund ist, dass man sich vor ihm zum Narren machen kann und er nicht nur nicht tadelt, sondern selbst mitmacht.", a:"Samuel Butler" },
|
||||
{ t:"Bedenke, dass der Allmächtige, der uns den Hund zum Gefährten gab, ihm ein edles Wesen verlieh, das des Betrugs unfähig ist.", a:"Sir Walter Scott" },
|
||||
{ t:"Ich habe oft über den Grund nachgedacht, warum Hunde so kurz leben, und bin überzeugt, es geschieht aus Mitleid mit dem Menschen.", a:"Sir Walter Scott" },
|
||||
{ t:"Hunde beißen mich nie. Nur Menschen.", a:"Marilyn Monroe" },
|
||||
{ t:"Wer hält dich für so großartig wie dein Hund.", a:"Audrey Hepburn" },
|
||||
{ t:"Ich gehe mit meinen Hunden, das hält mich fit. Ich rede mit meinen Hunden, das hält mich gesund.", a:"Audrey Hepburn" },
|
||||
{ t:"Sobald er seinen Herrn erblickte, ließ Argos die Ohren sinken und wedelte mit dem Schwanz, doch zu ihm hin zu kommen vermochte er nicht mehr.", a:"Homer" },
|
||||
{ t:"Hunde und Philosophen tun das meiste Gute und erhalten den geringsten Lohn.", a:"Diogenes" },
|
||||
{ t:"Hunde sind besser als Menschen, denn sie wissen, doch sie verraten es nicht.", a:"Emily Dickinson" },
|
||||
{ t:"Bei Schlachten, die das Schicksal von Völkern entschieden, blieb ich ungerührt. Hier aber, beim Kummer eines einzigen Hundes, war ich zu Tränen gerührt.", a:"Napoleon Bonaparte" },
|
||||
{ t:"Wenn Hunde in den Himmel kommen, brauchen sie keine Flügel, denn Gott weiß, dass Hunde das Laufen am meisten lieben.", a:"Cynthia Rylant" },
|
||||
{ t:"Meine ganze Bestimmung war es, ihn zu lieben, bei ihm zu sein und ihn glücklich zu machen.", a:"W. Bruce Cameron" },
|
||||
{ t:"Meine Hundefreunde scheinen meine Grenzen zu verstehen und bleiben immer dicht an meiner Seite.", a:"Helen Keller" },
|
||||
{ t:"Gib einem Hund dein Herz, das er zerreißen kann.", a:"Rudyard Kipling" },
|
||||
{ t:"Ein Hund lehrt einen Jungen Treue, Beharrlichkeit und sich dreimal zu drehen, bevor man sich hinlegt.", a:"Robert Benchley" },
|
||||
{ t:"Du kannst zu einem Hund den größten Unsinn sagen, und er schaut dich an, als wollte er sagen: Donnerwetter, du hast recht, darauf wäre ich nie gekommen.", a:"Dave Barry" },
|
||||
{ t:"Wenn ich an die Unsterblichkeit glaube, dann daran, dass gewisse Hunde in den Himmel kommen, und nur sehr, sehr wenige Menschen.", a:"James Thurber" },
|
||||
{ t:"Eine Tür ist das, auf deren falscher Seite ein Hund sich ständig befindet.", a:"Ogden Nash" },
|
||||
{ t:"Natürlich kann man ohne Hund leben, es lohnt sich nur nicht.", a:"Heinz Rühmann" },
|
||||
{ t:"Ein Leben ohne Mops ist möglich, aber sinnlos.", a:"Loriot" },
|
||||
{ t:"Die Welt wäre ein schönerer Ort, wenn jeder so bedingungslos lieben könnte wie ein Hund.", a:"M.K. Clinton" },
|
||||
{ t:"Der durchschnittliche Hund ist ein netterer Mensch als der durchschnittliche Mensch.", a:"Andy Rooney" },
|
||||
{ t:"Niemand schätzt das ganz besondere Genie deiner Unterhaltung so sehr wie dein Hund.", a:"Christopher Morley" },
|
||||
{ t:"Ich habe meinem Schmerz einen Namen gegeben und nenne ihn Hund, denn er ist ebenso treu und klug wie jeder andere Hund.", a:"Friedrich Nietzsche" },
|
||||
{ t:"Dem Hunde, wenn er gut gezogen, wird selbst ein weiser Mann gewogen.", a:"Johann Wolfgang von Goethe" },
|
||||
{ t:"Verstößt ein Herr seinen Hund, weil er ihm das Brot nicht mehr verdienen kann, so zeigt das stets eine sehr kleine Seele des Herrn an.", a:"Immanuel Kant" },
|
||||
{ t:"Wenn du keinen Hund besitzt, ist nicht unbedingt etwas mit dir verkehrt, aber vielleicht stimmt etwas mit deinem Leben nicht.", a:"Roger Caras" },
|
||||
{ t:"Alte Hunde sind wie alte Schuhe, bequem. Sie sind vielleicht etwas aus der Form, aber sie passen einfach gut.", a:"Bonnie Wilcox" },
|
||||
{ t:"Kommt ein Hund nicht zu dir, nachdem er dir ins Gesicht gesehen hat, so solltest du heimgehen und dein Gewissen prüfen.", a:"Woodrow Wilson" },
|
||||
{ t:"Springt ein Hund auf deinen Schoß, dann weil er dich mag. Tut eine Katze dasselbe, ist es nur, weil dein Schoß wärmer ist.", a:"Alfred North Whitehead" },
|
||||
{ t:"Geld kann dir einen feinen Hund kaufen, aber nur Liebe bringt ihn dazu, mit dem Schwanz zu wedeln.", a:"Kinky Friedman" },
|
||||
{ t:"Wenn ich für eine Reise den Koffer hervorhole, weiß er es lange vorher und gerät in einen Zustand milder Aufregung.", a:"John Steinbeck" },
|
||||
{ t:"Ein Hund ist ein Band zwischen Fremden.", a:"John Steinbeck" },
|
||||
{ t:"Mein kleiner Hund, ein Herzschlag zu meinen Füßen.", a:"Edith Wharton" },
|
||||
{ t:"Geschaffen wurde der Hund eigens für die Kinder. Er ist der Gott des Übermuts.", a:"Henry Ward Beecher" },
|
||||
{ t:"Hunde sind klug. Sie kriechen in eine stille Ecke und lecken ihre Wunden und kehren erst in die Welt zurück, wenn sie wieder heil sind.", a:"Agatha Christie" },
|
||||
{ t:"Schönheit ohne Eitelkeit, Stärke ohne Übermut, Mut ohne Wildheit und alle Tugenden des Menschen ohne seine Laster.", a:"Lord Byron" },
|
||||
{ t:"Gott dreht Wolken um und um, um den Hunden im Hundehimmel flauschige Betten zu bereiten.", a:"Cynthia Rylant" },
|
||||
{ t:"Hunde besitzen eine Eigenschaft, die unter Menschen selten ist, nämlich zu erkennen, wer Hilfe braucht, und sie zu geben.", a:"Caroline Knapp" },
|
||||
{ t:"In manchen Dingen ist mein Hund klüger als ich, in anderen ist er bodenlos unwissend.", a:"John Steinbeck" },
|
||||
{ t:"Ein Hund zur Hand ist besser als ein Bruder in der Ferne.", a:"Persisches Sprichwort" },
|
||||
{ t:"Wenn du bei jedem bellenden Hund stehen bleibst, beendest du deine Reise nie.", a:"Arabisches Sprichwort" },
|
||||
{ t:"Es ist schwer, einen so treuen Gefährten zu finden wie einen Hund.", a:"Mongolisches Sprichwort" },
|
||||
{ t:"Ein Hund ohne Schwanz kann nicht zeigen, dass er sich freut.", a:"Albanisches Sprichwort" },
|
||||
{ t:"Ein Hund, der mit dem Schwanz wedelt, bezieht keine Prügel.", a:"Japanisches Sprichwort" },
|
||||
{ t:"Der hungrige Hund fürchtet den Stock nicht.", a:"Japanisches Sprichwort" },
|
||||
{ t:"Treffen sich im Paradies eine Menschenseele und eine Hundeseele, verneigt sich der Mensch vor dem Hund.", a:"Sibirisches Sprichwort" },
|
||||
{ t:"Solange der Mensch denkt, Tiere fühlten nicht, fühlen Tiere, dass der Mensch nicht denkt.", a:"Indianische Weisheit" },
|
||||
{ t:"Mit Hunden zu leben tut dem Menschen gut.", a:"Tibetisches Sprichwort" },
|
||||
{ t:"Ein Hund ist ein Herz auf vier Beinen.", a:"Irisches Sprichwort" },
|
||||
{ t:"Der Hund vergisst den einen Bissen nicht, und wirfst du ihm auch hundert Steine nach.", a:"Chinesisches Sprichwort" },
|
||||
{ t:"Sei der Freund meines Hundes, dann bist du auch der meine.", a:"Indianische Weisheit" },
|
||||
{ t:"Hüte dich vor dem Menschen, der nicht spricht, und vor dem Hund, der nicht bellt.", a:"Indianische Weisheit" },
|
||||
{ t:"Ein kluger Hund bellt nicht ohne Grund.", a:"Französisches Sprichwort" },
|
||||
{ t:"In seiner eigenen Hütte ist jeder Hund ein Löwe.", a:"Französisches Sprichwort" },
|
||||
{ t:"Hunde, die sich beißen, halten gegen den Wolf zusammen.", a:"Armenisches Sprichwort" },
|
||||
{ t:"Der Hund bellt, doch die Karawane zieht weiter.", a:"Türkisches Sprichwort" },
|
||||
{ t:"Hat ein Armer den Hund großgezogen, folgt er keinem Reichen mehr.", a:"Mongolisches Sprichwort" },
|
||||
{ t:"Hat der Hund zu viele Herren, schläft er hungrig ein.", a:"Afrikanisches Sprichwort" },
|
||||
{ t:"Ich hoffe, einmal der Mensch zu werden, für den mein Hund mich hält.", a:"Ungarisches Sprichwort" },
|
||||
{ t:"Eines Hundes Treue währt ein ganzes Leben lang.", a:"Spanisches Sprichwort" },
|
||||
{ t:"Faule Schäfer haben die besten Hunde.", a:"Deutsches Sprichwort" },
|
||||
{ t:"Hunde, die viel bellen, beißen selten.", a:"Italienisches Sprichwort" },
|
||||
{ t:"Wer einen guten Hund hat, braucht keinen Wächter.", a:"Italienisches Sprichwort" },
|
||||
{ t:"Wo der Hund frei laufen darf, ist das Glück nicht weit.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund schaut nicht auf deinen Stand, nur auf dein Herz.", a:"Unbekannt" },
|
||||
{ t:"Dem Hund ist gleich, ob du reich bist; ihm reicht, dass du heimkommst.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund braucht keine Worte, um dich zu trösten.", a:"Unbekannt" },
|
||||
{ t:"Der beste Platz der Welt ist neben einem Hund.", a:"Unbekannt" },
|
||||
{ t:"Ein Tag mit Hund ist nie ganz verloren.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund füllt die Stille im Haus mit leisem Glück.", a:"Unbekannt" },
|
||||
{ t:"Hunde messen die Zeit nicht in Stunden, sondern in Spaziergängen.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund findet zum Glück immer den kürzesten Weg.", a:"Unbekannt" },
|
||||
{ t:"Mit einem Hund an der Seite läuft man nie allein.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund vergibt schneller, als wir uns entschuldigen können.", a:"Unbekannt" },
|
||||
{ t:"Wer die Sprache der Hunde lernt, hört auf zu reden und beginnt zu fühlen.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund kennt deinen Namen nicht, aber er kennt dein Herz.", a:"Unbekannt" },
|
||||
{ t:"Hunde sind die Pünktlichsten, wenn es ums Glücklichsein geht.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund wartet nicht auf morgen, um dich heute zu lieben.", a:"Unbekannt" },
|
||||
{ t:"Manche Engel haben Fell und kalte Pfoten.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund nimmt dich, wie du bist, und macht dich trotzdem besser.", a:"Unbekannt" },
|
||||
{ t:"Was ein Hund über Freundschaft weiß, lernt der Mensch ein Leben lang.", a:"Unbekannt" },
|
||||
{ t:"Hunde haben kurze Leben, weil sie das Lieben so gut können, dass sie keine Zeit verschwenden.", a:"Unbekannt" },
|
||||
{ t:"Glück hat vier Pfoten und einen wedelnden Schwanz.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund teilt dein Schweigen, ohne es zu füllen.", a:"Unbekannt" },
|
||||
{ t:"Der treueste Blick der Welt kommt von unten und wedelt dabei.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund braucht keinen Sonntag, jeder Tag mit dir ist ihm Feiertag.", a:"Unbekannt" },
|
||||
{ t:"Wo ein Hund die Stiefel bringt, fehlt es nie an Liebe.", a:"Unbekannt" },
|
||||
{ t:"Hunde rechnen nicht nach, wie viel du gibst; sie geben einfach alles zurück.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund sieht nicht, wie du aussiehst, sondern wer du bist.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund macht aus einem Spaziergang ein kleines Abenteuer.", a:"Unbekannt" },
|
||||
{ t:"Wer einem Hund ins Auge sieht, schaut der Ehrlichkeit beim Atmen zu.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund spürt deinen schlechten Tag und bleibt trotzdem.", a:"Unbekannt" },
|
||||
{ t:"Das Schwierigste am Hundeleben ist, dass es zu kurz für so viel Liebe ist.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund braucht wenig und schenkt davon das Meiste.", a:"Unbekannt" },
|
||||
{ t:"Hunde sind der Beweis, dass Treue keine Worte braucht.", a:"Unbekannt" },
|
||||
{ t:"Ein wedelnder Schwanz hat schon manchen Tag gerettet.", a:"Unbekannt" },
|
||||
{ t:"Wer einen Hund versteht, braucht den Menschen weniger zu erklären.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund spart seine Liebe nicht auf, er verschenkt sie sofort und vollständig.", a:"Unbekannt" },
|
||||
{ t:"Die kürzeste Verbindung zwischen zwei Menschen ist manchmal eine Hundeleine.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund kennt keinen Stolz, nur Wiedersehensfreude.", a:"Unbekannt" },
|
||||
{ t:"Wer mit einem Hund alt wird, lernt das Glück im Kleinen.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund hält dir keine Reden, er hält dir die Treue.", a:"Unbekannt" },
|
||||
{ t:"Vor einem Hund muss man nichts vorgeben; er liebt das Echte.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund schreibt keine Briefe, doch sein Schwanz erzählt alles.", a:"Unbekannt" },
|
||||
{ t:"Hunde nehmen die kleinen Dinge ernst, darum sind sie so groß im Lieben.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund verzeiht dir den Regen, solange du mit ihm hinausgehst.", a:"Unbekannt" },
|
||||
{ t:"Wer das Vertrauen eines Hundes gewinnt, hat etwas Selteneres als Gold.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund macht stille Tage warm und laute Tage leiser.", a:"Unbekannt" },
|
||||
{ t:"Die treueste Uhr im Haus ist der Hund vor dem Futternapf.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund fragt nicht, wie der Tag war; er macht ihn einfach besser.", a:"Unbekannt" },
|
||||
{ t:"Wer einen Hund an der Seite hat, ist nirgends ganz fremd.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund kennt nur ein Tempo bei der Liebe: sofort.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund am Feuer wärmt mehr als das Feuer selbst.", a:"Unbekannt" },
|
||||
{ t:"Wer den Hund gut behandelt, dem öffnet sich das Herz von selbst.", a:"Unbekannt" },
|
||||
{ t:"Ein Hund braucht keinen Kalender, er weiß genau, wann du nach Hause kommst.", a:"Unbekannt" },
|
||||
];
|
||||
|
||||
function _renderWelt() {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<script src="/js/landing-init.js?v=1278"></script>
|
||||
<script src="/js/landing-init.js?v=1292"></script>
|
||||
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
|
||||
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go).">
|
||||
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1278';
|
||||
const VER = '1292';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ services:
|
|||
- DB_PATH=/data/banyaro.db
|
||||
- MEDIA_DIR=/data/media
|
||||
- UMAMI_URL=https://umami.motocamp.de
|
||||
- KI_MODE=cloud
|
||||
# VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_CONTACT
|
||||
# → kommen aus .env (nicht in Git)
|
||||
healthcheck:
|
||||
|
|
|
|||
|
|
@ -13,5 +13,13 @@ for f in tests/js/test-map-offline*.js; do node "$f" backend/static/js/map-offli
|
|||
- r6: Standort-Grundversorgung (ensureHomeArea: lädt/skippt/Cap, überlebt clear)
|
||||
- r7: selektives Löschen (Korridor-Keep via keepTracks, manuelle Gebiete weg, Komplett-Wipe-Fallback)
|
||||
|
||||
Eigenständig (kein Stub-Argument nötig):
|
||||
|
||||
```
|
||||
node tests/js/test-nav-loop-closestidx.js
|
||||
```
|
||||
|
||||
- nav-loop-closestidx: Navi-Erst-Fix bei Runden springt nicht ans Track-Ende (spiegelt `_closestIdx` aus `js/pages/routes.js`) — Bugfix Angie/Deining 09.06.2026
|
||||
|
||||
⚠️ Node 21+: eingebautes `navigator`-Global — Stubs via `Object.defineProperty(globalThis, 'navigator', …)`,
|
||||
ein einfaches `global.navigator =` wird still verschluckt.
|
||||
|
|
|
|||
98
tests/js/test-nav-loop-closestidx.js
Normal file
98
tests/js/test-nav-loop-closestidx.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Navi-Erst-Fix bei RUNDEN: der Startindex darf nicht ans Track-Ende springen.
|
||||
//
|
||||
// Spiegelt die _closestIdx-Erst-Fix-Logik aus js/pages/routes.js (_startNav). An einem
|
||||
// Start/Ende-Knoten einer Runde ist der ENDPUNKT oft ein paar Meter näher als der
|
||||
// Startpunkt; die alte globale Suche sprang dann sofort ans Track-Ende → 100 % / 0 km ab
|
||||
// Sekunde 1 (Angie, Deining-Runde 09.06.2026). Bei Änderung BEIDE Stellen anpassen.
|
||||
//
|
||||
// Hinweis: bewusst eine Nachbildung — die echte Funktion ist eine Closure in _startNav
|
||||
// und nicht exportierbar, ohne routes.js umzubauen.
|
||||
|
||||
const _haversineKm = (lat1, lon1, lat2, lon2) => {
|
||||
const R = 6371, dLat = (lat2 - lat1) * Math.PI / 180, dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
// Erst-Fix-Index für gegebenen track + Userposition (1:1 aus routes.js).
|
||||
function firstFixIdx(track, lat, lon) {
|
||||
const search = (from, to) => {
|
||||
let best = from, bestD = Infinity;
|
||||
for (let i = from; i <= to; i++) {
|
||||
const d = _haversineKm(lat, lon, track[i].lat, track[i].lon);
|
||||
if (d < bestD) { bestD = d; best = i; }
|
||||
}
|
||||
return { best, bestD };
|
||||
};
|
||||
const isLoop = track.length > 2 &&
|
||||
_haversineKm(track[0].lat, track[0].lon,
|
||||
track[track.length - 1].lat, track[track.length - 1].lon) < 0.06;
|
||||
const g = search(0, track.length - 1);
|
||||
if (isLoop) {
|
||||
const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15)));
|
||||
const s = search(0, win);
|
||||
return { idx: s.bestD < 0.15 ? s.best : g.best, isLoop };
|
||||
}
|
||||
const s = search(0, Math.min(track.length - 1, 30));
|
||||
return { idx: (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best, isLoop };
|
||||
}
|
||||
|
||||
// Die ALTE Logik (vor dem Fix) — nur zum Beweis, dass der Fix wirklich etwas ändert.
|
||||
function firstFixIdxOld(track, lat, lon) {
|
||||
const search = (from, to) => {
|
||||
let best = from, bestD = Infinity;
|
||||
for (let i = from; i <= to; i++) {
|
||||
const d = _haversineKm(lat, lon, track[i].lat, track[i].lon);
|
||||
if (d < bestD) { bestD = d; best = i; }
|
||||
}
|
||||
return { best, bestD };
|
||||
};
|
||||
const g = search(0, track.length - 1);
|
||||
const s = search(0, Math.min(track.length - 1, 30));
|
||||
return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best;
|
||||
}
|
||||
|
||||
// --- Synthetische Deining-artige Runde -------------------------------------
|
||||
const C = { lat: 48.07, lon: 11.50 };
|
||||
const mLat = m => m / 111320;
|
||||
const mLon = (m, lat) => m / (111320 * Math.cos(lat * Math.PI / 180));
|
||||
// Punkt auf einem Kreis: Winkel von Nord, im Uhrzeigersinn.
|
||||
const onCircle = (deg, r) => {
|
||||
const rad = deg * Math.PI / 180;
|
||||
return { lat: C.lat + mLat(r * Math.cos(rad)), lon: C.lon + mLon(r * Math.sin(rad), C.lat) };
|
||||
};
|
||||
|
||||
const N = 40, R = 80; // 40 Punkte auf 80-m-Kreis, lange Runde von 0°→329°
|
||||
const track = [];
|
||||
for (let i = 0; i < N; i++) track.push(onCircle(i / (N - 1) * 329, R));
|
||||
// User steht 3 m außerhalb des ENDpunkts (329°) → näher am Ende als am Start.
|
||||
const user = onCircle(329, R + 3);
|
||||
|
||||
const startEndM = _haversineKm(track[0].lat, track[0].lon,
|
||||
track[N - 1].lat, track[N - 1].lon) * 1000;
|
||||
const dStart = _haversineKm(user.lat, user.lon, track[0].lat, track[0].lon) * 1000;
|
||||
const dEnd = _haversineKm(user.lat, user.lon, track[N - 1].lat, track[N - 1].lon) * 1000;
|
||||
console.log(`Runde: Start↔Ende ${startEndM.toFixed(0)} m | User→Start ${dStart.toFixed(0)} m, User→Ende ${dEnd.toFixed(0)} m`);
|
||||
|
||||
// 1. Loop wird erkannt (Start ≈ Ende < 60 m)
|
||||
const res = firstFixIdx(track, user.lat, user.lon);
|
||||
if (!res.isLoop) throw new Error('Runde nicht als Loop erkannt');
|
||||
|
||||
// 2. Erst-Fix landet im STARTbereich, NICHT am Track-Ende
|
||||
console.log('Erst-Fix-Index:', res.idx, '(von', N - 1 + ')');
|
||||
if (res.idx > Math.floor(N * 0.15)) throw new Error(`Erst-Fix sprang weg vom Start (idx ${res.idx})`);
|
||||
|
||||
// 3. Beweis: die alte Logik wäre hier ans Ende gesprungen (100 %)
|
||||
const old = firstFixIdxOld(track, user.lat, user.lon);
|
||||
console.log('Alte Logik-Index:', old);
|
||||
if (old !== N - 1) throw new Error('Erwartet: alte Logik springt ans Ende — Testfall trifft den Bug nicht mehr');
|
||||
|
||||
// 4. Punkt-zu-Punkt-Route (kein Loop): User am Start → 0 %, am Ende → bleibt sinnvoll
|
||||
const ptp = [];
|
||||
for (let i = 0; i < N; i++) ptp.push({ lat: C.lat + mLat(i * 25), lon: C.lon }); // 25-m-Schritte nach Norden
|
||||
const ptpRes = firstFixIdx(ptp, ptp[0].lat, ptp[0].lon);
|
||||
if (ptpRes.isLoop) throw new Error('Gerade Route fälschlich als Loop erkannt');
|
||||
if (ptpRes.idx !== 0) throw new Error(`Punkt-zu-Punkt am Start sollte idx 0 sein, war ${ptpRes.idx}`);
|
||||
|
||||
console.log('\nALLE NAV-LOOP-TESTS BESTANDEN');
|
||||
|
|
@ -59,3 +59,30 @@ def test_delete_account_minimal_user(client):
|
|||
assert resp.status_code == 200, resp.text
|
||||
with db() as conn:
|
||||
assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None
|
||||
|
||||
|
||||
def test_delete_account_purges_note_media(client):
|
||||
"""Account-Löschung entfernt Notiz-Medien — DB-Zeilen UND Dateien auf Disk."""
|
||||
import io, os
|
||||
from database import db
|
||||
from PIL import Image
|
||||
|
||||
uid, headers = _make_user(client)
|
||||
nid = client.post("/api/notes/diary/1", headers=headers,
|
||||
json={"text": "Mit Foto", "parent_label": "X"}).json()["id"]
|
||||
|
||||
buf = io.BytesIO(); Image.new("RGB", (10, 10), (1, 2, 3)).save(buf, format="JPEG")
|
||||
up = client.post(f"/api/notes/{nid}/media", headers=headers,
|
||||
files={"file": ("f.jpg", buf.getvalue(), "image/jpeg")})
|
||||
assert up.status_code == 200, up.text
|
||||
url = up.json()["url"]
|
||||
fpath = os.path.join(os.getenv("MEDIA_DIR", "/data/media"), url[len("/media/"):])
|
||||
assert os.path.exists(fpath)
|
||||
|
||||
resp = client.delete("/api/profile/account", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
with db() as conn:
|
||||
assert conn.execute("SELECT COUNT(*) c FROM note_media WHERE note_id=?", (nid,)).fetchone()["c"] == 0
|
||||
assert conn.execute("SELECT COUNT(*) c FROM notes WHERE user_id=?", (uid,)).fetchone()["c"] == 0
|
||||
assert not os.path.exists(fpath), "note_media-Datei blieb als Leiche auf Disk"
|
||||
|
|
|
|||
129
tests/test_notes_media.py
Normal file
129
tests/test_notes_media.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Tests für Notiz-Medien: Bild-/Audio-Upload, GET liefert media_items,
|
||||
Löschen entfernt DB-Zeile + Datei, Notiz-Delete räumt Dateien, Validierung."""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _jpeg_bytes(color=(200, 100, 50)):
|
||||
from PIL import Image
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (12, 12), color).save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# Minimaler MP4/M4A-Header (ftyp-Box) — genügt für validate_audio(audio/mp4).
|
||||
_M4A_BYTES = b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A mp42isom" + b"\x00" * 32
|
||||
|
||||
|
||||
def _create_note(client, user, text="Notiz mit Medien"):
|
||||
r = client.post("/api/notes/diary/1", headers=user["headers"],
|
||||
json={"text": text, "parent_label": "Testobjekt"})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _media_path(url):
|
||||
media_dir = os.getenv("MEDIA_DIR", "/data/media")
|
||||
rel = url[len("/media/"):] if url.startswith("/media/") else url.lstrip("/")
|
||||
return os.path.join(media_dir, rel)
|
||||
|
||||
|
||||
def test_upload_image_to_note(client, user):
|
||||
note = _create_note(client, user)
|
||||
r = client.post(
|
||||
f"/api/notes/{note['id']}/media",
|
||||
headers=user["headers"],
|
||||
files={"file": ("foto.jpg", _jpeg_bytes(), "image/jpeg")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
m = r.json()
|
||||
assert m["media_type"] == "image"
|
||||
assert m["url"].startswith("/media/notes/")
|
||||
assert os.path.exists(_media_path(m["url"]))
|
||||
|
||||
|
||||
def test_note_get_includes_media_items(client, user):
|
||||
note = _create_note(client, user)
|
||||
client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
||||
files={"file": ("a.jpg", _jpeg_bytes(), "image/jpeg")})
|
||||
r = client.get("/api/notes/diary/1", headers=user["headers"])
|
||||
assert r.status_code == 200
|
||||
target = next(n for n in r.json() if n["id"] == note["id"])
|
||||
assert len(target["media_items"]) == 1
|
||||
assert target["media_items"][0]["media_type"] == "image"
|
||||
|
||||
|
||||
def test_upload_audio_to_note(client, user):
|
||||
# Reine Sprachnotiz: leerer Text ist erlaubt, das Medium trägt die Notiz.
|
||||
note = _create_note(client, user, text="")
|
||||
r = client.post(
|
||||
f"/api/notes/{note['id']}/media",
|
||||
headers=user["headers"],
|
||||
files={"file": ("sprachnachricht.m4a", _M4A_BYTES, "audio/mp4")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
m = r.json()
|
||||
assert m["media_type"] == "audio"
|
||||
assert m["url"].endswith(".m4a")
|
||||
assert os.path.exists(_media_path(m["url"]))
|
||||
|
||||
|
||||
def test_delete_media_removes_row_and_file(client, user):
|
||||
note = _create_note(client, user)
|
||||
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
||||
files={"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}).json()
|
||||
path = _media_path(up["url"])
|
||||
assert os.path.exists(path)
|
||||
|
||||
r = client.delete(f"/api/notes/{note['id']}/media/{up['id']}", headers=user["headers"])
|
||||
assert r.status_code == 204
|
||||
assert not os.path.exists(path)
|
||||
|
||||
g = client.get("/api/notes/diary/1", headers=user["headers"]).json()
|
||||
target = next(n for n in g if n["id"] == note["id"])
|
||||
assert target["media_items"] == []
|
||||
|
||||
|
||||
def test_delete_note_removes_media_files(client, user):
|
||||
note = _create_note(client, user)
|
||||
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
||||
files={"file": ("y.jpg", _jpeg_bytes(), "image/jpeg")}).json()
|
||||
path = _media_path(up["url"])
|
||||
assert os.path.exists(path)
|
||||
|
||||
r = client.delete(f"/api/notes/{note['id']}", headers=user["headers"])
|
||||
assert r.status_code == 204
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
def test_upload_rejects_corrupt_image(client, user):
|
||||
note = _create_note(client, user)
|
||||
r = client.post(
|
||||
f"/api/notes/{note['id']}/media",
|
||||
headers=user["headers"],
|
||||
files={"file": ("fake.jpg", b"this is not a jpeg", "image/jpeg")},
|
||||
)
|
||||
assert r.status_code == 415
|
||||
|
||||
|
||||
def test_upload_media_requires_own_note(client, user):
|
||||
r = client.post(
|
||||
"/api/notes/999999/media",
|
||||
headers=user["headers"],
|
||||
files={"file": ("z.jpg", _jpeg_bytes(), "image/jpeg")},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_audio_utils_unit():
|
||||
"""to_m4a reicht bereits-AAC durch; validate_audio prüft Magic-Bytes."""
|
||||
from media_utils import to_m4a, validate_audio
|
||||
data, ext = to_m4a(_M4A_BYTES, ".m4a")
|
||||
assert ext == ".m4a"
|
||||
assert data == _M4A_BYTES # schon AAC → keine Transkodierung
|
||||
validate_audio(_M4A_BYTES, "audio/mp4") # kein Raise
|
||||
with pytest.raises(ValueError):
|
||||
validate_audio(b"xxxx", "audio/mp4")
|
||||
Loading…
Add table
Add a link
Reference in a new issue