diff --git a/backend/database.py b/backend/database.py index c64783a..7554627 100644 --- a/backend/database.py +++ b/backend/database.py @@ -630,3 +630,21 @@ def _migrate(conn_factory): SELECT id, dog_id FROM diary """) logger.info("Migration: diary_dogs Backfill abgeschlossen.") + + # Hund-Teilen: Einladungssystem + conn.executescript(""" + CREATE TABLE IF NOT EXISTS dog_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + shared_with_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + invite_token TEXT NOT NULL UNIQUE, + role TEXT NOT NULL DEFAULT 'editor', + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_dog_shares_dog ON dog_shares(dog_id); + CREATE INDEX IF NOT EXISTS idx_dog_shares_token ON dog_shares(invite_token); + CREATE INDEX IF NOT EXISTS idx_dog_shares_user ON dog_shares(shared_with_id); + """) + logger.info("Migration: dog_shares Tabelle bereit.") diff --git a/backend/main.py b/backend/main.py index 4a6fe82..3e39cdd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -74,6 +74,8 @@ from routes.admin import router as admin_router from routes.webcal import router as webcal_router from routes.profile import router as profile_router from routes.import_data import router as import_router +from routes.sharing import dog_router as sharing_dog_router, share_router as sharing_share_router +from routes.widget import router as widget_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -99,7 +101,10 @@ app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) -app.include_router(import_router, prefix="/api/import", tags=["Import"]) +app.include_router(import_router, prefix="/api/import", tags=["Import"]) +app.include_router(sharing_dog_router, prefix="/api/dogs", tags=["Teilen"]) +app.include_router(sharing_share_router, prefix="/api/share", tags=["Teilen"]) +app.include_router(widget_router, prefix="/api/widget", tags=["Widget"]) # ------------------------------------------------------------------ @@ -445,6 +450,212 @@ async def public_dog_page(dog_id: int): return HTMLResponse(content=html) +# ------------------------------------------------------------------ +# Einladungsseite /teilen/{token} — SPA lädt + nimmt Einladung an +# ------------------------------------------------------------------ +@app.get("/teilen/{token}") +async def invite_page(token: str): + return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) + + +# ------------------------------------------------------------------ +# Widget-Vorschau /widget +# ------------------------------------------------------------------ +@app.get("/widget") +async def widget_page(): + return FileResponse(f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"}) + + +# ------------------------------------------------------------------ +# Digitaler Heimtierausweis /ausweis/{dog_id} +# ------------------------------------------------------------------ +@app.get("/ausweis/{dog_id}") +async def ausweis_page(dog_id: int, request: Request): + from fastapi.responses import HTMLResponse + from auth import get_current_user_optional, decode_token + import json as _json + + # Auth via Cookie + token = request.cookies.get("by_token") + user_id = None + if token: + try: + payload = decode_token(token) + user_id = int(payload["sub"]) + except Exception: + pass + + if not user_id: + return HTMLResponse( + '

' + 'Bitte einloggen um den Ausweis anzuzeigen.

', + status_code=401 + ) + + from database import db as _db + with _db() as conn: + dog = conn.execute( + """SELECT d.* FROM dogs d + LEFT JOIN dog_shares ds ON ds.dog_id=d.id AND ds.shared_with_id=? AND ds.accepted_at IS NOT NULL + WHERE d.id=? AND (d.user_id=? OR ds.id IS NOT NULL)""", + (user_id, dog_id, user_id) + ).fetchone() + if not dog: + return HTMLResponse("

Hund nicht gefunden.

", status_code=404) + + owner = conn.execute("SELECT name, email FROM users WHERE id=?", (dog["user_id"],)).fetchone() + + health_rows = conn.execute( + "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC", + (dog_id,) + ).fetchall() + + vets = conn.execute( + """SELECT DISTINCT t.name, t.strasse, t.plz, t.ort, t.telefon + FROM tieraerzte t + JOIN health h ON h.tierarzt_id = t.id + WHERE h.dog_id=?""", + (dog_id,) + ).fetchall() + + dog = dict(dog) + vets = [dict(v) for v in vets] + + def esc(s): + if not s: return "" + return str(s).replace("&","&").replace("<","<").replace(">",">").replace('"',""") + + def fmt_date(d): + if not d: return "–" + try: + from datetime import date + parts = d.split("-") + return f"{int(parts[2])}.{int(parts[1])}.{parts[0]}" + except Exception: + return d + + geschlecht = {"m": "Rüde", "w": "Hündin"}.get(dog.get("geschlecht",""), "–") + + # Impfungen + impfungen = [r for r in health_rows if r["typ"] == "impfung"] + # Medikamente (aktiv) + medis = [r for r in health_rows if r["typ"] == "medikament" and r["aktiv"]] + # Allergien + allergien = [r for r in health_rows if r["typ"] == "allergie"] + + def health_rows_html(rows, cols): + if not rows: + return 'Keine Einträge' + out = "" + for r in rows: + out += "" + "".join(f"{esc(r[c])}" for c in cols) + "" + return out + + photo_html = f'{esc(dog[' if dog.get("foto_url") else '
🐕
' + + vets_html = "" + for v in vets: + addr = ", ".join(filter(None, [v.get("strasse"), v.get("plz"), v.get("ort")])) + vets_html += f'
{esc(v["name"])}' + if addr: vets_html += f'
{esc(addr)}' + if v.get("telefon"): vets_html += f'
☎ {esc(v["telefon"])}' + vets_html += "
" + if not vets_html: + vets_html = 'Keine Tierärzte eingetragen' + + html = f""" + + + + +Heimtierausweis – {esc(dog["name"])} + + + +
+
+ {photo_html} +
+

{esc(dog["name"])}

+
{esc(dog.get("rasse") or "Rasse unbekannt")}
+
+
Geburtstag
{fmt_date(dog.get("geburtstag"))}
+
Geschlecht
{geschlecht}
+
Gewicht
{f'{dog["gewicht_kg"]} kg' if dog.get("gewicht_kg") else "–"}
+
Transponder
{esc(dog.get("chip_nr")) or "–"}
+
Besitzer
{esc(owner["name"]) if owner else "–"}
+
+
+
+ +
+ + +
+

Impfungen

+ + + {health_rows_html(impfungen, ["bezeichnung","datum","naechstes","charge_nr","tierarzt_name"])} +
ImpfungDatumNächste FälligkeitChargeTierarzt
+
+ +
+

Aktive Medikamente

+ + + {health_rows_html(medis, ["bezeichnung","datum","dosierung","haeufigkeit"])} +
MedikamentSeitDosierungHäufigkeit
+
+ +
+

Allergien & Unverträglichkeiten

+ + + {health_rows_html(allergien, ["bezeichnung","schweregrad","reaktion","datum"])} +
AllergenSchweregradReaktionSeit
+
+ +
+

Tierärzte

+ {vets_html} +
+
+ +
+ +""" + return HTMLResponse(html) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/routes/diary.py b/backend/routes/diary.py index ba59f6d..8e59465 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -33,9 +33,17 @@ class DiaryUpdate(BaseModel): def _own_dog(dog_id: int, user_id: int, conn): + """Eigener Hund ODER geteilter Hund (angenommene Einladung).""" dog = conn.execute( "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id) ).fetchone() + if not dog: + dog = conn.execute( + """SELECT d.id FROM dogs d + JOIN dog_shares ds ON ds.dog_id = d.id + WHERE d.id=? AND ds.shared_with_id=? AND ds.accepted_at IS NOT NULL""", + (dog_id, user_id) + ).fetchone() if not dog: raise HTTPException(404, "Hund nicht gefunden.") return dog @@ -82,18 +90,30 @@ def _entry_dict(row, dog_ids_map: dict) -> dict: @router.get("/{dog_id}/diary") async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, + q: Optional[str] = None, user=Depends(get_current_user)): with db() as conn: _own_dog(dog_id, user["id"], conn) - # Einträge des primären Hundes SOWIE Einträge wo der Hund als weiterer zugeordnet ist - rows = conn.execute( - """SELECT DISTINCT d.* FROM diary d - LEFT JOIN diary_dogs dd ON dd.diary_id = d.id - WHERE d.dog_id = ? OR dd.dog_id = ? - ORDER BY d.datum DESC, d.created_at DESC - LIMIT ? OFFSET ?""", - (dog_id, dog_id, limit, offset) - ).fetchall() + if q: + pattern = f"%{q}%" + rows = conn.execute( + """SELECT DISTINCT d.* FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE (d.dog_id = ? OR dd.dog_id = ?) + AND (d.titel LIKE ? OR d.text LIKE ? OR d.tags LIKE ?) + ORDER BY d.datum DESC, d.created_at DESC + LIMIT ? OFFSET ?""", + (dog_id, dog_id, pattern, pattern, pattern, limit, offset) + ).fetchall() + else: + rows = conn.execute( + """SELECT DISTINCT d.* FROM diary d + LEFT JOIN diary_dogs dd ON dd.diary_id = d.id + WHERE d.dog_id = ? OR dd.dog_id = ? + ORDER BY d.datum DESC, d.created_at DESC + LIMIT ? OFFSET ?""", + (dog_id, dog_id, limit, offset) + ).fetchall() ids = [r["id"] for r in rows] dogs_map = _fetch_dog_ids(conn, ids) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 94849a7..d5ffd29 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -38,10 +38,19 @@ class DogUpdate(BaseModel): @router.get("") async def list_dogs(user=Depends(get_current_user)): with db() as conn: - rows = conn.execute( - "SELECT * FROM dogs WHERE user_id=? ORDER BY id", (user["id"],) + own = conn.execute( + "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? ORDER BY id", + (user["id"],) ).fetchall() - return [dict(r) for r in rows] + shared = conn.execute( + """SELECT d.*, u.name AS shared_by, ds.role AS share_role + FROM dog_shares ds + JOIN dogs d ON d.id = ds.dog_id + JOIN users u ON u.id = ds.owner_id + WHERE ds.shared_with_id = ? AND ds.accepted_at IS NOT NULL""", + (user["id"],) + ).fetchall() + return [dict(r) for r in own] + [dict(r) for r in shared] @router.post("") diff --git a/backend/routes/sharing.py b/backend/routes/sharing.py new file mode 100644 index 0000000..eb2aa58 --- /dev/null +++ b/backend/routes/sharing.py @@ -0,0 +1,141 @@ +"""BAN YARO — Hund teilen (Familie/Partner)""" + +import secrets +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from database import db +from auth import get_current_user + +# Hunde-spezifische Routen → eingebunden unter /api/dogs +dog_router = APIRouter() + +# Token-basierte Routen → eingebunden unter /api/share +share_router = APIRouter() + + +class ShareInvite(BaseModel): + role: str = "editor" # viewer | editor + + +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/share → Einladungs-Link erzeugen +# ------------------------------------------------------------------ +@dog_router.post("/{dog_id}/share", status_code=201) +async def create_share(dog_id: int, data: ShareInvite, + user=Depends(get_current_user)): + if data.role not in ("viewer", "editor"): + raise HTTPException(400, "Rolle muss 'viewer' oder 'editor' sein.") + + with db() as conn: + dog = conn.execute( + "SELECT id, name FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + token = secrets.token_urlsafe(24) + conn.execute( + """INSERT INTO dog_shares (dog_id, owner_id, invite_token, role) + VALUES (?, ?, ?, ?)""", + (dog_id, user["id"], token, data.role), + ) + return {"token": token, "invite_path": f"/teilen/{token}"} + + +# ------------------------------------------------------------------ +# GET /api/dogs/{dog_id}/shares → aktive Einladungen auflisten +# ------------------------------------------------------------------ +@dog_router.get("/{dog_id}/shares") +async def list_shares(dog_id: int, user=Depends(get_current_user)): + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Nicht gefunden.") + rows = conn.execute( + """SELECT ds.id, ds.invite_token, ds.role, ds.accepted_at, + u.name AS shared_with_name, u.email AS shared_with_email + FROM dog_shares ds + LEFT JOIN users u ON u.id = ds.shared_with_id + WHERE ds.dog_id = ? + ORDER BY ds.created_at DESC""", + (dog_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# DELETE /api/dogs/{dog_id}/share/{share_id} → Freigabe widerrufen +# ------------------------------------------------------------------ +@dog_router.delete("/{dog_id}/share/{share_id}", status_code=204) +async def revoke_share(dog_id: int, share_id: int, + user=Depends(get_current_user)): + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Nicht gefunden.") + conn.execute( + "DELETE FROM dog_shares WHERE id=? AND dog_id=?", + (share_id, dog_id) + ) + + +# ------------------------------------------------------------------ +# POST /api/share/accept/{token} → Einladung annehmen +# ------------------------------------------------------------------ +@share_router.post("/accept/{token}") +async def accept_share(token: str, user=Depends(get_current_user)): + with db() as conn: + share = conn.execute( + """SELECT ds.*, d.name AS dog_name, u.name AS owner_name + FROM dog_shares ds + JOIN dogs d ON d.id = ds.dog_id + JOIN users u ON u.id = ds.owner_id + WHERE ds.invite_token = ?""", + (token,) + ).fetchone() + + if not share: + raise HTTPException(404, "Einladungslink ungültig oder abgelaufen.") + if share["owner_id"] == user["id"]: + raise HTTPException(400, "Das ist dein eigener Hund.") + if share["accepted_at"]: + return {"message": "Bereits angenommen.", "dog_name": share["dog_name"]} + + conn.execute( + """UPDATE dog_shares + SET shared_with_id = ?, accepted_at = datetime('now') + WHERE invite_token = ?""", + (user["id"], token), + ) + return { + "message": "Einladung angenommen!", + "dog_name": share["dog_name"], + "owner_name": share["owner_name"], + } + + +# ------------------------------------------------------------------ +# GET /api/share/info/{token} → Info vor dem Annehmen (kein Auth nötig) +# ------------------------------------------------------------------ +@share_router.get("/info/{token}") +async def share_info(token: str): + with db() as conn: + share = conn.execute( + """SELECT d.name AS dog_name, d.foto_url, d.rasse, + u.name AS owner_name, ds.role, ds.accepted_at + FROM dog_shares ds + JOIN dogs d ON d.id = ds.dog_id + JOIN users u ON u.id = ds.owner_id + WHERE ds.invite_token = ?""", + (token,) + ).fetchone() + if not share: + raise HTTPException(404, "Einladungslink ungültig.") + return dict(share) diff --git a/backend/routes/widget.py b/backend/routes/widget.py new file mode 100644 index 0000000..f5cc940 --- /dev/null +++ b/backend/routes/widget.py @@ -0,0 +1,56 @@ +"""BAN YARO — Widget-Snapshot Endpoint""" + +import json, random +from fastapi import APIRouter, Depends +from database import db +from auth import get_current_user + +router = APIRouter() + + +@router.get("/snapshot") +async def widget_snapshot(user=Depends(get_current_user)): + """Liefert kompakte Widget-Daten: Hund, nächste Erinnerung, zufälliges Tagebuchbild.""" + with db() as conn: + # Aktiver Hund (erster oder letzter genutzter) + dog = conn.execute( + "SELECT id, name, rasse, foto_url FROM dogs WHERE user_id=? ORDER BY id LIMIT 1", + (user["id"],) + ).fetchone() + + if not dog: + return {"dog": None} + + dog_id = dog["id"] + + # Nächste fällige Erinnerung + reminder = conn.execute( + """SELECT bezeichnung, naechstes, typ FROM health + WHERE dog_id=? AND naechstes IS NOT NULL AND naechstes >= date('now') + ORDER BY naechstes ASC LIMIT 1""", + (dog_id,) + ).fetchone() + + # Zufälliges Tagebuchbild (letzte 50 Einträge mit Bild) + photos = conn.execute( + """SELECT media_url, titel, datum FROM diary + WHERE dog_id=? AND media_url IS NOT NULL + ORDER BY datum DESC LIMIT 50""", + (dog_id,) + ).fetchall() + + random_photo = dict(random.choice(photos)) if photos else None + + # Anzahl überfälliger Erinnerungen + overdue = conn.execute( + """SELECT COUNT(*) as n FROM health + WHERE dog_id=? AND naechstes IS NOT NULL AND naechstes < date('now')""", + (dog_id,) + ).fetchone()["n"] + + return { + "dog": dict(dog), + "reminder": dict(reminder) if reminder else None, + "random_photo": random_photo, + "overdue": overdue, + } diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 648f647..a62d998 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -239,6 +239,90 @@ .health-transponder-label { color: var(--c-text-muted); } .health-transponder-edit { margin-left: auto; } +/* Diary: Suchleiste */ +.diary-search-wrap { + position: relative; + flex: 1; + min-width: 0; +} +.diary-search-icon { + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + color: var(--c-text-muted); + pointer-events: none; +} +.diary-search-input { + width: 100%; + padding: var(--space-2) var(--space-3) var(--space-2) 2.2rem; + border: 1.5px solid var(--c-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-family: var(--font-sans); + background: var(--c-bg); + color: var(--c-text); + outline: none; + transition: border-color var(--transition-fast); +} +.diary-search-input:focus { border-color: var(--c-primary); } + +/* Widget-Karte */ +.widget-card { + background: var(--c-surface); + border-radius: var(--radius-xl); + overflow: hidden; + box-shadow: var(--shadow-sm); +} +.widget-dog-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); +} +.widget-dog-av { + width: 48px; height: 48px; border-radius: 50%; + object-fit: cover; border: 2px solid var(--c-primary-light); + flex-shrink: 0; +} +.widget-dog-av--placeholder { + display: flex; align-items: center; justify-content: center; + background: var(--c-surface-2); font-size: 1.5rem; +} +.widget-reminder { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--c-primary-subtle); + color: var(--c-primary-dark); + border-top: 1px solid var(--c-border-light); +} +.widget-reminder--overdue { background: var(--c-danger-subtle); color: var(--c-danger); } +.widget-reminder--ok { background: var(--c-success-subtle); color: var(--c-success); } +.widget-photo-wrap { + position: relative; + aspect-ratio: 4/3; + overflow: hidden; + border-top: 1px solid var(--c-border-light); + display: flex; align-items: center; justify-content: center; + background: var(--c-surface-2); +} +.widget-photo { width: 100%; height: 100%; object-fit: cover; display: block; } +.widget-photo-placeholder { flex-direction: column; gap: var(--space-2); } +.widget-photo-caption { + position: absolute; + bottom: 0; left: 0; right: 0; + padding: var(--space-2) var(--space-3); + background: linear-gradient(transparent, rgba(0,0,0,.55)); + color: #fff; + font-size: var(--text-sm); + display: flex; + justify-content: space-between; + align-items: flex-end; +} +.widget-photo-date { font-size: var(--text-xs); opacity: .8; } + /* Import: Format-Auswahl-Karten */ .import-format-card { display: flex; diff --git a/backend/static/index.html b/backend/static/index.html index 9ee713d..fe5c0a2 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -247,6 +247,10 @@
+
+
+
+ diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 9ee07d3..3bde8cf 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -408,6 +408,18 @@ const API = (() => { resetToken: () => del('/webcal/token'), }; + const sharing = { + create: (dogId, role) => post(`/dogs/${dogId}/share`, { role }), + list: (dogId) => get(`/dogs/${dogId}/shares`), + revoke: (dogId, id) => del(`/dogs/${dogId}/share/${id}`), + accept: (token) => post(`/share/accept/${token}`, {}), + info: (token) => get(`/share/info/${token}`), + }; + + const widget = { + snapshot: () => get('/widget/snapshot'), + }; + const importData = { notestation(dogId, file) { const fd = new FormData(); @@ -440,7 +452,7 @@ const API = (() => { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, - friends, chat, webcal, importData, + friends, chat, webcal, importData, sharing, widget, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 80cb9d7..b1bea35 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '116'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '117'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { @@ -56,6 +56,7 @@ const App = (() => { admin: { title: 'Admin', module: null, requiresAuth: true }, impressum: { title: 'Impressum', module: null }, datenschutz: { title: 'Datenschutz', module: null }, + widget: { title: 'Widget', module: null, requiresAuth: true }, }; // ---------------------------------------------------------- @@ -573,9 +574,16 @@ const App = (() => { _bindNavigation(); await _checkAuth(); + // Einladungslink /teilen/{token} → direkt annehmen + const inviteMatch = location.pathname.match(/^\/teilen\/([A-Za-z0-9_-]+)$/); + if (inviteMatch) { + const token = inviteMatch[1]; + navigate('diary', false); + _handleInvite(token); + return; + } + // Erste Seite laden: Hash aus URL oder Standard 'diary'. - // Bewusst NACH _checkAuth(), damit _loadPage() nur einmal aufgerufen wird - // (vorher war Hash-Navigation auch in _bindNavigation() → doppelter Aufruf). const rawHash = location.hash.replace('#', ''); const [hashPage, hashQuery] = rawHash.split('?'); const hashParams = {}; @@ -588,6 +596,38 @@ const App = (() => { navigate(startPage, false, hashParams); } + async function _handleInvite(token) { + try { + const info = await API.sharing.info(token); + if (info.accepted_at) { + UI.toast.success(`Du hast bereits Zugriff auf ${info.dog_name}.`); + history.replaceState(null, '', '/'); + return; + } + const ok = await UI.modal.confirm( + `${UI.escape(info.owner_name)} möchte das Profil von + ${UI.escape(info.dog_name)} mit dir teilen + (${info.role === 'editor' ? 'Lesen & Schreiben' : 'Nur lesen'}). + Möchtest du die Einladung annehmen?` + ); + if (!ok) { history.replaceState(null, '', '/'); return; } + await API.sharing.accept(token); + // Hundeliste neu laden + state.dogs = await API.dogs.list(); + const newDog = state.dogs.find(d => d.name === info.dog_name); + if (newDog) { + state.activeDog = newDog; + localStorage.setItem('by_active_dog', String(newDog.id)); + _renderDogSwitcher(); + } + history.replaceState(null, '', '/'); + UI.toast.success(`${UI.escape(info.dog_name)} wurde deiner Liste hinzugefügt!`); + } catch (e) { + UI.toast.error(e.message || 'Einladungslink ungültig.'); + history.replaceState(null, '', '/'); + } + } + // ---------------------------------------------------------- // AUTH-GATE HELPER — einheitlicher "Bitte anmelden"-Block // ---------------------------------------------------------- diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 42b8aa8..c6b7849 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -9,11 +9,12 @@ window.Page_diary = (() => { // ---------------------------------------------------------- // MODUL-STATE // ---------------------------------------------------------- - let _container = null; - let _appState = null; - let _entries = []; - let _offset = 0; - const LIMIT = 20; + let _container = null; + let _appState = null; + let _entries = []; + let _offset = 0; + let _searchQuery = ''; + const LIMIT = 20; const TYPEN = { eintrag: { label: 'Eintrag', icon: '' }, @@ -53,9 +54,9 @@ window.Page_diary = (() => { // ON DOG CHANGE — vom Header-Switcher ausgelöst // ---------------------------------------------------------- async function onDogChange(dog) { - _offset = 0; - _entries = []; - // Direkt Diary laden — Hund wurde bereits extern gewählt + _offset = 0; + _entries = []; + _searchQuery = ''; await _renderDiary(); } @@ -136,9 +137,13 @@ window.Page_diary = (() => { async function _renderDiary() { _container.innerHTML = `
-
@@ -152,6 +157,20 @@ window.Page_diary = (() => { _container.querySelector('#diary-btn-more') ?.addEventListener('click', () => _loadMore()); + // Suche mit Debounce + let _searchTimer = null; + _container.querySelector('#diary-search-input') + ?.addEventListener('input', e => { + clearTimeout(_searchTimer); + _searchTimer = setTimeout(async () => { + _offset = 0; + _entries = []; + _searchQuery = e.target.value.trim(); + await _load(); + _renderList(); + }, 350); + }); + await _load(); _renderList(); } @@ -163,7 +182,9 @@ window.Page_diary = (() => { const dog = _appState.activeDog; if (!dog) return; try { - const batch = await API.diary.list(dog.id, { limit: LIMIT, offset: _offset }); + const params = { limit: LIMIT, offset: _offset }; + if (_searchQuery) params.q = _searchQuery; + const batch = await API.diary.list(dog.id, params); _entries = _entries.concat(batch); // "Mehr laden" anzeigen wenn volle Page geladen wurde diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 6d72005..9909b0a 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -188,6 +188,16 @@ window.Page_dog_profile = (() => { +
+ + +
@@ -240,6 +250,14 @@ window.Page_dog_profile = (() => { _showChipEdit(dog); }); + document.getElementById('dp-ausweis-btn')?.addEventListener('click', () => { + window.open(`/ausweis/${dog.id}`, '_blank'); + }); + + document.getElementById('dp-share-btn')?.addEventListener('click', () => { + _showShareModal(dog); + }); + // Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig. } @@ -275,6 +293,102 @@ window.Page_dog_profile = (() => { }); } + // ---------------------------------------------------------- + // TEILEN + // ---------------------------------------------------------- + async function _showShareModal(dog) { + UI.modal.open({ + title: `${_esc(dog.name)} teilen`, + body: ` +

+ Erstelle einen Einladungslink, den du per WhatsApp, Signal oder E-Mail teilen kannst. + Die eingeladene Person sieht Tagebuch und Gesundheitsakte nach dem Annehmen. +

+
+ + +
+ +
`, + footer: ` + + `, + }); + + _loadShareList(dog.id); + + document.getElementById('share-create-btn').addEventListener('click', async () => { + const role = document.getElementById('share-role-select').value; + const btn = document.getElementById('share-create-btn'); + UI.setLoading(btn, true); + try { + const res = await API.sharing.create(dog.id, role); + const link = `${location.origin}${res.invite_path}`; + const inp = document.getElementById('share-link-input'); + inp.value = link; + document.getElementById('share-link-result').style.display = 'block'; + document.getElementById('share-link-copy').onclick = async () => { + await navigator.clipboard.writeText(link).catch(() => {}); + UI.toast.success('Link kopiert!'); + }; + UI.setLoading(btn, false); + _loadShareList(dog.id); + } catch (e) { + UI.setLoading(btn, false); + UI.toast.error(e.message || 'Fehler'); + } + }); + } + + async function _loadShareList(dogId) { + const wrap = document.getElementById('share-list-wrap'); + if (!wrap) return; + try { + const shares = await API.sharing.list(dogId); + if (!shares.length) { wrap.innerHTML = ''; return; } + wrap.innerHTML = ` +
+ Aktive Einladungen +
` + + shares.map(s => ` +
+ +
+ ${s.shared_with_name + ? `${_esc(s.shared_with_name)} · ${s.role}` + : `Ausstehend · ${s.role}`} +
+ +
`).join(''); + wrap.querySelectorAll('.share-revoke-btn').forEach(btn => { + btn.addEventListener('click', async () => { + await API.sharing.revoke(dogId, parseInt(btn.dataset.shareId)); + _loadShareList(dogId); + }); + }); + } catch (e) { /* ignore */ } + } + // ---------------------------------------------------------- // NEU ANLEGEN (direkt auf der Seite, kein Modal) // ---------------------------------------------------------- diff --git a/backend/static/js/pages/widget.js b/backend/static/js/pages/widget.js new file mode 100644 index 0000000..a311ff9 --- /dev/null +++ b/backend/static/js/pages/widget.js @@ -0,0 +1,145 @@ +/* BAN YARO — Widget-Vorschau (Home-Screen-Widget) */ + +window.Page_widget = (() => { + + let _container = null; + let _appState = null; + let _refreshTimer = null; + + async function init(container, appState) { + _container = container; + _appState = appState; + await _render(); + } + + async function refresh() { + await _render(); + } + + async function _render() { + _container.innerHTML = ` +
+
+
+
Lade…
+
+
`; + + if (!_appState.activeDog) { + _container.innerHTML = UI.emptyState({ + icon: UI.icon('dog'), + title: 'Kein Hund angelegt', + text: 'Erstelle zuerst ein Hundeprofil.', + action: ``, + }); + return; + } + + let data; + try { + data = await API.widget.snapshot(); + } catch (e) { + _container.innerHTML = '

Fehler beim Laden.

'; + return; + } + + const dog = data.dog; + const photo = data.random_photo; + const rem = data.reminder; + + const photoHtml = photo + ? `
+ ${_esc(photo.titel || '')} +
+ ${_esc(photo.titel || '')} + ${_fmtDate(photo.datum)} +
+
` + : `
+ +
Noch keine Fotos im Tagebuch
+
`; + + const reminderHtml = rem + ? `
+ +
+
${_esc(rem.bezeichnung)}
+
${_fmtDate(rem.naechstes)}
+
+
` + : data.overdue > 0 + ? `
+ +
${data.overdue} überfällige Erinnerung${data.overdue > 1 ? 'en' : ''}
+
` + : `
+ +
Keine offenen Erinnerungen
+
`; + + const dogAvatar = dog.foto_url + ? `${_esc(dog.name)}` + : `
🐕
`; + + _container.innerHTML = ` +
+ +
+
+ ${dogAvatar} +
+
${_esc(dog.name)}
+ ${dog.rasse ? `
${_esc(dog.rasse)}
` : ''} +
+ +
+ + ${reminderHtml} + ${photoHtml} +
+ +
+
+ + Als Home-Screen-Widget nutzen +
+

+ Füge diese Seite zum Home-Screen hinzu und öffne sie mit einem Tipp. +

+
+
+ iOS Safari: Teilen-Symbol → „Zum Home-Bildschirm" +
+
+ Android Chrome: Menü (⋮) → „Zum Startbildschirm hinzufügen" +
+
+
+
`; + + _container.querySelector('#widget-refresh-btn')?.addEventListener('click', () => _render()); + } + + function _esc(str) { + if (!str) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + function _fmtDate(d) { + if (!d) return ''; + try { + const [y, m, day] = d.split('-'); + return `${parseInt(day)}.${parseInt(m)}.${y}`; + } catch { return d; } + } + + return { init, refresh }; + +})(); diff --git a/backend/static/manifest.json b/backend/static/manifest.json index 6fa8414..019ea6d 100644 --- a/backend/static/manifest.json +++ b/backend/static/manifest.json @@ -27,14 +27,21 @@ }, "shortcuts": [ { - "name": "Tagebuch", - "url": "/#diary", - "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + "name": "Tagebuch", + "url": "/#diary", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] }, { - "name": "Giftköder melden", - "url": "/#poison", - "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + "name": "Widget", + "short_name": "Widget", + "description": "Nächste Erinnerung + zufälliges Tagebuchbild", + "url": "/#widget", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Giftköder melden", + "url": "/#poison", + "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] } ] } diff --git a/backend/static/sw.js b/backend/static/sw.js index aec02ba..725e55b 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v143'; +const CACHE_VERSION = 'by-v144'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/diary/20260417_150753_8657_rene.nsx b/diary/20260417_150753_8657_rene.nsx new file mode 100644 index 0000000..e2feada Binary files /dev/null and b/diary/20260417_150753_8657_rene.nsx differ