Sprint 13: WebCal-Abo / Kalender-Integration
- GET /api/webcal/token: erzeugt personl. Kalender-Token (einmalig)
- GET /api/webcal/{token}.ics: iCal-Feed mit Health-Erinnerungen,
eigenen Events, Gassi-Treffen (erstellt + beigetreten), angenommenen Sittings
- RRULE für wiederkehrende Health-Einträge (intervall_tage)
- Migration: users.calendar_token (TEXT UNIQUE)
- Settings: "Kalender abonnieren" öffnet webcal://-Link + Kopier-Button
- api.js: API.webcal.getToken() / resetToken()
- SW-Cache: by-v104, APP_VER: 80
This commit is contained in:
parent
b58789373c
commit
a4f74b6c64
8 changed files with 362 additions and 8 deletions
|
|
@ -442,6 +442,8 @@ def _migrate(conn_factory):
|
||||||
# Admin: User-Sperre
|
# Admin: User-Sperre
|
||||||
("users", "is_banned", "INTEGER NOT NULL DEFAULT 0"),
|
("users", "is_banned", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
("users", "ban_reason", "TEXT"),
|
("users", "ban_reason", "TEXT"),
|
||||||
|
# WebCal: Kalender-Abo-Token
|
||||||
|
("users", "calendar_token", "TEXT UNIQUE"),
|
||||||
]
|
]
|
||||||
with conn_factory() as conn:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ from routes.movies import router as movies_router
|
||||||
from routes.friends import router as friends_router
|
from routes.friends import router as friends_router
|
||||||
from routes.chat import router as chat_router
|
from routes.chat import router as chat_router
|
||||||
from routes.admin import router as admin_router
|
from routes.admin import router as admin_router
|
||||||
|
from routes.webcal import router as webcal_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -94,6 +95,7 @@ app.include_router(movies_router, prefix="/api/movies", tags=["Filme"])
|
||||||
app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"])
|
app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"])
|
||||||
app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
|
app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
|
||||||
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
|
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
|
||||||
|
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
290
backend/routes/webcal.py
Normal file
290
backend/routes/webcal.py
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
"""
|
||||||
|
BAN YARO — WebCal / iCal-Feed
|
||||||
|
Kalender-Abo für Gesundheits-Erinnerungen, Events, Gassi-Treffen und Sittings.
|
||||||
|
|
||||||
|
Endpunkte:
|
||||||
|
GET /api/webcal/token → liefert (oder erzeugt) den persönlichen Kalender-Token
|
||||||
|
GET /api/webcal/{token}.ics → iCal-Feed (kein Auth, Token ist das Geheimnis)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, date, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from auth import get_current_user
|
||||||
|
from database import db
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Hilfsfunktionen — RFC-5545-konformes iCal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _esc(text: str) -> str:
|
||||||
|
"""Escape-Sonderzeichen für iCal-Textwerte."""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
return (text
|
||||||
|
.replace('\\', '\\\\')
|
||||||
|
.replace(',', '\\,')
|
||||||
|
.replace(';', '\\;')
|
||||||
|
.replace('\n', '\\n')
|
||||||
|
.replace('\r', ''))
|
||||||
|
|
||||||
|
|
||||||
|
def _fold(line: str) -> str:
|
||||||
|
"""Zeilenumbruch nach max. 75 Bytes (RFC 5545 §3.1)."""
|
||||||
|
encoded = line.encode('utf-8')
|
||||||
|
if len(encoded) <= 75:
|
||||||
|
return line
|
||||||
|
parts = []
|
||||||
|
while True:
|
||||||
|
chunk = line.encode('utf-8')
|
||||||
|
if len(chunk) <= 75:
|
||||||
|
parts.append(line)
|
||||||
|
break
|
||||||
|
# Schneide bei max. 75 Bytes ab, ohne Multibyte-Zeichen zu zerreißen
|
||||||
|
i = 75
|
||||||
|
while len(line[:i].encode('utf-8')) > 75:
|
||||||
|
i -= 1
|
||||||
|
parts.append(line[:i])
|
||||||
|
line = ' ' + line[i:]
|
||||||
|
return '\r\n'.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _date_str(d: str) -> str:
|
||||||
|
"""YYYY-MM-DD → YYYYMMDD"""
|
||||||
|
return d.replace('-', '')
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_str(d: str, t: str | None = None) -> str:
|
||||||
|
"""Datum + optionale Uhrzeit (HH:MM) → iCal-Datetime (lokale Zeit, kein Z)."""
|
||||||
|
base = _date_str(d)
|
||||||
|
if t:
|
||||||
|
hhmm = t.replace(':', '')[:4]
|
||||||
|
return f"{base}T{hhmm}00"
|
||||||
|
return f"{base}T000000"
|
||||||
|
|
||||||
|
|
||||||
|
def _uid(prefix: str, entry_id: int) -> str:
|
||||||
|
return f"{prefix}-{entry_id}@banyaro.app"
|
||||||
|
|
||||||
|
|
||||||
|
def _vevent(**kwargs) -> str:
|
||||||
|
"""Baut einen VEVENT-Block. Bekannte kwargs: uid, dtstart, dtend, summary, description, location, rrule."""
|
||||||
|
lines = ['BEGIN:VEVENT']
|
||||||
|
now = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
lines.append(f'DTSTAMP:{now}')
|
||||||
|
lines.append(f'UID:{kwargs["uid"]}')
|
||||||
|
lines.append(f'DTSTART{kwargs["dtstart"]}')
|
||||||
|
lines.append(f'DTEND{kwargs["dtend"]}')
|
||||||
|
lines.append(_fold(f'SUMMARY:{_esc(kwargs.get("summary", ""))}'))
|
||||||
|
if kwargs.get('description'):
|
||||||
|
lines.append(_fold(f'DESCRIPTION:{_esc(kwargs["description"])}'))
|
||||||
|
if kwargs.get('location'):
|
||||||
|
lines.append(_fold(f'LOCATION:{_esc(kwargs["location"])}'))
|
||||||
|
if kwargs.get('rrule'):
|
||||||
|
lines.append(f'RRULE:{kwargs["rrule"]}')
|
||||||
|
lines.append('END:VEVENT')
|
||||||
|
return '\r\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Token erzeugen / abrufen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/token")
|
||||||
|
async def get_calendar_token(user=Depends(get_current_user)):
|
||||||
|
"""Liefert den persönlichen Kalender-Token; erzeugt ihn beim ersten Aufruf."""
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT calendar_token FROM users WHERE id=?", (user["id"],)
|
||||||
|
).fetchone()
|
||||||
|
token = row["calendar_token"] if row else None
|
||||||
|
if not token:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET calendar_token=? WHERE id=?",
|
||||||
|
(token, user["id"])
|
||||||
|
)
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/token")
|
||||||
|
async def reset_calendar_token(user=Depends(get_current_user)):
|
||||||
|
"""Setzt den Token zurück (invalidiert alle laufenden Abos)."""
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET calendar_token=? WHERE id=?",
|
||||||
|
(token, user["id"])
|
||||||
|
)
|
||||||
|
return {"token": token}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# iCal-Feed
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/{token}.ics")
|
||||||
|
async def ical_feed(token: str):
|
||||||
|
"""
|
||||||
|
Öffentlicher iCal-Feed — kein Session-Cookie nötig.
|
||||||
|
Der Token dient als Authentifizierung.
|
||||||
|
"""
|
||||||
|
with db() as conn:
|
||||||
|
user_row = conn.execute(
|
||||||
|
"SELECT id FROM users WHERE calendar_token=?", (token,)
|
||||||
|
).fetchone()
|
||||||
|
if not user_row:
|
||||||
|
raise HTTPException(404, "Kalender nicht gefunden.")
|
||||||
|
user_id = user_row["id"]
|
||||||
|
|
||||||
|
dogs = conn.execute(
|
||||||
|
"SELECT id, name FROM dogs WHERE user_id=?", (user_id,)
|
||||||
|
).fetchall()
|
||||||
|
dog_map = {d["id"]: d["name"] for d in dogs}
|
||||||
|
dog_ids = list(dog_map.keys())
|
||||||
|
|
||||||
|
vevents = []
|
||||||
|
|
||||||
|
# ── 1. Health-Erinnerungen ─────────────────────────────────
|
||||||
|
if dog_ids:
|
||||||
|
ph = ','.join('?' * len(dog_ids))
|
||||||
|
health_rows = conn.execute(
|
||||||
|
f"""SELECT h.id, h.typ, h.bezeichnung, h.naechstes,
|
||||||
|
h.notiz, h.intervall_tage, h.dog_id
|
||||||
|
FROM health h
|
||||||
|
WHERE h.dog_id IN ({ph})
|
||||||
|
AND h.naechstes IS NOT NULL
|
||||||
|
AND h.typ IN ('impfung','entwurmung','medikament','laeufigkeit')
|
||||||
|
ORDER BY h.naechstes""",
|
||||||
|
dog_ids
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
TYP_LABEL = {
|
||||||
|
'impfung': 'Impfung',
|
||||||
|
'entwurmung': 'Entwurmung',
|
||||||
|
'medikament': 'Medikament',
|
||||||
|
'laeufigkeit': 'Läufigkeit',
|
||||||
|
}
|
||||||
|
for e in health_rows:
|
||||||
|
dog_name = dog_map.get(e["dog_id"], "Hund")
|
||||||
|
typ_label = TYP_LABEL.get(e["typ"], e["typ"])
|
||||||
|
summary = f"{typ_label}: {e['bezeichnung']} ({dog_name})"
|
||||||
|
|
||||||
|
rrule = None
|
||||||
|
if e["intervall_tage"]:
|
||||||
|
rrule = f"FREQ=DAILY;INTERVAL={e['intervall_tage']}"
|
||||||
|
|
||||||
|
# next-day als DTEND (All-Day-Event)
|
||||||
|
dtend_date = (date.fromisoformat(e["naechstes"]) + timedelta(days=1)).isoformat()
|
||||||
|
vevents.append(_vevent(
|
||||||
|
uid = _uid('health', e["id"]),
|
||||||
|
dtstart = f";VALUE=DATE:{_date_str(e['naechstes'])}",
|
||||||
|
dtend = f";VALUE=DATE:{_date_str(dtend_date)}",
|
||||||
|
summary = summary,
|
||||||
|
description = e["notiz"] or '',
|
||||||
|
rrule = rrule,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 2. Events (zukünftige) ─────────────────────────────────
|
||||||
|
event_rows = conn.execute(
|
||||||
|
"""SELECT id, titel, datum, uhrzeit, ort_name, beschreibung
|
||||||
|
FROM events
|
||||||
|
WHERE user_id=? AND datum >= date('now')
|
||||||
|
ORDER BY datum, uhrzeit""",
|
||||||
|
(user_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for e in event_rows:
|
||||||
|
if e["uhrzeit"]:
|
||||||
|
dtstart = f":{_datetime_str(e['datum'], e['uhrzeit'])}"
|
||||||
|
dtend = f":{_datetime_str(e['datum'], e['uhrzeit'])}"
|
||||||
|
else:
|
||||||
|
next_day = (date.fromisoformat(e["datum"]) + timedelta(days=1)).isoformat()
|
||||||
|
dtstart = f";VALUE=DATE:{_date_str(e['datum'])}"
|
||||||
|
dtend = f";VALUE=DATE:{_date_str(next_day)}"
|
||||||
|
vevents.append(_vevent(
|
||||||
|
uid = _uid('event', e["id"]),
|
||||||
|
dtstart = dtstart,
|
||||||
|
dtend = dtend,
|
||||||
|
summary = e["titel"],
|
||||||
|
location = e["ort_name"] or '',
|
||||||
|
description = e["beschreibung"] or '',
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 3. Gassi-Treffen (erstellt oder beigetreten) ───────────
|
||||||
|
walk_rows = conn.execute(
|
||||||
|
"""SELECT DISTINCT w.id, w.titel, w.datum, w.uhrzeit, w.ort_name
|
||||||
|
FROM walks w
|
||||||
|
LEFT JOIN walk_participants wp
|
||||||
|
ON wp.walk_id = w.id AND wp.user_id = ?
|
||||||
|
WHERE (w.user_id = ? OR wp.user_id = ?)
|
||||||
|
AND w.datum >= date('now')
|
||||||
|
ORDER BY w.datum, w.uhrzeit""",
|
||||||
|
(user_id, user_id, user_id)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for w in walk_rows:
|
||||||
|
if w["uhrzeit"]:
|
||||||
|
dtstart = f":{_datetime_str(w['datum'], w['uhrzeit'])}"
|
||||||
|
dtend = f":{_datetime_str(w['datum'], w['uhrzeit'])}"
|
||||||
|
else:
|
||||||
|
next_day = (date.fromisoformat(w["datum"]) + timedelta(days=1)).isoformat()
|
||||||
|
dtstart = f";VALUE=DATE:{_date_str(w['datum'])}"
|
||||||
|
dtend = f";VALUE=DATE:{_date_str(next_day)}"
|
||||||
|
vevents.append(_vevent(
|
||||||
|
uid = _uid('walk', w["id"]),
|
||||||
|
dtstart = dtstart,
|
||||||
|
dtend = dtend,
|
||||||
|
summary = f"Gassi-Treffen: {w['titel']}",
|
||||||
|
location = w["ort_name"] or '',
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 4. Angenommene Sittings ────────────────────────────────
|
||||||
|
sitting_rows = conn.execute(
|
||||||
|
"""SELECT sr.id, sr.von, sr.bis, u.name AS sitter_name
|
||||||
|
FROM sitting_requests sr
|
||||||
|
JOIN sitters s ON s.id = sr.sitter_id
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE sr.user_id = ?
|
||||||
|
AND sr.status = 'angenommen'
|
||||||
|
AND sr.bis >= date('now')
|
||||||
|
ORDER BY sr.von""",
|
||||||
|
(user_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for s in sitting_rows:
|
||||||
|
dtend_date = (date.fromisoformat(s["bis"]) + timedelta(days=1)).isoformat()
|
||||||
|
vevents.append(_vevent(
|
||||||
|
uid = _uid('sitting', s["id"]),
|
||||||
|
dtstart = f";VALUE=DATE:{_date_str(s['von'])}",
|
||||||
|
dtend = f";VALUE=DATE:{_date_str(dtend_date)}",
|
||||||
|
summary = f"Hundesitting bei {s['sitter_name'] or 'Sitter'}",
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── Kalender zusammenbauen ─────────────────────────────────────
|
||||||
|
body = '\r\n'.join(vevents)
|
||||||
|
cal = (
|
||||||
|
"BEGIN:VCALENDAR\r\n"
|
||||||
|
"VERSION:2.0\r\n"
|
||||||
|
"PRODID:-//Ban Yaro//DE\r\n"
|
||||||
|
"CALSCALE:GREGORIAN\r\n"
|
||||||
|
"METHOD:PUBLISH\r\n"
|
||||||
|
"X-WR-CALNAME:Ban Yaro\r\n"
|
||||||
|
"X-WR-CALDESC:Impfungen\\, Events & Treffen aus Ban Yaro\r\n"
|
||||||
|
"X-WR-TIMEZONE:Europe/Berlin\r\n"
|
||||||
|
+ (body + "\r\n" if body else "")
|
||||||
|
+ "END:VCALENDAR\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content = cal.encode('utf-8'),
|
||||||
|
media_type = "text/calendar; charset=utf-8",
|
||||||
|
headers = {"Content-Disposition": 'inline; filename="banyaro.ics"'},
|
||||||
|
)
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=79">
|
<link rel="stylesheet" href="/css/layout.css?v=80">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=79">
|
<link rel="stylesheet" href="/css/components.css?v=80">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -269,9 +269,9 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=79"></script>
|
<script src="/js/api.js?v=80"></script>
|
||||||
<script src="/js/ui.js?v=79"></script>
|
<script src="/js/ui.js?v=80"></script>
|
||||||
<script src="/js/app.js?v=79"></script>
|
<script src="/js/app.js?v=80"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,14 @@ const API = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// WEBCAL
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const webcal = {
|
||||||
|
getToken: () => get('/webcal/token'),
|
||||||
|
resetToken: () => del('/webcal/token'),
|
||||||
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// ERROR-KLASSE
|
// ERROR-KLASSE
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -417,7 +425,7 @@ const API = (() => {
|
||||||
get, post, put, patch, del, upload,
|
get, post, put, patch, del, upload,
|
||||||
auth, dogs, diary, health, tieraerzte, poison,
|
auth, dogs, diary, health, tieraerzte, poison,
|
||||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||||
friends, chat,
|
friends, chat, webcal,
|
||||||
subscribeToPush, getLocation,
|
subscribeToPush, getLocation,
|
||||||
APIError,
|
APIError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '79'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '80'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,12 @@ window.Page_settings = (() => {
|
||||||
<span>Push-Benachrichtigungen</span>
|
<span>Push-Benachrichtigungen</span>
|
||||||
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-item" id="settings-calendar-btn"
|
||||||
|
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg>
|
||||||
|
<span>Kalender abonnieren</span>
|
||||||
|
<span style="margin-left:auto;color:var(--c-text-secondary)">›</span>
|
||||||
|
</div>
|
||||||
<div class="sidebar-item" id="settings-logout-btn"
|
<div class="sidebar-item" id="settings-logout-btn"
|
||||||
style="padding:var(--space-4);border-radius:0;cursor:pointer;
|
style="padding:var(--space-4);border-radius:0;cursor:pointer;
|
||||||
color:var(--c-danger)">
|
color:var(--c-danger)">
|
||||||
|
|
@ -171,6 +177,52 @@ window.Page_settings = (() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-calendar-btn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const { token } = await API.webcal.getToken();
|
||||||
|
const url = `webcal://${location.host}/api/webcal/${token}.ics`;
|
||||||
|
const httpsUrl = `https://${location.host}/api/webcal/${token}.ics`;
|
||||||
|
UI.modal.open({
|
||||||
|
title: `${UI.icon('calendar-dots')} Kalender abonnieren`,
|
||||||
|
body: `
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||||
|
Abonniere deinen persönlichen Ban-Yaro-Kalender. Er enthält Impf-Erinnerungen,
|
||||||
|
Läufigkeits-Termine, Events und Gassi-Treffen — immer aktuell.
|
||||||
|
</p>
|
||||||
|
<div style="background:var(--c-bg);border-radius:var(--radius-md);
|
||||||
|
padding:var(--space-3) var(--space-4);
|
||||||
|
font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
|
word-break:break-all;margin-bottom:var(--space-4)">
|
||||||
|
${UI.escHtml(httpsUrl)}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||||
|
<a href="${UI.escHtml(url)}"
|
||||||
|
class="btn btn-primary"
|
||||||
|
style="text-align:center">
|
||||||
|
${UI.icon('calendar-dots')} In Kalender-App öffnen
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-secondary" id="cal-copy-btn">
|
||||||
|
${UI.icon('clipboard-text')} URL kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-4)">
|
||||||
|
Tipp: iOS → Einstellungen › Kalender › Accounts › Account hinzufügen › Andere › Kalenderabo
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
document.getElementById('cal-copy-btn')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(httpsUrl);
|
||||||
|
UI.toast.success('URL kopiert.');
|
||||||
|
} catch {
|
||||||
|
UI.toast.warning('Kopieren nicht möglich — URL oben manuell kopieren.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
UI.toast.error('Kalender-Token konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('toggle-pocket-mode')?.addEventListener('change', e => {
|
document.getElementById('toggle-pocket-mode')?.addEventListener('change', e => {
|
||||||
localStorage.setItem('by_pocket_mode', String(e.target.checked));
|
localStorage.setItem('by_pocket_mode', String(e.target.checked));
|
||||||
UI.toast.info(e.target.checked
|
UI.toast.info(e.target.checked
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v103';
|
const CACHE_VERSION = 'by-v104';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue