Feature: Hunde-Buch — druckbare HTML-Tagebuchansicht als PDF (SW by-v700)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
20a4936397
commit
c5030024b0
4 changed files with 761 additions and 2 deletions
|
|
@ -436,6 +436,332 @@ async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_curren
|
|||
}
|
||||
|
||||
|
||||
@router.get("/{dog_id}/buch")
|
||||
async def get_hunde_buch(
|
||||
dog_id: int,
|
||||
jahr: int = None,
|
||||
limit: int = 50,
|
||||
nur_fotos: bool = False,
|
||||
nur_meilensteine: bool = False,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Hunde-Buch: druckbare HTML-Ansicht der schoensten Tagebucheintraege."""
|
||||
import json as _json
|
||||
from datetime import date as _date
|
||||
from fastapi.responses import HTMLResponse
|
||||
from html import escape as _esc
|
||||
|
||||
with db() as conn:
|
||||
dog = conn.execute(
|
||||
"SELECT id, name, rasse, geburtstag, foto_url FROM dogs WHERE id=? AND user_id=?",
|
||||
(dog_id, user["id"])
|
||||
).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
|
||||
dog = dict(dog)
|
||||
|
||||
# --- Eintraege laden ---
|
||||
conditions = ["(d.dog_id=? OR dd.dog_id=?)"]
|
||||
params: list = [dog_id, dog_id]
|
||||
|
||||
if jahr:
|
||||
conditions.append("strftime('%Y', d.datum) = ?")
|
||||
params.append(str(jahr))
|
||||
|
||||
if nur_meilensteine:
|
||||
conditions.append("d.is_milestone = 1")
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT DISTINCT d.id, d.datum, d.titel, d.text, d.tags,
|
||||
d.gps_lat, d.gps_lon, d.location_name, d.weather_json,
|
||||
d.is_milestone,
|
||||
(SELECT dm.url FROM diary_media dm
|
||||
WHERE dm.diary_id=d.id AND dm.media_type='image'
|
||||
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
|
||||
FROM diary d
|
||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||
WHERE {where}
|
||||
AND d.datum IS NOT NULL
|
||||
ORDER BY d.datum ASC""",
|
||||
params
|
||||
).fetchall()
|
||||
|
||||
rows = [dict(r) for r in rows]
|
||||
|
||||
# Filtern: Eintraege mit Foto bevorzugen / nur Fotos-Modus
|
||||
if nur_fotos:
|
||||
rows = [r for r in rows if r.get("cover_url")]
|
||||
else:
|
||||
# Prioritaet: Meilensteine + Foto-Eintraege; Rest auffuellen bis limit
|
||||
with_photo = [r for r in rows if r.get("cover_url")]
|
||||
milestones = [r for r in rows if r.get("is_milestone") and not r.get("cover_url")]
|
||||
rest = [r for r in rows if not r.get("cover_url") and not r.get("is_milestone")]
|
||||
rows = with_photo + milestones + rest
|
||||
rows.sort(key=lambda r: r["datum"] or "")
|
||||
|
||||
rows = rows[:limit]
|
||||
|
||||
# --- Hund-Alter berechnen ---
|
||||
alter_str = ""
|
||||
if dog.get("geburtstag"):
|
||||
try:
|
||||
geb = _date.fromisoformat(dog["geburtstag"])
|
||||
heute = _date.today()
|
||||
jahre = (heute - geb).days // 365
|
||||
alter_str = f"{jahre} Jahre"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- HTML bauen ---
|
||||
dog_name = _esc(dog["name"] or "Mein Hund")
|
||||
rasse_str = _esc(dog.get("rasse") or "")
|
||||
jahr_str = str(jahr) if jahr else "Alle Jahre"
|
||||
foto_url = dog.get("foto_url") or ""
|
||||
|
||||
cover_img = (
|
||||
f'<img src="{_esc(foto_url)}" alt="{dog_name}" '
|
||||
f'style="border-radius:50%;width:200px;height:200px;object-fit:cover;'
|
||||
f'border:4px solid #b97c2a;margin-bottom:24px;" onerror="this.style.display=\'none\'">'
|
||||
if foto_url else
|
||||
f'<div style="width:200px;height:200px;border-radius:50%;background:#f0e8d8;'
|
||||
f'display:flex;align-items:center;justify-content:center;font-size:5rem;'
|
||||
f'margin:0 auto 24px">🐾</div>'
|
||||
)
|
||||
|
||||
subtitle_parts = [p for p in [rasse_str, alter_str] if p]
|
||||
subtitle = " · ".join(subtitle_parts)
|
||||
|
||||
_MONATE = ["Januar","Februar","März","April","Mai","Juni",
|
||||
"Juli","August","September","Oktober","November","Dezember"]
|
||||
|
||||
def _fmt_datum(iso: str) -> str:
|
||||
try:
|
||||
d = _date.fromisoformat(iso)
|
||||
return f"{d.day}. {_MONATE[d.month - 1]} {d.year}"
|
||||
except Exception:
|
||||
return iso or ""
|
||||
|
||||
def _wetter_chip(wj_str: str) -> str:
|
||||
if not wj_str:
|
||||
return ""
|
||||
try:
|
||||
wj = _json.loads(wj_str)
|
||||
temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp")
|
||||
if temp is None:
|
||||
return ""
|
||||
temp_i = int(float(temp))
|
||||
emoji = "☀️" if temp_i > 20 else ("🌧️" if temp_i < 10 else "⛅")
|
||||
return f'<span class="chip">{emoji} {temp_i}°C</span>'
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
entries_html = ""
|
||||
for e in rows:
|
||||
milestone_class = "milestone" if e.get("is_milestone") else ""
|
||||
datum_fmt = _fmt_datum(e.get("datum") or "")
|
||||
titel = _esc(e.get("titel") or "")
|
||||
text_raw = e.get("text") or ""
|
||||
text = _esc(text_raw).replace("\n", "<br>")
|
||||
wetter = _wetter_chip(e.get("weather_json") or "")
|
||||
loc = _esc(e.get("location_name") or "")
|
||||
cover = e.get("cover_url") or ""
|
||||
|
||||
foto_html = ""
|
||||
if cover:
|
||||
foto_html = (
|
||||
f'<div class="entry-photo">'
|
||||
f'<img src="{_esc(cover)}" alt="" loading="lazy">'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
loc_html = f'<span class="chip">📍 {loc}</span>' if loc else ""
|
||||
chips_html = f'<div class="chips">{wetter}{loc_html}</div>' if (wetter or loc_html) else ""
|
||||
titel_html = f'<div class="entry-title">{titel}</div>' if titel else ""
|
||||
text_html = f'<div class="entry-text">{text}</div>' if text_raw else ""
|
||||
|
||||
entries_html += f"""
|
||||
<div class="entry {milestone_class}">
|
||||
{foto_html}
|
||||
<div class="entry-date">{datum_fmt}</div>
|
||||
{titel_html}
|
||||
{text_html}
|
||||
{chips_html}
|
||||
</div>
|
||||
"""
|
||||
|
||||
anzahl = len(rows)
|
||||
html_page = f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hunde-Buch — {dog_name}</title>
|
||||
<style>
|
||||
*, *::before, *::after {{ box-sizing: border-box; }}
|
||||
|
||||
body {{
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px 60px;
|
||||
color: #2c2c2c;
|
||||
background: #fff;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
|
||||
/* Druck-Button */
|
||||
.print-btn {{
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #b97c2a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
padding: 12px 24px;
|
||||
font-size: 1rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(185,124,42,0.35);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}}
|
||||
.print-btn:hover {{ background: #a06820; }}
|
||||
|
||||
/* Titelseite */
|
||||
.cover {{
|
||||
text-align: center;
|
||||
padding: 80px 40px 100px;
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}}
|
||||
.cover h1 {{
|
||||
font-size: 3em;
|
||||
color: #b97c2a;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.2;
|
||||
}}
|
||||
.cover .subtitle {{
|
||||
font-size: 1.15em;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}}
|
||||
.cover .year-label {{
|
||||
font-size: 1em;
|
||||
color: #aaa;
|
||||
margin-bottom: 32px;
|
||||
}}
|
||||
.cover .stat-line {{
|
||||
font-size: 0.95em;
|
||||
color: #888;
|
||||
font-family: system-ui, sans-serif;
|
||||
margin-top: 24px;
|
||||
}}
|
||||
|
||||
/* Eintraege */
|
||||
.entry {{
|
||||
padding: 32px 0;
|
||||
border-bottom: 1px solid #e8e0d0;
|
||||
break-inside: avoid;
|
||||
}}
|
||||
.entry:last-child {{ border-bottom: none; }}
|
||||
|
||||
.entry-photo {{ margin-bottom: 20px; }}
|
||||
.entry-photo img {{
|
||||
width: 100%;
|
||||
max-height: 420px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
display: block;
|
||||
}}
|
||||
|
||||
.entry-date {{
|
||||
color: #999;
|
||||
font-size: 0.85em;
|
||||
font-family: system-ui, sans-serif;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}}
|
||||
.entry-title {{
|
||||
font-size: 1.45em;
|
||||
font-weight: bold;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.3;
|
||||
}}
|
||||
.entry-text {{
|
||||
font-size: 1em;
|
||||
line-height: 1.75;
|
||||
color: #333;
|
||||
}}
|
||||
|
||||
/* Meilenstein */
|
||||
.milestone {{
|
||||
border-left: 4px solid #b97c2a;
|
||||
padding-left: 24px;
|
||||
}}
|
||||
.milestone .entry-title::before {{
|
||||
content: "\\2605 ";
|
||||
color: #b97c2a;
|
||||
}}
|
||||
|
||||
/* Chips */
|
||||
.chips {{
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}}
|
||||
.chip {{
|
||||
background: #f5f0e8;
|
||||
border-radius: 50px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.82em;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #666;
|
||||
}}
|
||||
|
||||
/* Drucken */
|
||||
@media print {{
|
||||
.print-btn {{ display: none !important; }}
|
||||
.cover {{ page-break-after: always; }}
|
||||
.entry {{ break-inside: avoid; }}
|
||||
body {{ padding: 0; }}
|
||||
@page {{ margin: 2cm; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<button class="print-btn" onclick="window.print()">
|
||||
🖨 Drucken / Als PDF speichern
|
||||
</button>
|
||||
|
||||
<div class="cover">
|
||||
{cover_img}
|
||||
<h1>{dog_name}</h1>
|
||||
{'<div class="subtitle">' + subtitle + '</div>' if subtitle else ''}
|
||||
<div class="year-label">{jahr_str}</div>
|
||||
<div class="stat-line">{anzahl} Einträge</div>
|
||||
</div>
|
||||
|
||||
{entries_html}
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
return HTMLResponse(content=html_page)
|
||||
|
||||
|
||||
@router.get("/{dog_id}")
|
||||
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
|
|
@ -764,3 +1090,159 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)):
|
|||
"kategorien": list(dict.fromkeys(t["kategorie"] for t in result)),
|
||||
"fell_pflege_art": fell_pflege_art_filter, # 'schneiden' | 'trimmen' | None
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# LEBENS-TIMELINE
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/{dog_id}/timeline")
|
||||
async def get_dog_timeline(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Aggregierte Lebens-Timeline eines Hundes aus allen Datenquellen."""
|
||||
import json as _json
|
||||
|
||||
with db() as conn:
|
||||
dog = conn.execute(
|
||||
"SELECT id, name, user_id, geburtstag FROM dogs WHERE id=? AND user_id=?",
|
||||
(dog_id, user["id"])
|
||||
).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
|
||||
events = []
|
||||
|
||||
with db() as conn:
|
||||
# --- Tagebuch ---
|
||||
diary_rows = conn.execute(
|
||||
"""SELECT d.id, d.datum, d.titel, d.typ, d.is_milestone,
|
||||
dm.url AS foto_url
|
||||
FROM diary d
|
||||
LEFT JOIN diary_media dm ON dm.diary_id = d.id AND dm.sort_order = 0
|
||||
WHERE d.dog_id=?
|
||||
ORDER BY d.datum ASC, d.id ASC""",
|
||||
(dog_id,)
|
||||
).fetchall()
|
||||
|
||||
for i, r in enumerate(diary_rows):
|
||||
events.append({
|
||||
"datum": r["datum"],
|
||||
"kategorie": "tagebuch",
|
||||
"titel": r["titel"] or ("Tagebucheintrag" if r["typ"] == "eintrag" else str(r["typ"]).capitalize()),
|
||||
"typ": r["typ"],
|
||||
"is_first": i == 0,
|
||||
"is_milestone": bool(r["is_milestone"]),
|
||||
"foto_url": r["foto_url"],
|
||||
"ref_id": r["id"],
|
||||
})
|
||||
|
||||
# --- Gesundheit ---
|
||||
health_rows = conn.execute(
|
||||
"""SELECT id, datum, bezeichnung, typ
|
||||
FROM health
|
||||
WHERE dog_id=?
|
||||
ORDER BY datum ASC, id ASC""",
|
||||
(dog_id,)
|
||||
).fetchall()
|
||||
|
||||
typ_seen = {}
|
||||
for r in health_rows:
|
||||
t = r["typ"]
|
||||
is_first = t not in typ_seen
|
||||
if is_first:
|
||||
typ_seen[t] = True
|
||||
events.append({
|
||||
"datum": r["datum"],
|
||||
"kategorie": "gesundheit",
|
||||
"titel": r["bezeichnung"],
|
||||
"typ": t,
|
||||
"is_first": is_first,
|
||||
"is_milestone": False,
|
||||
"foto_url": None,
|
||||
"ref_id": r["id"],
|
||||
})
|
||||
|
||||
# --- Training-Sessions ---
|
||||
ts_rows = conn.execute(
|
||||
"""SELECT id, datum, exercise_name, erfolgsquote, ist_top
|
||||
FROM training_sessions
|
||||
WHERE dog_id=? AND user_id=?
|
||||
ORDER BY datum ASC, id ASC""",
|
||||
(dog_id, user["id"])
|
||||
).fetchall()
|
||||
|
||||
ts_first = True
|
||||
ts_best = None
|
||||
ts_best_score = -1
|
||||
for r in ts_rows:
|
||||
if r["erfolgsquote"] is not None and r["erfolgsquote"] > ts_best_score:
|
||||
ts_best_score = r["erfolgsquote"]
|
||||
ts_best = r
|
||||
|
||||
for i, r in enumerate(ts_rows):
|
||||
is_first = (i == 0)
|
||||
is_best = ts_best and r["id"] == ts_best["id"] and i > 0
|
||||
events.append({
|
||||
"datum": r["datum"],
|
||||
"kategorie": "training",
|
||||
"titel": r["exercise_name"],
|
||||
"typ": "training",
|
||||
"is_first": is_first,
|
||||
"is_milestone": bool(r["ist_top"]) or is_best,
|
||||
"foto_url": None,
|
||||
"ref_id": r["id"],
|
||||
})
|
||||
|
||||
# --- Routen ---
|
||||
route_rows = conn.execute(
|
||||
"""SELECT id, name, distanz_km,
|
||||
date(created_at) AS datum
|
||||
FROM routes
|
||||
WHERE user_id=?
|
||||
ORDER BY created_at ASC""",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
|
||||
route_first = True
|
||||
route_longest = None
|
||||
route_max_km = -1
|
||||
for r in route_rows:
|
||||
km = r["distanz_km"] or 0
|
||||
if km > route_max_km:
|
||||
route_max_km = km
|
||||
route_longest = r
|
||||
|
||||
for i, r in enumerate(route_rows):
|
||||
is_first = (i == 0)
|
||||
is_longest = route_longest and r["id"] == route_longest["id"] and i > 0
|
||||
events.append({
|
||||
"datum": r["datum"],
|
||||
"kategorie": "route",
|
||||
"titel": r["name"],
|
||||
"typ": "route",
|
||||
"is_first": is_first,
|
||||
"is_milestone": is_longest,
|
||||
"foto_url": None,
|
||||
"ref_id": r["id"],
|
||||
"distanz_km": r["distanz_km"],
|
||||
})
|
||||
|
||||
# Geburtstag des Hundes als erster Eintrag
|
||||
if dog["geburtstag"]:
|
||||
events.append({
|
||||
"datum": dog["geburtstag"],
|
||||
"kategorie": "meilenstein",
|
||||
"titel": f"{dog['name']} wird geboren",
|
||||
"typ": "geburtstag",
|
||||
"is_first": True,
|
||||
"is_milestone": True,
|
||||
"foto_url": None,
|
||||
"ref_id": None,
|
||||
})
|
||||
|
||||
# Chronologisch sortieren
|
||||
events.sort(key=lambda e: (e["datum"] or "0000-00-00", e["kategorie"]))
|
||||
|
||||
return {
|
||||
"dog_name": dog["name"],
|
||||
"geburtstag": dog["geburtstag"],
|
||||
"events": events,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '699'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '700'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
|
||||
|
|
@ -78,6 +78,7 @@ const App = (() => {
|
|||
wetter: { title: 'Wetter', module: null },
|
||||
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
|
||||
personality: { title: 'Persönlichkeitstest', module: null },
|
||||
reise: { title: 'Reise mit Hund', module: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -207,6 +207,15 @@ window.Page_dog_profile = (() => {
|
|||
border-color:transparent;font-weight:700">
|
||||
✨ Jahresrückblick ${new Date().getFullYear()}
|
||||
</button>` : ''}
|
||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-buch-btn"
|
||||
style="background:linear-gradient(135deg,#5c3a10,#7a4f1a);color:#f5e4c0;
|
||||
border-color:transparent;font-weight:700">
|
||||
📖 Hunde-Buch erstellen
|
||||
</button>` : ''}
|
||||
${!dog.is_guest ? `<button class="btn btn-secondary w-full" id="dp-timeline-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#timeline"></use></svg>
|
||||
Lebens-Timeline 🐾
|
||||
</button>` : ''}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -281,6 +290,14 @@ window.Page_dog_profile = (() => {
|
|||
_showWrappedModal(dog);
|
||||
});
|
||||
|
||||
document.getElementById('dp-buch-btn')?.addEventListener('click', () => {
|
||||
_showBuchModal(dog);
|
||||
});
|
||||
|
||||
document.getElementById('dp-timeline-btn')?.addEventListener('click', () => {
|
||||
_showTimelineModal(dog);
|
||||
});
|
||||
|
||||
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
|
||||
}
|
||||
|
||||
|
|
@ -2110,6 +2127,265 @@ window.Page_dog_profile = (() => {
|
|||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// HUNDE-BUCH
|
||||
// ----------------------------------------------------------
|
||||
function _showBuchModal(dog) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
let selectedJahr = String(currentYear);
|
||||
let nurFotos = false;
|
||||
let nurMeilensteine = false;
|
||||
|
||||
const modalEl = document.createElement('div');
|
||||
modalEl.style.cssText = `
|
||||
position:fixed;inset:0;z-index:9999;
|
||||
background:rgba(0,0,0,0.55);
|
||||
display:flex;align-items:center;justify-content:center;padding:16px;
|
||||
`;
|
||||
|
||||
const renderModal = () => {
|
||||
const years = [String(currentYear - 1), String(currentYear), 'alle'];
|
||||
const yearBtns = years.map(y => {
|
||||
const active = selectedJahr === y
|
||||
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
|
||||
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
|
||||
const label = y === 'alle' ? 'Alle' : y;
|
||||
return `<button onclick="window._buchSetJahr('${y}')" style="
|
||||
border:1px solid;border-radius:8px;padding:8px 16px;
|
||||
font-size:0.9rem;cursor:pointer;font-family:inherit;
|
||||
${active}
|
||||
">${label}</button>`;
|
||||
}).join('');
|
||||
|
||||
const togStyle = (active) =>
|
||||
active
|
||||
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
|
||||
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
|
||||
|
||||
modalEl.innerHTML = `
|
||||
<div style="
|
||||
background:#fff;border-radius:16px;padding:32px 24px;
|
||||
max-width:420px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.2);
|
||||
font-family:system-ui,sans-serif;
|
||||
">
|
||||
<div style="font-size:1.4rem;font-weight:700;margin-bottom:4px">📖 Hunde-Buch erstellen</div>
|
||||
<div style="font-size:0.9rem;color:#888;margin-bottom:24px">
|
||||
Eine druckbare Ansicht der schönsten Einträge.<br>Im Browser als PDF speichern.
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:20px">
|
||||
<div style="font-weight:600;margin-bottom:10px;color:#555;font-size:0.85rem;text-transform:uppercase;letter-spacing:.05em">Jahrgang</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">${yearBtns}</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:20px;display:flex;flex-direction:column;gap:10px">
|
||||
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
|
||||
<button onclick="window._buchToggleFotos()" style="
|
||||
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
|
||||
${togStyle(nurFotos)}
|
||||
">${nurFotos ? '✓' : ''}</button>
|
||||
<span style="font-size:0.95rem">Nur Einträge mit Fotos</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
|
||||
<button onclick="window._buchToggleMeilensteine()" style="
|
||||
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
|
||||
${togStyle(nurMeilensteine)}
|
||||
">${nurMeilensteine ? '✓' : ''}</button>
|
||||
<span style="font-size:0.95rem">Nur Meilensteine</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px">
|
||||
<button onclick="window._buchOpen()" style="
|
||||
flex:1;background:#7a4f1a;color:#f5e4c0;border:none;border-radius:10px;
|
||||
padding:14px;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit;
|
||||
">📖 Buch öffnen</button>
|
||||
<button onclick="window._buchClose()" style="
|
||||
background:#f0f0f0;color:#555;border:none;border-radius:10px;
|
||||
padding:14px 18px;font-size:1rem;cursor:pointer;font-family:inherit;
|
||||
">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
window._buchSetJahr = (y) => { selectedJahr = y; renderModal(); };
|
||||
window._buchToggleFotos = () => { nurFotos = !nurFotos; renderModal(); };
|
||||
window._buchToggleMeilensteine = () => { nurMeilensteine = !nurMeilensteine; renderModal(); };
|
||||
window._buchClose = () => {
|
||||
modalEl.remove();
|
||||
delete window._buchSetJahr;
|
||||
delete window._buchToggleFotos;
|
||||
delete window._buchToggleMeilensteine;
|
||||
delete window._buchOpen;
|
||||
delete window._buchClose;
|
||||
};
|
||||
window._buchOpen = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedJahr !== 'alle') params.set('jahr', selectedJahr);
|
||||
if (nurFotos) params.set('nur_fotos', 'true');
|
||||
if (nurMeilensteine) params.set('nur_meilensteine', 'true');
|
||||
const url = `/api/dogs/${dog.id}/buch?${params.toString()}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
renderModal();
|
||||
document.body.appendChild(modalEl);
|
||||
modalEl.addEventListener('click', e => { if (e.target === modalEl) window._buchClose(); });
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === 'Escape') { window._buchClose(); document.removeEventListener('keydown', onKey); }
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// LEBENS-TIMELINE
|
||||
// ----------------------------------------------------------
|
||||
async function _showTimelineModal(dog) {
|
||||
UI.modal.open({
|
||||
title: `Lebens-Timeline — ${_esc(dog.name)}`,
|
||||
body: `<div id="dp-timeline-body" style="min-height:200px;text-align:center;padding:var(--space-6)">
|
||||
<svg class="ph-icon" style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#spinner-gap"></use>
|
||||
</svg>
|
||||
</div>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
size: 'large',
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await API.get(`/dogs/${dog.id}/timeline`);
|
||||
} catch (e) {
|
||||
const b = document.getElementById('dp-timeline-body');
|
||||
if (b) b.innerHTML = `<p style="color:var(--c-danger)">Fehler: ${_esc(e.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = document.getElementById('dp-timeline-body');
|
||||
if (!wrap) return;
|
||||
|
||||
const events = data.events || [];
|
||||
if (!events.length) {
|
||||
wrap.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-6)">
|
||||
Noch keine Einträge vorhanden. Beginne dein Tagebuch oder trage Gesundheitsdaten ein.
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const _KAT = {
|
||||
meilenstein: { color: '#8b5cf6', icon: 'star', label: 'Meilenstein' },
|
||||
tagebuch: { color: 'var(--c-primary)', icon: 'book-open', label: 'Tagebuch' },
|
||||
gesundheit: { color: '#ef4444', icon: 'heartbeat', label: 'Gesundheit' },
|
||||
training: { color: '#22c55e', icon: 'target', label: 'Training' },
|
||||
route: { color: '#3b82f6', icon: 'path', label: 'Route' },
|
||||
};
|
||||
|
||||
const _fmtDate = d => {
|
||||
if (!d) return '';
|
||||
try {
|
||||
const p = d.substring(0, 10).split('-');
|
||||
return `${p[2]}.${p[1]}.${p[0]}`;
|
||||
} catch { return d; }
|
||||
};
|
||||
|
||||
let lastYear = null;
|
||||
let html = '<div class="tl-wrap">';
|
||||
|
||||
for (const ev of events) {
|
||||
const year = ev.datum ? ev.datum.substring(0, 4) : null;
|
||||
if (year && year !== lastYear) {
|
||||
html += `<div class="tl-year">${_esc(year)}</div>`;
|
||||
lastYear = year;
|
||||
}
|
||||
|
||||
const kat = _KAT[ev.kategorie] || _KAT.tagebuch;
|
||||
const big = ev.is_milestone;
|
||||
|
||||
let label = _esc(ev.titel);
|
||||
if (ev.is_first && ev.kategorie === 'tagebuch') label = `🎉 Erster Tagebucheintrag — ${label}`;
|
||||
if (ev.is_first && ev.kategorie === 'route') label = `🎉 Erste Route — ${label}`;
|
||||
if (ev.is_first && ev.kategorie === 'training') label = `🎉 Erstes Training — ${label}`;
|
||||
if (ev.typ === 'geburtstag') label = `🎂 ${label}`;
|
||||
|
||||
const dotSize = big ? '18px' : '12px';
|
||||
const dotBorder = big ? `3px solid ${kat.color}` : `2px solid ${kat.color}`;
|
||||
const dotML = big ? '6px' : '9px';
|
||||
|
||||
html += `
|
||||
<div class="tl-item${big ? ' tl-item--big' : ''}">
|
||||
<div class="tl-dot" style="width:${dotSize};height:${dotSize};
|
||||
background:${kat.color};border:${dotBorder};
|
||||
margin-left:${dotML};
|
||||
box-shadow:${big ? `0 0 0 4px ${kat.color}22` : 'none'}"></div>
|
||||
<div class="tl-card">
|
||||
${big && ev.foto_url ? `
|
||||
<div class="tl-foto" style="background-image:url(${_esc(ev.foto_url)})"></div>` : ''}
|
||||
<div class="tl-meta">
|
||||
<span class="tl-badge" style="background:${kat.color}22;color:${kat.color}">
|
||||
<svg class="ph-icon" style="width:11px;height:11px" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${kat.icon}"></use>
|
||||
</svg>
|
||||
${_esc(kat.label)}
|
||||
</span>
|
||||
<span class="tl-date">${_fmtDate(ev.datum)}</span>
|
||||
</div>
|
||||
<div class="tl-title${big ? ' tl-title--big' : ''}">${label}</div>
|
||||
${ev.distanz_km ? `<div class="tl-sub">${ev.distanz_km} km</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += `
|
||||
<style>
|
||||
.tl-wrap { padding:var(--space-2) 0;position:relative; }
|
||||
.tl-wrap::before {
|
||||
content:'';position:absolute;left:15px;top:0;bottom:0;width:2px;
|
||||
background:var(--c-border);border-radius:1px;
|
||||
}
|
||||
.tl-year {
|
||||
padding:var(--space-2) 0 var(--space-2) 48px;
|
||||
font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-secondary);text-transform:uppercase;letter-spacing:.08em;
|
||||
}
|
||||
.tl-item {
|
||||
display:flex;align-items:flex-start;gap:var(--space-3);
|
||||
margin-bottom:var(--space-3);position:relative;
|
||||
}
|
||||
.tl-dot {
|
||||
flex-shrink:0;border-radius:50%;margin-top:4px;z-index:1;position:relative;
|
||||
}
|
||||
.tl-card {
|
||||
flex:1;min-width:0;background:var(--c-surface-2);
|
||||
border-radius:var(--radius-md);padding:var(--space-3);overflow:hidden;
|
||||
}
|
||||
.tl-item--big .tl-card { border-left:3px solid var(--c-primary); }
|
||||
.tl-foto {
|
||||
width:100%;height:120px;background-size:cover;background-position:center;
|
||||
border-radius:var(--radius-sm);margin-bottom:var(--space-2);
|
||||
}
|
||||
.tl-meta {
|
||||
display:flex;align-items:center;gap:var(--space-2);
|
||||
margin-bottom:var(--space-1);flex-wrap:wrap;
|
||||
}
|
||||
.tl-badge {
|
||||
display:inline-flex;align-items:center;gap:3px;
|
||||
padding:2px 8px;border-radius:var(--radius-full);
|
||||
font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||
}
|
||||
.tl-date { font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:auto; }
|
||||
.tl-title { font-size:var(--text-sm);color:var(--c-text);font-weight:var(--weight-medium); }
|
||||
.tl-title--big { font-weight:var(--weight-semibold);font-size:var(--text-base); }
|
||||
.tl-sub { font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px; }
|
||||
</style>`;
|
||||
|
||||
wrap.innerHTML = html;
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v699';
|
||||
const CACHE_VERSION = 'by-v700';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue