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' ' 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"])}
+
+
+
+
+
+
+
+
🖨 Drucken / Als PDF speichern
+
+
+
Impfungen
+
+ Impfung Datum Nächste Fälligkeit Charge Tierarzt
+ {health_rows_html(impfungen, ["bezeichnung","datum","naechstes","charge_nr","tierarzt_name"])}
+
+
+
+
+
Aktive Medikamente
+
+ Medikament Seit Dosierung Häufigkeit
+ {health_rows_html(medis, ["bezeichnung","datum","dosierung","haeufigkeit"])}
+
+
+
+
+
Allergien & Unverträglichkeiten
+
+ Allergen Schweregrad Reaktion Seit
+ {health_rows_html(allergien, ["bezeichnung","schweregrad","reaktion","datum"])}
+
+
+
+
+
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 = (() => {
Profil bearbeiten
+
+
+
+ Ausweis
+
+
+
+ Teilen
+
+
+ Weiteren Hund anlegen
@@ -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.
+
+
+ Berechtigung
+
+ Mitschreiben (Tagebuch & Gesundheit bearbeiten)
+ Nur lesen
+
+
+
+
Einladungslink
+
+
+
+
+
+
+
+ Dieser Link kann einmalig angenommen werden.
+
+
+
`,
+ footer: `
+ Schließen
+ Link erstellen `,
+ });
+
+ _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 = `
+ `;
+
+ if (!_appState.activeDog) {
+ _container.innerHTML = UI.emptyState({
+ icon: UI.icon('dog'),
+ title: 'Kein Hund angelegt',
+ text: 'Erstelle zuerst ein Hundeprofil.',
+ action: `Profil erstellen `,
+ });
+ 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
+ ? ``
+ : ``;
+
+ const reminderHtml = rem
+ ? ``
+ : data.overdue > 0
+ ? ``
+ : ``;
+
+ const dogAvatar = dog.foto_url
+ ? ` `
+ : `🐕
`;
+
+ _container.innerHTML = `
+
+
+
+
+
+
+
+ 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