Notiz-Medien & Sprachnachrichten: Fotos/Videos/Dateien + Audio an Notizen

Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.

admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
This commit is contained in:
rene 2026-06-14 20:22:35 +02:00
parent 203da50e1d
commit e86d89f3d9
12 changed files with 947 additions and 59 deletions

View file

@ -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,

View file

@ -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'; "

View file

@ -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:

View file

@ -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"])
# ------------------------------------------------------------------

View file

@ -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

View file

@ -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"}

View file

@ -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 &amp; Notizen:</strong> Texte, Fotos, Stimmungseinträge (privat, nur für dich)</li>
<li><strong>Tagebuch &amp; 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 &amp; 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>

View file

@ -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}`);
},
};
// ----------------------------------------------------------

View file

@ -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();

View file

@ -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,