Feature+Fix: Referral-Admin, Pro-Gates, Karten-Layer, onDogChange, Staging-Media (SW by-v855)
Features: - Admin: Referral-Tab (Virality Factor, Top-Werber, letzte Einladungen) - Karte: Regenradar (RainViewer, zoom→7, color=4), Temperatur-Layer (OWM) mit Zahlen-Grid + Legende - Wetter-Chip: Umschwung-Warnung bei ≥40%-Sprung in Niederschlagswahrscheinlichkeit - Freundschaftsanfragen: Accept/Decline direkt in Notifications (kein Pro nötig) - Freunde-Seite für Standard-User freigeschaltet Pro-Gates: - KI-Trainer, Routenvorschläge, Regenradar, Temperatur-Layer jetzt Pro-Feature - Pro-Badge (P) auf Chips für Admins/Mods in allen Welten + Welten-einrichten - Oranger Banner auf Pro-Seiten für Admin/Mod/Manager Bugfixes: - onDogChange: uebungen.js (Cache leeren + _render), trainingsplaene.js (war leer) - robots.txt vereinfacht (nur Disallow, kein Allow-Durcheinander) - Hintergrund-Foto: Querformat-Filter korrigiert (kein Fallback auf Hochformat) - Staging Media: FileResponse mit korrektem MIME-Type, no-cache statt immutable - Staging Docker: MEDIA_DIR=/data/media + /prod-media:ro Fallback-Handler - Staging-Fix: Bild-Upload auf zweitem Hund (war Read-only file system)
This commit is contained in:
parent
2f021f54c2
commit
79fa5684b9
22 changed files with 570 additions and 58 deletions
|
|
@ -179,7 +179,10 @@ class MediaCacheMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if request.url.path.startswith('/media/'):
|
if request.url.path.startswith('/media/'):
|
||||||
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
if os.getenv('STAGING') == 'true':
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
else:
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
app.add_middleware(MediaCacheMiddleware)
|
app.add_middleware(MediaCacheMiddleware)
|
||||||
|
|
@ -341,9 +344,39 @@ app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
|
||||||
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
|
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
|
||||||
|
|
||||||
APP_VER = "834" # muss mit APP_VER in app.js übereinstimmen
|
STAGING = os.getenv("STAGING", "false").lower() == "true"
|
||||||
|
PROD_MEDIA_DIR = "/prod-media"
|
||||||
|
|
||||||
|
_MIME_MAP = {
|
||||||
|
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
||||||
|
".webp": "image/webp", ".gif": "image/gif", ".mp4": "video/mp4",
|
||||||
|
".webm": "video/webm", ".pdf": "application/pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
if STAGING and os.path.isdir(PROD_MEDIA_DIR):
|
||||||
|
# Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only)
|
||||||
|
from fastapi.responses import FileResponse as _FileResponse
|
||||||
|
|
||||||
|
def _media_response(filepath: str):
|
||||||
|
ext = os.path.splitext(filepath)[1].lower()
|
||||||
|
mt = _MIME_MAP.get(ext, "application/octet-stream")
|
||||||
|
return _FileResponse(filepath, media_type=mt)
|
||||||
|
|
||||||
|
@app.api_route("/media/{path:path}", methods=["GET", "HEAD"])
|
||||||
|
async def serve_media_staging(path: str):
|
||||||
|
staging_file = os.path.join(MEDIA_DIR, path)
|
||||||
|
if os.path.isfile(staging_file):
|
||||||
|
return _media_response(staging_file)
|
||||||
|
prod_file = os.path.join(PROD_MEDIA_DIR, path)
|
||||||
|
if os.path.isfile(prod_file):
|
||||||
|
return _media_response(prod_file)
|
||||||
|
from fastapi import HTTPException as _HE
|
||||||
|
raise _HE(404, "Media not found")
|
||||||
|
else:
|
||||||
|
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||||
|
|
||||||
|
APP_VER = "855" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
@app.get("/.well-known/assetlinks.json")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
|
||||||
|
|
@ -583,6 +583,48 @@ async def scheduler_trigger(job_id: str, user=Depends(require_admin)):
|
||||||
return {"ok": True, "job_id": job_id}
|
return {"ok": True, "job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/admin/referrals — User-wirbt-User Top 100
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/referrals")
|
||||||
|
async def referral_stats(user=Depends(require_mod)):
|
||||||
|
with db() as conn:
|
||||||
|
# Top-Werber mit Anzahl
|
||||||
|
top = conn.execute("""
|
||||||
|
SELECT r.id, r.name, r.email,
|
||||||
|
COUNT(u.id) AS invited_count,
|
||||||
|
r.created_at AS member_since
|
||||||
|
FROM users u
|
||||||
|
JOIN users r ON r.id = u.referred_by
|
||||||
|
GROUP BY r.id
|
||||||
|
ORDER BY invited_count DESC
|
||||||
|
LIMIT 100
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
# Alle Einladungen (für Detail-Ansicht)
|
||||||
|
invites = conn.execute("""
|
||||||
|
SELECT u.id, u.name, u.email, u.created_at,
|
||||||
|
r.id AS referrer_id, r.name AS referrer_name
|
||||||
|
FROM users u
|
||||||
|
JOIN users r ON r.id = u.referred_by
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
total_users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
total_referred = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE referred_by IS NOT NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"top_referrers": [dict(r) for r in top],
|
||||||
|
"recent_invites": [dict(r) for r in invites],
|
||||||
|
"total_users": total_users,
|
||||||
|
"total_referred": total_referred,
|
||||||
|
"viral_factor": round(total_referred / max(total_users - total_referred, 1), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time)
|
# GET /api/admin/ki/history — 30-Tage-Verlauf + Top-User (all-time)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -188,8 +188,7 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
if not dog:
|
if not dog:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
|
# Hintergrundfoto: Querformat-Bilder bevorzugt, tagesweise rotierend
|
||||||
# Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
|
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"""SELECT dm.url FROM diary_media dm
|
"""SELECT dm.url FROM diary_media dm
|
||||||
JOIN diary d ON d.id = dm.diary_id
|
JOIN diary d ON d.id = dm.diary_id
|
||||||
|
|
@ -198,12 +197,13 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
# Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill)
|
# Fallback: Bilder ohne Dimensionsdaten (vor dem Backfill hochgeladen)
|
||||||
if not photos:
|
if not photos:
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"""SELECT dm.url FROM diary_media dm
|
"""SELECT dm.url FROM diary_media dm
|
||||||
JOIN diary d ON d.id = dm.diary_id
|
JOIN diary d ON d.id = dm.diary_id
|
||||||
WHERE d.dog_id=? AND dm.media_type='image'
|
WHERE d.dog_id=? AND dm.media_type='image'
|
||||||
|
AND dm.img_width IS NULL
|
||||||
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
ORDER BY d.datum DESC, d.id DESC, dm.id ASC""",
|
||||||
(dog_id,)
|
(dog_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,20 @@ async def send_request(target_id: int, user=Depends(get_current_user)):
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending")
|
||||||
|
async def pending_requests(user=Depends(get_current_user)):
|
||||||
|
"""Eingehende Freundschaftsanfragen — kein Pro nötig, für Notification-Accept."""
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT f.id, u.name AS requester_name, u.avatar_url
|
||||||
|
FROM friendships f
|
||||||
|
JOIN users u ON u.id = f.requester_id
|
||||||
|
WHERE f.addressee_id=? AND f.status='pending'
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
""", (user["id"],)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{friendship_id}/accept")
|
@router.post("/{friendship_id}/accept")
|
||||||
async def accept_request(friendship_id: int, user=Depends(get_current_user)):
|
async def accept_request(friendship_id: int, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ Schreibe klar und strukturiert, ohne unnötigen Fachjargon."""
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
system=system,
|
system=system,
|
||||||
max_tokens=600,
|
max_tokens=600,
|
||||||
requires_premium=False,
|
requires_premium=True,
|
||||||
user_id=user["id"],
|
user_id=user["id"],
|
||||||
)
|
)
|
||||||
return {"antwort": result}
|
return {"antwort": result}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,10 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)):
|
||||||
if not ORS_API_KEY:
|
if not ORS_API_KEY:
|
||||||
raise HTTPException(503, "ORS nicht konfiguriert")
|
raise HTTPException(503, "ORS nicht konfiguriert")
|
||||||
|
|
||||||
|
from auth import has_pro_access
|
||||||
|
if not has_pro_access(user):
|
||||||
|
raise HTTPException(403, "Routenvorschläge sind ein Pro-Feature.")
|
||||||
|
|
||||||
is_privileged = (
|
is_privileged = (
|
||||||
user.get("rolle") in ("admin", "moderator") or
|
user.get("rolle") in ("admin", "moderator") or
|
||||||
user.get("is_moderator") or
|
user.get("is_moderator") or
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ BAN YARO — Wetter-API
|
||||||
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
GET /api/weather?lat=&lon= → aktuelles Wetter + Zecken-Warnung für Nutzerstandort
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Query, HTTPException, Depends
|
from fastapi import APIRouter, Query, HTTPException, Depends
|
||||||
import weather as weather_module
|
import weather as weather_module
|
||||||
|
|
@ -11,6 +12,34 @@ from database import db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
OWM_API_KEY = os.getenv("OPENWEATHERMAP_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_OWM_LAYERS = {"temp_new", "clouds_new", "wind_new", "pressure_new", "precipitation_new"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/radar-tiles')
|
||||||
|
async def radar_tile_config(user=Depends(get_current_user)):
|
||||||
|
"""Regenradar-Tile-Config (RainViewer)."""
|
||||||
|
return {"provider": "rainviewer"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/layer-tiles')
|
||||||
|
async def layer_tile_config(
|
||||||
|
layer: str = "temp_new",
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""OWM-Tile-URL für Wetter-Layer (Key bleibt server-seitig)."""
|
||||||
|
if layer not in ALLOWED_OWM_LAYERS:
|
||||||
|
raise HTTPException(400, f"Unbekannter Layer. Erlaubt: {', '.join(ALLOWED_OWM_LAYERS)}")
|
||||||
|
if not OWM_API_KEY:
|
||||||
|
raise HTTPException(503, "OWM nicht konfiguriert.")
|
||||||
|
return {
|
||||||
|
"url": f"https://tile.openweathermap.org/map/{layer}/{{z}}/{{x}}/{{y}}.png?appid={OWM_API_KEY}",
|
||||||
|
"maxNativeZoom": 18,
|
||||||
|
"opacity": 0.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get('')
|
@router.get('')
|
||||||
async def get_weather(
|
async def get_weather(
|
||||||
|
|
|
||||||
|
|
@ -3184,6 +3184,16 @@ html.modal-open {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
#map-radar-btn.active {
|
||||||
|
background: #1d4ed8;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
#map-temp-btn.active {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.map-fab:disabled { opacity: 0.5; cursor: default; }
|
.map-fab:disabled { opacity: 0.5; cursor: default; }
|
||||||
.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; }
|
.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; }
|
||||||
@keyframes fab-spin { to { transform: rotate(360deg); } }
|
@keyframes fab-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- 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?v=834">
|
<link rel="stylesheet" href="/css/design-system.css?v=855">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=834">
|
<link rel="stylesheet" href="/css/layout.css?v=855">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=834">
|
<link rel="stylesheet" href="/css/components.css?v=855">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -583,10 +583,10 @@
|
||||||
<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=834"></script>
|
<script src="/js/api.js?v=855"></script>
|
||||||
<script src="/js/ui.js?v=834"></script>
|
<script src="/js/ui.js?v=855"></script>
|
||||||
<script src="/js/app.js?v=834"></script>
|
<script src="/js/app.js?v=855"></script>
|
||||||
<script src="/js/worlds.js?v=834"></script>
|
<script src="/js/worlds.js?v=855"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,7 @@ const API = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
const friends = {
|
const friends = {
|
||||||
list() { return get('/friends/'); },
|
list() { return get('/friends/'); },
|
||||||
|
pending() { return get('/friends/pending'); },
|
||||||
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
|
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
|
||||||
activity() { return get('/friends/activity'); },
|
activity() { return get('/friends/activity'); },
|
||||||
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
|
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '834'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '855'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||||
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
|
if (location.search.includes('_t=')) history.replaceState(null, '', '/');
|
||||||
|
|
@ -57,7 +57,7 @@ const App = (() => {
|
||||||
'erste-hilfe': { title: 'Erste Hilfe', module: null },
|
'erste-hilfe': { title: 'Erste Hilfe', module: null },
|
||||||
settings: { title: 'Einstellungen', module: null },
|
settings: { title: 'Einstellungen', module: null },
|
||||||
lost: { title: 'Verlorener Hund', module: null },
|
lost: { title: 'Verlorener Hund', module: null },
|
||||||
friends: { title: 'Freunde', module: null, requiresAuth: true, requiresPro: true },
|
friends: { title: 'Freunde', module: null, requiresAuth: true },
|
||||||
chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true },
|
chat: { title: 'Nachrichten', module: null, requiresAuth: true, requiresPro: true },
|
||||||
social: { title: 'Social Media', module: null, requiresAuth: true },
|
social: { title: 'Social Media', module: null, requiresAuth: true },
|
||||||
admin: { title: 'Admin', module: null, requiresAuth: true },
|
admin: { title: 'Admin', module: null, requiresAuth: true },
|
||||||
|
|
@ -198,6 +198,26 @@ const App = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pro-Feature-Hinweis für Admins/Mods/Manager — Banner VOR .page-body, überlebt page.module.init()
|
||||||
|
const pageEl = document.getElementById(`page-${pageId}`);
|
||||||
|
if (pageEl) {
|
||||||
|
pageEl.querySelector('#pro-role-banner')?.remove();
|
||||||
|
if (page.requiresPro && state.user) {
|
||||||
|
const t = state.user.subscription_tier || 'standard';
|
||||||
|
const isRoleBased = !t.endsWith('_test') && !['pro','breeder'].includes(t) &&
|
||||||
|
(state.user.rolle === 'admin' || state.user.rolle === 'moderator' ||
|
||||||
|
state.user.is_moderator || state.user.is_social_media);
|
||||||
|
if (isRoleBased) {
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'pro-role-banner';
|
||||||
|
banner.style.cssText = 'background:#92400e;color:#fef3c7;padding:8px 16px;font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;';
|
||||||
|
banner.innerHTML = `<svg style="width:14px;height:14px;flex-shrink:0" viewBox="0 0 256 256" fill="currentColor"><path d="M236.8,188.09,149.35,36.22a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM120,104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm8,88a12,12,0,1,1,12-12A12,12,0,0,1,128,192Z"/></svg>
|
||||||
|
⭐ Pro-Feature — Standard-User sehen diese Seite nicht`;
|
||||||
|
pageEl.insertBefore(banner, pageEl.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (page.module) {
|
if (page.module) {
|
||||||
const hasParams = params && Object.keys(params).length > 0;
|
const hasParams = params && Object.keys(params).length > 0;
|
||||||
if (hasParams) {
|
if (hasParams) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ window.Page_admin = (() => {
|
||||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||||
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
|
{ id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' },
|
||||||
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
{ id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' },
|
||||||
|
{ id: 'referrals', label: 'Referrals', icon: 'share-network' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -161,6 +162,7 @@ window.Page_admin = (() => {
|
||||||
case 'bewerbungen': await _renderBewerbungen(el); break;
|
case 'bewerbungen': await _renderBewerbungen(el); break;
|
||||||
case 'hilfe': await _renderHilfe(el); break;
|
case 'hilfe': await _renderHilfe(el); break;
|
||||||
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
case 'uebungen_admin': await _renderUebungenAdmin(el); break;
|
||||||
|
case 'referrals': await _renderReferrals(el); break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||||
|
|
@ -3344,6 +3346,79 @@ window.Page_admin = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// TAB: REFERRALS
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
async function _renderReferrals(el) {
|
||||||
|
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade…</div>`;
|
||||||
|
let d;
|
||||||
|
try { d = await API.get('/admin/referrals'); } catch { el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger)">Fehler beim Laden.</div>`; return; }
|
||||||
|
|
||||||
|
const pct = d.total_users > 0 ? Math.round(d.total_referred / d.total_users * 100) : 0;
|
||||||
|
|
||||||
|
const topRows = d.top_referrers.map((r, i) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 10px;color:var(--c-text-muted);font-weight:600">${i + 1}</td>
|
||||||
|
<td style="padding:8px 10px;font-weight:600">${_esc(r.name)}</td>
|
||||||
|
<td style="padding:8px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.email)}</td>
|
||||||
|
<td style="padding:8px 10px;text-align:right">
|
||||||
|
<span style="font-size:var(--text-lg);font-weight:800;color:var(--c-primary)">${r.invited_count}</span>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const recentRows = d.recent_invites.slice(0, 50).map(r => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:6px 10px;font-weight:500">${_esc(r.name)}</td>
|
||||||
|
<td style="padding:6px 10px;color:var(--c-text-secondary);font-size:var(--text-xs)">${_esc(r.referrer_name)}</td>
|
||||||
|
<td style="padding:6px 10px;color:var(--c-text-muted);font-size:var(--text-xs)">${(r.created_at || '').slice(0, 10)}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-3);margin-bottom:var(--space-4)">
|
||||||
|
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||||
|
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-primary)">${d.total_referred}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Geworbene User</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||||
|
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-success)">${pct}%</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Anteil geworbener User</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="padding:var(--space-4);text-align:center">
|
||||||
|
<div style="font-size:var(--text-2xl);font-weight:800;color:var(--c-warning)">${d.viral_factor}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">Virality Factor</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:var(--space-4);overflow:hidden">
|
||||||
|
<div class="by-card-section-header">Top Werber</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table style="width:100%;border-collapse:collapse">
|
||||||
|
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
<th style="padding:8px 10px;text-align:left">#</th>
|
||||||
|
<th style="padding:8px 10px;text-align:left">Name</th>
|
||||||
|
<th style="padding:8px 10px;text-align:left">E-Mail</th>
|
||||||
|
<th style="padding:8px 10px;text-align:right">Eingeladen</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${topRows || '<tr><td colspan="4" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Empfehlungen</td></tr>'}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="overflow:hidden">
|
||||||
|
<div class="by-card-section-header">Zuletzt geworbene User</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table style="width:100%;border-collapse:collapse">
|
||||||
|
<thead><tr style="border-bottom:2px solid var(--c-border);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
<th style="padding:6px 10px;text-align:left">User</th>
|
||||||
|
<th style="padding:6px 10px;text-align:left">Geworben von</th>
|
||||||
|
<th style="padding:6px 10px;text-align:left">Datum</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${recentRows || '<tr><td colspan="3" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Noch keine Daten</td></tr>'}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, onDogChange };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,8 @@ window.Page_map = (() => {
|
||||||
|
|
||||||
<div class="map-fabs">
|
<div class="map-fabs">
|
||||||
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg></button>
|
||||||
|
<button class="map-fab" id="map-radar-btn" title="Regenradar ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#cloud-rain"></use></svg></button>
|
||||||
|
<button class="map-fab" id="map-temp-btn" title="Temperatur ein-/ausblenden"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#thermometer"></use></svg></button>
|
||||||
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
<button class="map-fab" id="map-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -285,6 +287,186 @@ window.Page_map = (() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode);
|
||||||
|
document.getElementById('map-radar-btn').addEventListener('click', _toggleRadar);
|
||||||
|
document.getElementById('map-temp-btn').addEventListener('click', _toggleTemp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// REGENRADAR (RainViewer) + OWM-LAYER (Temperatur etc.)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
let _radarLayer = null;
|
||||||
|
let _radarActive = false;
|
||||||
|
let _radarTimer = null;
|
||||||
|
let _tempLayer = null;
|
||||||
|
let _tempActive = false;
|
||||||
|
let _tempMarkers = [];
|
||||||
|
let _tempDebounce = null;
|
||||||
|
|
||||||
|
async function _toggleRadar() {
|
||||||
|
if (!App.hasPro(_appState?.user)) {
|
||||||
|
UI.toast.info('Regenradar ist ein Pro-Feature.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('map-radar-btn');
|
||||||
|
if (_radarActive) {
|
||||||
|
_radarActive = false;
|
||||||
|
if (_radarLayer) { _map.removeLayer(_radarLayer); _radarLayer = null; }
|
||||||
|
clearInterval(_radarTimer);
|
||||||
|
btn?.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_radarActive = true;
|
||||||
|
btn?.classList.add('active');
|
||||||
|
if (_map && _map.getZoom() > 7) _map.setZoom(7);
|
||||||
|
await _loadRadar();
|
||||||
|
_radarTimer = setInterval(_loadRadar, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _toggleTemp() {
|
||||||
|
if (!App.hasPro(_appState?.user)) {
|
||||||
|
UI.toast.info('Temperatur-Layer ist ein Pro-Feature.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('map-temp-btn');
|
||||||
|
if (_tempActive) {
|
||||||
|
_tempActive = false;
|
||||||
|
if (_tempLayer) { _map.removeLayer(_tempLayer); _tempLayer = null; }
|
||||||
|
_tempMarkers.forEach(m => _map.removeLayer(m));
|
||||||
|
_tempMarkers = [];
|
||||||
|
clearTimeout(_tempDebounce);
|
||||||
|
_map.off('moveend zoomend', _debounceTempLabels);
|
||||||
|
document.getElementById('map-temp-legend')?.remove();
|
||||||
|
btn?.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_tempActive = true;
|
||||||
|
btn?.classList.add('active');
|
||||||
|
try {
|
||||||
|
const cfg = await API.get('/weather/layer-tiles?layer=temp_new');
|
||||||
|
_tempLayer = window.L.tileLayer(cfg.url, {
|
||||||
|
opacity: 1.0,
|
||||||
|
tileSize: 256,
|
||||||
|
zIndex: 290,
|
||||||
|
maxNativeZoom: cfg.maxNativeZoom ?? 18,
|
||||||
|
maxZoom: 18,
|
||||||
|
attribution: 'Temp © <a href="https://openweathermap.org">OpenWeatherMap</a>',
|
||||||
|
}).addTo(_map);
|
||||||
|
_showTempLegend();
|
||||||
|
_map.on('moveend zoomend', _debounceTempLabels);
|
||||||
|
await _loadTempLabels();
|
||||||
|
} catch {
|
||||||
|
_tempActive = false;
|
||||||
|
btn?.classList.remove('active');
|
||||||
|
UI.toast.error('Temperatur-Layer nicht verfügbar.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _debounceTempLabels() {
|
||||||
|
clearTimeout(_tempDebounce);
|
||||||
|
_tempDebounce = setTimeout(_loadTempLabels, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tempColor(t) {
|
||||||
|
if (t <= -10) return '#0033cc';
|
||||||
|
if (t <= 0) return '#0099ff';
|
||||||
|
if (t <= 10) return '#00cc88';
|
||||||
|
if (t <= 15) return '#88cc00';
|
||||||
|
if (t <= 20) return '#ffcc00';
|
||||||
|
if (t <= 25) return '#ff8800';
|
||||||
|
if (t <= 30) return '#ff3300';
|
||||||
|
return '#990000';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadTempLabels() {
|
||||||
|
if (!_tempActive || !_map) return;
|
||||||
|
const bounds = _map.getBounds();
|
||||||
|
const n = bounds.getNorth(), s = bounds.getSouth();
|
||||||
|
const e = bounds.getEast(), w = bounds.getWest();
|
||||||
|
|
||||||
|
// 3×3 Raster
|
||||||
|
const rows = 3, cols = 3;
|
||||||
|
const points = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const lat = s + (n - s) * (r + 0.5) / rows;
|
||||||
|
const lon = w + (e - w) * (c + 0.5) / cols;
|
||||||
|
points.push([lat, lon]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(points.map(([lat, lon]) =>
|
||||||
|
fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(2)}&longitude=${lon.toFixed(2)}¤t=temperature_2m&timezone=auto`, { cache: 'no-store' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => ({ lat, lon, t: d.current?.temperature_2m }))
|
||||||
|
.catch(() => null)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Alte Marker entfernen
|
||||||
|
_tempMarkers.forEach(m => _map.removeLayer(m));
|
||||||
|
_tempMarkers = [];
|
||||||
|
|
||||||
|
results.filter(Boolean).forEach(({ lat, lon, t }) => {
|
||||||
|
if (t == null) return;
|
||||||
|
const temp = Math.round(t);
|
||||||
|
const color = _tempColor(temp);
|
||||||
|
const icon = window.L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="background:${color};color:#fff;font-size:12px;font-weight:800;
|
||||||
|
padding:2px 6px;border-radius:10px;white-space:nowrap;
|
||||||
|
box-shadow:0 1px 4px rgba(0,0,0,0.5);text-shadow:0 1px 2px rgba(0,0,0,0.4)">${temp}°</div>`,
|
||||||
|
iconSize: null,
|
||||||
|
iconAnchor: [20, 10],
|
||||||
|
});
|
||||||
|
const m = window.L.marker([lat, lon], { icon, zIndexOffset: 500, interactive: false });
|
||||||
|
m.addTo(_map);
|
||||||
|
_tempMarkers.push(m);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showTempLegend() {
|
||||||
|
const existing = document.getElementById('map-temp-legend');
|
||||||
|
if (existing) return;
|
||||||
|
const steps = [
|
||||||
|
{ c: '#0000cc', v: '−20°' }, { c: '#0055ff', v: '−10°' },
|
||||||
|
{ c: '#00aaff', v: '0°' }, { c: '#00ffaa', v: '10°' },
|
||||||
|
{ c: '#aaff00', v: '15°' }, { c: '#ffee00', v: '20°' },
|
||||||
|
{ c: '#ff8800', v: '25°' }, { c: '#ff2200', v: '30°' },
|
||||||
|
{ c: '#990000', v: '35°' },
|
||||||
|
];
|
||||||
|
const gradient = steps.map(s => s.c).join(',');
|
||||||
|
const labels = steps.map(s =>
|
||||||
|
`<span style="flex:1;text-align:center;font-size:9px;color:#fff;text-shadow:0 0 3px #000">${s.v}</span>`
|
||||||
|
).join('');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = 'map-temp-legend';
|
||||||
|
el.style.cssText = `position:absolute;bottom:36px;left:50%;transform:translateX(-50%);
|
||||||
|
z-index:800;background:rgba(0,0,0,0.55);border-radius:6px;padding:4px 8px;
|
||||||
|
min-width:220px;pointer-events:none`;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="height:10px;border-radius:3px;background:linear-gradient(to right,${gradient});margin-bottom:2px"></div>
|
||||||
|
<div style="display:flex">${labels}</div>`;
|
||||||
|
document.getElementById('central-map')?.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loadRadar() {
|
||||||
|
if (!_radarActive || !_map) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' });
|
||||||
|
const data = await resp.json();
|
||||||
|
const frames = [...(data.radar?.past || []), ...(data.radar?.nowcast || [])];
|
||||||
|
if (!frames.length) return;
|
||||||
|
const latest = frames[frames.length - 1].path;
|
||||||
|
const url = `https://tilecache.rainviewer.com${latest}/256/{z}/{x}/{y}/4/1_1.png`;
|
||||||
|
if (_radarLayer) _map.removeLayer(_radarLayer);
|
||||||
|
_radarLayer = window.L.tileLayer(url, {
|
||||||
|
opacity: 0.7,
|
||||||
|
tileSize: 256,
|
||||||
|
zIndex: 300,
|
||||||
|
maxNativeZoom: 7,
|
||||||
|
maxZoom: 18,
|
||||||
|
attribution: 'Radar © <a href="https://rainviewer.com">RainViewer</a>',
|
||||||
|
}).addTo(_map);
|
||||||
|
} catch { /* still */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -1634,12 +1816,15 @@ window.Page_map = (() => {
|
||||||
const regen = w.precip_prob != null
|
const regen = w.precip_prob != null
|
||||||
? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`)
|
? (w.next_rain_time ? ` · 💧 ${w.precip_prob}% ab ${w.next_rain_time}` : ` · 💧 ${w.precip_prob}%`)
|
||||||
: '';
|
: '';
|
||||||
|
const warning = w.rain_warning_time
|
||||||
|
? ` · <span style="color:#f59e0b;font-weight:700">⚠ ab ${w.rain_warning_time}</span>`
|
||||||
|
: '';
|
||||||
let zecken = '';
|
let zecken = '';
|
||||||
if (w.zecken_warnung) {
|
if (w.zecken_warnung) {
|
||||||
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
|
const col = w.zecken_warnung === 'hoch' ? '#991B1B' : '#92400E';
|
||||||
zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`;
|
zecken = ` · <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="${col}" style="display:inline;width:14px;height:14px;vertical-align:-2px" title="Zeckenrisiko ${w.zecken_warnung}"><ellipse cx="128" cy="68" rx="26" ry="22"/><ellipse cx="128" cy="158" rx="88" ry="80"/><path d="M52,120 Q20,106 8,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,142 Q12,136 2,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M44,164 Q12,162 2,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M52,184 Q22,192 10,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,120 Q236,106 248,94" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,142 Q244,136 254,132" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M212,164 Q244,162 254,162" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/><path d="M204,184 Q234,192 246,204" fill="none" stroke="${col}" stroke-width="14" stroke-linecap="round"/></svg>`;
|
||||||
}
|
}
|
||||||
info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${zecken}`;
|
info.innerHTML = `${icon} ${temp} ${w.desc}${regen}${warning}${zecken}`;
|
||||||
info.classList.remove('map-weather-chip--hidden');
|
info.classList.remove('map-weather-chip--hidden');
|
||||||
sep.classList.remove('map-weather-chip--hidden');
|
sep.classList.remove('map-weather-chip--hidden');
|
||||||
} catch { /* still */ }
|
} catch { /* still */ }
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ window.Page_notifications = (() => {
|
||||||
? App.callModule('poison', 'openDetail', { id: d.id })
|
? App.callModule('poison', 'openDetail', { id: d.id })
|
||||||
: App.navigate('poison');
|
: App.navigate('poison');
|
||||||
break;
|
break;
|
||||||
case 'friend_request': App.navigate('friends'); break;
|
case 'friend_request': _openFriendRequestModal(); break;
|
||||||
case 'health_reminder':App.navigate('health'); break;
|
case 'health_reminder':App.navigate('health'); break;
|
||||||
case 'milestone': App.navigate('diary'); break;
|
case 'milestone': App.navigate('diary'); break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -379,5 +379,55 @@ window.Page_notifications = (() => {
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _openFriendRequestModal() {
|
||||||
|
let requests;
|
||||||
|
try { requests = await API.friends.pending(); } catch { requests = []; }
|
||||||
|
|
||||||
|
if (!requests.length) {
|
||||||
|
UI.toast.info('Keine offenen Freundschaftsanfragen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = requests.map(r => `
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) 0;
|
||||||
|
border-bottom:1px solid var(--c-border)">
|
||||||
|
<div style="width:36px;height:36px;border-radius:50%;overflow:hidden;flex-shrink:0;
|
||||||
|
background:var(--c-surface-2);display:flex;align-items:center;justify-content:center">
|
||||||
|
${r.avatar_url
|
||||||
|
? `<img src="${UI.escape(r.avatar_url)}" style="width:100%;height:100%;object-fit:cover">`
|
||||||
|
: `<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#user"></use></svg>`}
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;font-weight:600">${UI.escape(r.requester_name)}</div>
|
||||||
|
<button class="btn btn-primary" style="padding:6px 14px;font-size:var(--text-sm)"
|
||||||
|
data-accept="${r.id}">Annehmen</button>
|
||||||
|
<button class="btn btn-secondary" style="padding:6px 10px;font-size:var(--text-sm)"
|
||||||
|
data-decline="${r.id}">✕</button>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
UI.modal.open({
|
||||||
|
title: 'Freundschaftsanfragen',
|
||||||
|
body: `<div>${items}</div>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-accept]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
await API.friends.accept(parseInt(btn.dataset.accept));
|
||||||
|
UI.modal.close();
|
||||||
|
UI.toast.success('Freundschaft angenommen!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-decline]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
await API.friends.decline(parseInt(btn.dataset.decline));
|
||||||
|
UI.modal.close();
|
||||||
|
UI.toast.info('Anfrage abgelehnt.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init };
|
return { init };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,17 @@ window.Page_routes = (() => {
|
||||||
if (actRow) actRow.style.display = 'none';
|
if (actRow) actRow.style.display = 'none';
|
||||||
const filterPanel = document.getElementById('rk-filter-panel');
|
const filterPanel = document.getElementById('rk-filter-panel');
|
||||||
if (filterPanel) filterPanel.style.display = 'none';
|
if (filterPanel) filterPanel.style.display = 'none';
|
||||||
_renderSuggestTab();
|
if (!App.hasPro(_appState?.user)) {
|
||||||
|
document.getElementById('rk-list')?.replaceChildren();
|
||||||
|
const gate = document.createElement('div');
|
||||||
|
gate.style.cssText = 'padding:var(--space-6);text-align:center;color:var(--c-text-muted)';
|
||||||
|
gate.innerHTML = `<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
|
||||||
|
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
|
||||||
|
<div style="font-size:var(--text-sm)">Routenvorschläge sind ein Pro-Feature.</div>`;
|
||||||
|
document.getElementById('rk-list')?.appendChild(gate);
|
||||||
|
} else {
|
||||||
|
_renderSuggestTab();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (searchRow) searchRow.style.display = '';
|
if (searchRow) searchRow.style.display = '';
|
||||||
if (actRow) actRow.style.display = '';
|
if (actRow) actRow.style.display = '';
|
||||||
|
|
|
||||||
|
|
@ -862,7 +862,9 @@ window.Page_trainingsplaene = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {}
|
function refresh() {}
|
||||||
function onDogChange() {}
|
function onDogChange() {
|
||||||
|
_render();
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, onDogChange };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -553,8 +553,11 @@ window.Page_uebungen = (() => {
|
||||||
_renderContent();
|
_renderContent();
|
||||||
}
|
}
|
||||||
function onDogChange() {
|
function onDogChange() {
|
||||||
_statsData = null;
|
_statsData = null;
|
||||||
_badgesData = null;
|
_badgesData = null;
|
||||||
|
_progressCache = {};
|
||||||
|
_exerciseStats = {};
|
||||||
|
_render();
|
||||||
_loadStatsAndBadges();
|
_loadStatsAndBadges();
|
||||||
_loadVirtualTrainer();
|
_loadVirtualTrainer();
|
||||||
}
|
}
|
||||||
|
|
@ -1004,7 +1007,17 @@ window.Page_uebungen = (() => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
|
case 'grundlagen': el.innerHTML = _renderGrundlagen(); break;
|
||||||
case 'ki-trainer': el.innerHTML = _renderKiTrainer(); break;
|
case 'ki-trainer':
|
||||||
|
if (!App.hasPro(_appState?.user)) {
|
||||||
|
el.innerHTML = `<div style="padding:var(--space-6);text-align:center;color:var(--c-text-muted)">
|
||||||
|
<svg class="ph-icon" style="width:36px;height:36px;color:var(--c-primary);margin-bottom:var(--space-3)" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
|
||||||
|
<div style="font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Ban Yaro Pro</div>
|
||||||
|
<div style="font-size:var(--text-sm)">Der KI-Trainer ist ein Pro-Feature.</div>
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
el.innerHTML = _renderKiTrainer();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
_bindAccordions();
|
_bindAccordions();
|
||||||
_bindStatusButtons();
|
_bindStatusButtons();
|
||||||
|
|
|
||||||
|
|
@ -346,7 +346,8 @@ window.Worlds = (() => {
|
||||||
<div class="w3-section-label">${worldLabels[w]}</div>
|
<div class="w3-section-label">${worldLabels[w]}</div>
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
||||||
${chips.map(c => `
|
${chips.map(c => `
|
||||||
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}">
|
<button class="all-chip-btn w3-chip-btn" data-page="${c.page}" style="position:relative">
|
||||||
|
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>` : ''}
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:var(--c-primary)">
|
||||||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -523,7 +524,7 @@ window.Worlds = (() => {
|
||||||
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
|
fab:[{ icon:'map-pin', color:'#10B981', label:'Ort vorschlagen', sub:'Neuen POI auf der Karte', page:'map' }] },
|
||||||
{ icon:'push-pin', label:'Forum', page:'forum',
|
{ icon:'push-pin', label:'Forum', page:'forum',
|
||||||
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
|
fab:[{ icon:'push-pin', color:'#8B5CF6', label:'Forum-Beitrag', sub:'Thema oder Frage erstellen', page:'forum', action:'openNew' }] },
|
||||||
{ icon:'users', label:'Freunde', page:'friends', pro: true,
|
{ icon:'users', label:'Freunde', page:'friends',
|
||||||
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
|
fab:[{ icon:'users', color:'#3B82F6', label:'Freund einladen', sub:'Per Link einladen', page:'friends', action:'openNew' }] },
|
||||||
{ icon:'paw-print', label:'Gassi', page:'walks', pro: true,
|
{ icon:'paw-print', label:'Gassi', page:'walks', pro: true,
|
||||||
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
|
fab:[{ icon:'paw-print', color:'#F59E0B', label:'Gassirunde', sub:'Neue Runde starten', page:'walks', action:'openNew' },
|
||||||
|
|
@ -760,6 +761,7 @@ window.Worlds = (() => {
|
||||||
<use href="/icons/phosphor.svg#lock-simple"></use>
|
<use href="/icons/phosphor.svg#lock-simple"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`}
|
</div>`}
|
||||||
|
${c.pro && _isRoleBasedPro() ? `<span style="position:absolute;top:3px;left:4px;font-size:8px;font-weight:800;color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px;z-index:2">P</span>` : ''}
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem;color:white;flex-shrink:0">
|
||||||
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
<use href="/icons/phosphor.svg#${c.icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -969,10 +971,24 @@ window.Worlds = (() => {
|
||||||
|
|
||||||
// ── CHIP-HELPER ──────────────────────────────────────────────
|
// ── CHIP-HELPER ──────────────────────────────────────────────
|
||||||
|
|
||||||
function _chip(icon, label, page, locked = false) {
|
function _isRoleBasedPro() {
|
||||||
|
const u = _state?.user;
|
||||||
|
if (!u) return false;
|
||||||
|
const t = u.subscription_tier || 'standard';
|
||||||
|
if (t.endsWith('_test') || ['pro','breeder'].includes(t)) return false;
|
||||||
|
return u.rolle === 'admin' || u.rolle === 'moderator' || u.is_moderator || u.is_social_media;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _chip(icon, label, page, locked = false, proBadge = false) {
|
||||||
const style = locked ? 'opacity:0.25;cursor:default;' : '';
|
const style = locked ? 'opacity:0.25;cursor:default;' : '';
|
||||||
|
const badge = proBadge
|
||||||
|
? `<span style="position:absolute;top:2px;left:3px;font-size:8px;font-weight:800;
|
||||||
|
color:#fff;background:#92400e;border-radius:3px;padding:0 3px;line-height:14px">P</span>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`} style="${style}">
|
<div class="world-chip" ${locked ? '' : `data-wnav="${page}"`}
|
||||||
|
style="${style}position:relative">
|
||||||
|
${badge}
|
||||||
<svg class="ph-icon" style="width:1.4rem;height:1.4rem">
|
<svg class="ph-icon" style="width:1.4rem;height:1.4rem">
|
||||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -1097,9 +1113,9 @@ window.Worlds = (() => {
|
||||||
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
|
<span style="font-size:1.25rem;font-weight:800;color:${gassiColor};line-height:1">${gassiScore ?? '—'}</span>
|
||||||
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
|
${gassiScore ? `<span style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);font-weight:600">/10</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${w ? `<div style="font-size:9px;color:rgba(255,255,255,0.75);font-weight:500;margin-top:2px;line-height:1.5">
|
${w ? `<div style="font-size:9px;font-weight:500;margin-top:2px;line-height:1.5">
|
||||||
<div>${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
|
<div style="color:rgba(255,255,255,0.75)">${Math.round(w.temp_c ?? 0)}° · ${w.precip_prob ?? 0}% Regen</div>
|
||||||
${w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
|
${w.rain_warning_time ? `<div style="color:#fbbf24;font-weight:700">⚠ Umschwung ab ${w.rain_warning_time}</div>` : w.next_rain_time ? `<div style="color:rgba(255,255,255,0.5)">ab ${w.next_rain_time} Uhr</div>` : ''}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1122,7 +1138,7 @@ window.Worlds = (() => {
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
<div class="world-section-label">Deine Bereiche</div>
|
<div class="world-section-label">Deine Bereiche</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${features.map(f => _chip(f.icon, f.label, f.page)).join('')}
|
${features.map(f => _chip(f.icon, f.label, f.page, false, f.pro && _isRoleBasedPro())).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="impressum">Impressum</span>
|
<span data-wnav="impressum">Impressum</span>
|
||||||
|
|
@ -1413,7 +1429,7 @@ window.Worlds = (() => {
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
|
<div class="world-section-label">Alles über ${_esc(dog.name)}</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
|
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="gruender">Die 100 Gründer</span>
|
<span data-wnav="gruender">Die 100 Gründer</span>
|
||||||
|
|
@ -1583,7 +1599,7 @@ window.Worlds = (() => {
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
<div class="world-section-label">Die Welt da draußen</div>
|
<div class="world-section-label">Die Welt da draußen</div>
|
||||||
<div class="world-chips-grid">
|
<div class="world-chips-grid">
|
||||||
${chips.map(c => _chip(c.icon, c.label, c.page)).join('')}
|
${chips.map(c => _chip(c.icon, c.label, c.page, false, c.pro && _isRoleBasedPro())).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-footer-links">
|
<div class="world-footer-links">
|
||||||
<span data-wnav="datenschutz">Datenschutz</span>
|
<span data-wnav="datenschutz">Datenschutz</span>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
|
||||||
Allow: /info
|
|
||||||
Allow: /wiki/rassen
|
|
||||||
Allow: /wiki/rasse/
|
|
||||||
Allow: /hund/
|
|
||||||
Allow: /breeder/
|
|
||||||
Allow: /wurfboerse
|
|
||||||
Allow: /knigge
|
|
||||||
Disallow: /api/
|
Disallow: /api/
|
||||||
Disallow: /ausweis/
|
Disallow: /ausweis/
|
||||||
Disallow: /teilen/
|
Disallow: /teilen/
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v834';
|
const CACHE_VERSION = 'by-v855';
|
||||||
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
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
|
|
@ -87,24 +87,39 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||||||
if temp is not None and temp > 7.0 and 3 <= month <= 10:
|
if temp is not None and temp > 7.0 and 3 <= month <= 10:
|
||||||
zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
|
zecken = 'hoch' if temp > 20 else ('mittel' if temp > 12 else 'niedrig')
|
||||||
|
|
||||||
# Nächste Regenstunde: erstes stündliches Fenster (jetzt+1h bis +12h) mit ≥60% Niederschlag
|
# Nächste Regenstunde + Umschwung-Warnung
|
||||||
next_rain_time = None
|
next_rain_time = None
|
||||||
|
rain_warning_time = None # Stunde mit ≥40%-Sprung gegenüber Vorststunde
|
||||||
already_raining = wcode >= 51
|
already_raining = wcode >= 51
|
||||||
|
now_h = datetime.now().hour
|
||||||
|
h_times = hourly.get('time', [])
|
||||||
|
h_precip = hourly.get('precipitation_probability', [])
|
||||||
|
|
||||||
|
# Index-Liste nur für Stunden im Fenster now_h+1 … now_h+12
|
||||||
|
window = []
|
||||||
|
for t, p in zip(h_times, h_precip):
|
||||||
|
try:
|
||||||
|
entry_h = int(t[11:13])
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if entry_h <= now_h or entry_h > now_h + 12:
|
||||||
|
continue
|
||||||
|
window.append((entry_h, p if p is not None else 0))
|
||||||
|
|
||||||
if not already_raining:
|
if not already_raining:
|
||||||
now_h = datetime.now().hour
|
for entry_h, p in window:
|
||||||
h_times = hourly.get('time', [])
|
if next_rain_time is None and p >= 20:
|
||||||
h_precip = hourly.get('precipitation_probability', [])
|
|
||||||
for t, p in zip(h_times, h_precip):
|
|
||||||
try:
|
|
||||||
entry_h = int(t[11:13])
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if entry_h <= now_h or entry_h > now_h + 12:
|
|
||||||
continue
|
|
||||||
if p is not None and p >= 20:
|
|
||||||
next_rain_time = f"{entry_h:02d}:00"
|
next_rain_time = f"{entry_h:02d}:00"
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Umschwung: Sprung ≥40% von einer Stunde zur nächsten
|
||||||
|
prev_p = wcode >= 51 and 100 or (h_precip[now_h] if now_h < len(h_precip) else 0) or 0
|
||||||
|
for entry_h, p in window:
|
||||||
|
if p - prev_p >= 40:
|
||||||
|
rain_warning_time = f"{entry_h:02d}:00"
|
||||||
|
break
|
||||||
|
prev_p = p
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'temp_c': temp,
|
'temp_c': temp,
|
||||||
'feels_like_c': feels_like,
|
'feels_like_c': feels_like,
|
||||||
|
|
@ -115,8 +130,9 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
|
||||||
'precip_prob': precip,
|
'precip_prob': precip,
|
||||||
'uv_index': uv,
|
'uv_index': uv,
|
||||||
'is_day': bool(is_day),
|
'is_day': bool(is_day),
|
||||||
'zecken_warnung': zecken,
|
'zecken_warnung': zecken,
|
||||||
'next_rain_time': next_rain_time,
|
'next_rain_time': next_rain_time,
|
||||||
|
'rain_warning_time': rain_warning_time,
|
||||||
}
|
}
|
||||||
_location_cache[key] = (now, data)
|
_location_cache[key] = (now, data)
|
||||||
return data
|
return data
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/data/banyaro.db
|
- DB_PATH=/data/banyaro.db
|
||||||
- MEDIA_DIR=/prod-media
|
- MEDIA_DIR=/data/media
|
||||||
- STAGING=true
|
- STAGING=true
|
||||||
- KI_MODE=cloud
|
- KI_MODE=cloud
|
||||||
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue