Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration

- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push
- Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push
- Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge,
  forum, wiki, walks) vollständig auf Phosphor-Icons migriert
- Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos)
- TheDogAPI lokal gespiegelt (169 Rassen + Fotos)
- Quiz-Result-Cards horizontal (korrekte Bildproportionen)
- SW by-v89
This commit is contained in:
rene 2026-04-15 21:33:53 +02:00
parent 96bd57f0ad
commit 097295c628
44 changed files with 9980 additions and 300 deletions

View file

@ -81,7 +81,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
"SELECT id, email, name, rolle, is_premium FROM users WHERE id=?",
"SELECT id, email, name, rolle, is_premium, is_moderator FROM users WHERE id=?",
(user_id,)
).fetchone()

View file

@ -307,6 +307,23 @@ def init_db():
);
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
-- VERLORENE HUNDE
CREATE TABLE IF NOT EXISTS lost_dogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
name TEXT NOT NULL,
rasse TEXT,
beschreibung TEXT NOT NULL,
foto_url TEXT,
lat REAL NOT NULL,
lon REAL NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
gefunden_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_lost_active ON lost_dogs(is_active, created_at DESC);
-- OSM Tile-Cache: welche Kacheln wurden schon geladen?
CREATE TABLE IF NOT EXISTS osm_tiles (
type TEXT NOT NULL,
@ -401,6 +418,27 @@ def _migrate(conn_factory):
("osm_pois", "opening_hours", "TEXT"),
("osm_pois", "phone", "TEXT"),
("osm_pois", "website", "TEXT"),
# Forum: Threads brauchen text + antworten-Zähler
("forum_threads", "text", "TEXT NOT NULL DEFAULT ''"),
("forum_threads", "antworten", "INTEGER NOT NULL DEFAULT 0"),
# Forum Sprint 11: erweiterte Thread-Felder
("forum_threads", "foto_urls", "TEXT"),
("forum_threads", "is_pinned", "INTEGER NOT NULL DEFAULT 0"),
("forum_threads", "is_locked", "INTEGER NOT NULL DEFAULT 0"),
("forum_threads", "is_deleted", "INTEGER NOT NULL DEFAULT 0"),
("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"),
# Forum Sprint 11: erweiterte Post-Felder
("forum_posts", "foto_urls", "TEXT"),
("forum_posts", "is_deleted", "INTEGER NOT NULL DEFAULT 0"),
("forum_posts", "likes", "INTEGER NOT NULL DEFAULT 0"),
# Users: Moderator-Flag + Forum-Standort
("users", "is_moderator", "INTEGER NOT NULL DEFAULT 0"),
("users", "forum_lat", "REAL"),
("users", "forum_lon", "REAL"),
("users", "forum_show_location", "INTEGER NOT NULL DEFAULT 0"),
# Events: Quelle + externe ID für gescrapte Events
("events", "quelle", "TEXT NOT NULL DEFAULT 'nutzer'"),
("events", "external_id", "TEXT"),
]
with conn_factory() as conn:
for table, column, col_type in migrations:
@ -414,6 +452,142 @@ def _migrate(conn_factory):
)
logger.info(f"Migration: {table}.{column} hinzugefügt.")
# Knigge: Community-Votes
conn.executescript("""
CREATE TABLE IF NOT EXISTS knigge_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
szenario_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
answer TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(szenario_id, user_id)
);
""")
# Forum Sprint 11: neue Tabellen
conn.executescript("""
CREATE TABLE IF NOT EXISTS forum_likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
UNIQUE(user_id, target_type, target_id)
);
CREATE TABLE IF NOT EXISTS forum_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id INTEGER NOT NULL,
grund TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved INTEGER NOT NULL DEFAULT 0
);
""")
# Wiki: Community-Berichte
conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_berichte (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rasse TEXT NOT NULL,
titel TEXT NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC);
""")
# Hunde-Filme: Bewertungen + Hund des Monats
conn.executescript("""
CREATE TABLE IF NOT EXISTS movie_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
film_id TEXT NOT NULL,
bewertung INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, film_id)
);
CREATE TABLE IF NOT EXISTS hund_des_monats_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
monat TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, monat)
);
""")
# Events: Unique-Index für externe IDs (idempotent)
conn.executescript("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external
ON events(external_id) WHERE external_id IS NOT NULL;
""")
# Freundschaften + Direktnachrichten
conn.executescript("""
CREATE TABLE IF NOT EXISTS friendships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT,
UNIQUE(requester_id, addressee_id)
);
CREATE INDEX IF NOT EXISTS idx_friendships_addressee ON friendships(addressee_id, status);
CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id, status);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_a_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_b_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_msg_at TEXT,
a_read_at TEXT,
b_read_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_a_id, user_b_id)
);
CREATE TABLE IF NOT EXISTS direct_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dm_conv ON direct_messages(conversation_id, created_at ASC);
""")
# Wiki: Rassen-Datenbank (TheDogAPI)
conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_rassen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id INTEGER UNIQUE,
name TEXT NOT NULL,
name_de TEXT,
gruppe TEXT,
herkunft TEXT,
temperament TEXT,
gewicht_min_kg REAL,
gewicht_max_kg REAL,
groesse TEXT,
lebensdauer TEXT,
foto_url TEXT,
bred_for TEXT,
aktivitaet TEXT,
wohnung_geeignet INTEGER DEFAULT 0,
kinder_geeignet INTEGER DEFAULT 1,
erfahrung TEXT DEFAULT 'anfaenger',
slug TEXT UNIQUE,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_wiki_rassen_slug ON wiki_rassen(slug);
CREATE INDEX IF NOT EXISTS idx_wiki_rassen_gruppe ON wiki_rassen(gruppe);
""")
# Datenmigration: diary_dogs für bestehende Einträge befüllen
conn.execute("""
INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id)

View file

@ -63,6 +63,13 @@ from routes.walks import router as walks_router
from routes.events import router as events_router
from routes.sitting import router as sitting_router
from routes.osm import router as osm_router
from routes.forum import router as forum_router
from routes.lost import router as lost_router
from routes.knigge import router as knigge_router
from routes.wiki import router as wiki_router
from routes.movies import router as movies_router
from routes.friends import router as friends_router
from routes.chat import router as chat_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -78,6 +85,13 @@ app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Tre
app.include_router(events_router, prefix="/api/events", tags=["Events"])
app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"])
app.include_router(osm_router, prefix="/api/osm", tags=["OSM"])
app.include_router(forum_router, prefix="/api/forum", tags=["Forum"])
app.include_router(lost_router, prefix="/api/lost", tags=["Verlorener Hund"])
app.include_router(knigge_router, prefix="/api/knigge", tags=["Knigge"])
app.include_router(wiki_router, prefix="/api/wiki", tags=["Wiki"])
app.include_router(movies_router, prefix="/api/movies", tags=["Filme"])
app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"])
app.include_router(chat_router, prefix="/api/chat", tags=["Chat"])
# ------------------------------------------------------------------
@ -131,6 +145,298 @@ async def share_target(request: Request):
headers={"Cache-Control": "no-cache"}
)
# Öffentliche Hunde-Profilseite (für NFC-Tags, kein Login nötig)
@app.get("/hund/{dog_id}")
async def public_dog_page(dog_id: int):
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Profil BAN YARO</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/components.css">
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: var(--font-sans);
background: var(--c-bg);
color: var(--c-text);
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-6) var(--space-4);
}}
.profile-card {{
background: var(--c-surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
max-width: 440px;
width: 100%;
padding: var(--space-8) var(--space-6);
text-align: center;
}}
.dog-photo {{
width: 140px;
height: 140px;
border-radius: 50%;
object-fit: cover;
border: 4px solid var(--c-primary);
margin-bottom: var(--space-4);
}}
.dog-photo-placeholder {{
width: 140px;
height: 140px;
border-radius: 50%;
background: var(--c-surface-2);
border: 4px solid var(--c-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
margin: 0 auto var(--space-4);
}}
.dog-name {{
font-size: var(--text-2xl);
font-weight: var(--weight-bold);
color: var(--c-text);
margin-bottom: var(--space-1);
}}
.dog-rasse {{
font-size: var(--text-base);
color: var(--c-text-secondary);
margin-bottom: var(--space-5);
}}
.info-row {{
display: flex;
justify-content: center;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-5);
}}
.info-pill {{
background: var(--c-primary-subtle);
border-radius: var(--radius-full);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
color: var(--c-primary-dark);
font-weight: var(--weight-medium);
}}
.dog-bio {{
background: var(--c-surface-2);
border-radius: var(--radius-md);
padding: var(--space-4);
font-style: italic;
color: var(--c-text-secondary);
line-height: var(--leading-relaxed);
margin-bottom: var(--space-5);
text-align: left;
}}
.besitzer {{
font-size: var(--text-sm);
color: var(--c-text-muted);
margin-bottom: var(--space-6);
}}
.found-section {{
border-top: 1px solid var(--c-border-light);
padding-top: var(--space-6);
}}
.found-hint {{
font-size: var(--text-sm);
color: var(--c-text-secondary);
margin-bottom: var(--space-4);
}}
.found-fields {{
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}}
.found-input {{
width: 100%;
padding: var(--space-3) var(--space-4);
border: 1.5px solid var(--c-border);
border-radius: var(--radius-md);
font-size: var(--text-base);
font-family: var(--font-sans);
background: var(--c-surface);
color: var(--c-text);
outline: none;
transition: border-color var(--transition-fast);
}}
.found-input:focus {{
border-color: var(--c-primary);
}}
.found-btn {{
width: 100%;
}}
.success-msg {{
background: var(--c-success-subtle);
color: var(--c-success);
border-radius: var(--radius-md);
padding: var(--space-4);
font-weight: var(--weight-medium);
display: none;
}}
.error-msg {{
background: var(--c-danger-subtle);
color: var(--c-danger);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: var(--text-sm);
display: none;
}}
.loading {{
color: var(--c-text-muted);
padding: var(--space-12) 0;
font-size: var(--text-lg);
}}
.not-found {{
text-align: center;
color: var(--c-text-secondary);
padding: var(--space-12) var(--space-4);
}}
.not-found .icon {{ font-size: 4rem; margin-bottom: var(--space-4); display: block; }}
.app-logo {{
font-size: var(--text-sm);
color: var(--c-text-muted);
margin-top: var(--space-8);
}}
.app-logo a {{
color: var(--c-primary);
text-decoration: none;
font-weight: var(--weight-medium);
}}
</style>
</head>
<body>
<div class="profile-card" id="profile-card">
<div class="loading">Lade Profil</div>
</div>
<p class="app-logo">powered by <a href="/">BAN YARO</a></p>
<script>
const DOG_ID = {dog_id};
function esc(s) {{
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}}
function calcAlter(geburtstag) {{
const born = new Date(geburtstag + 'T00:00:00');
const tage = Math.floor((Date.now() - born) / 86400000);
if (tage < 0) return '';
if (tage < 30) return tage + ' Tag' + (tage !== 1 ? 'e' : '') + ' alt';
if (tage < 365) {{
const m = Math.floor(tage / 30);
return m + ' Monat' + (m !== 1 ? 'e' : '') + ' alt';
}}
const j = Math.floor(tage / 365);
const m = Math.floor((tage % 365) / 30);
return m > 0
? j + ' Jahr' + (j !== 1 ? 'e' : '') + ', ' + m + ' Monat' + (m !== 1 ? 'e' : '') + ' alt'
: j + ' Jahr' + (j !== 1 ? 'e' : '') + ' alt';
}}
async function load() {{
const card = document.getElementById('profile-card');
try {{
const resp = await fetch('/api/dogs/public/' + DOG_ID);
if (!resp.ok) {{
card.innerHTML = `
<div class="not-found">
<span class="icon">🐾</span>
<p>Dieses Profil ist nicht öffentlich oder wurde nicht gefunden.</p>
</div>`;
return;
}}
const dog = await resp.json();
const photoHTML = dog.foto_url
? `<img class="dog-photo" src="${{esc(dog.foto_url)}}" alt="${{esc(dog.name)}}">`
: `<div class="dog-photo-placeholder">🐕</div>`;
const pills = [];
if (dog.geburtstag) {{
pills.push(`<span class="info-pill">🎂 ${{calcAlter(dog.geburtstag)}}</span>`);
}}
const bioHTML = dog.bio
? `<div class="dog-bio">"${{esc(dog.bio)}}"</div>`
: '';
// Vorname des Besitzers
const vorname = dog.besitzer_name ? dog.besitzer_name.split(' ')[0] : '';
card.innerHTML = `
${{photoHTML}}
<h1 class="dog-name">${{esc(dog.name)}}</h1>
${{dog.rasse ? `<p class="dog-rasse">${{esc(dog.rasse)}}</p>` : '<p class="dog-rasse"></p>'}}
${{pills.length ? `<div class="info-row">${{pills.join('')}}</div>` : ''}}
${{bioHTML}}
${{vorname ? `<p class="besitzer">Besitzer: ${{esc(vorname)}}</p>` : ''}}
<div class="found-section">
<p class="found-hint">Hast du diesen Hund gefunden? Benachrichtige den Besitzer!</p>
<div class="found-fields">
<input class="found-input" type="text" id="found-msg"
placeholder="Kurze Nachricht (optional)" maxlength="200">
<input class="found-input" type="text" id="found-kontakt"
placeholder="Deine Telefonnummer / E-Mail (optional)" maxlength="100">
</div>
<button class="btn btn-primary found-btn" id="found-btn"
onclick="sendFound()">
🐾 Ich habe diesen Hund gefunden
</button>
<div class="success-msg" id="found-success">
Benachrichtigung wurde gesendet. Der Besitzer wurde informiert!
</div>
<div class="error-msg" id="found-error"></div>
</div>
`;
}} catch(e) {{
card.innerHTML = `<div class="not-found"><span class="icon"></span><p>Fehler beim Laden.</p></div>`;
}}
}}
async function sendFound() {{
const btn = document.getElementById('found-btn');
const success = document.getElementById('found-success');
const error = document.getElementById('found-error');
const msg = document.getElementById('found-msg')?.value || '';
const kontakt = document.getElementById('found-kontakt')?.value || '';
btn.disabled = true;
btn.textContent = 'Sende…';
error.style.display = 'none';
try {{
const resp = await fetch('/api/dogs/public/' + DOG_ID + '/found', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{ message: msg, kontakt: kontakt }}),
}});
if (!resp.ok) throw new Error('Fehler beim Senden.');
btn.style.display = 'none';
success.style.display = 'block';
}} catch(e) {{
btn.disabled = false;
btn.textContent = '🐾 Ich habe diesen Hund gefunden';
error.textContent = 'Fehler beim Senden. Bitte versuche es erneut.';
error.style.display = 'block';
}}
}}
load();
</script>
</body>
</html>"""
from fastapi.responses import HTMLResponse
return HTMLResponse(content=html)
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):

191
backend/routes/chat.py Normal file
View file

@ -0,0 +1,191 @@
"""BAN YARO — Direktnachrichten"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
def _conv_key(a: int, b: int):
"""Normalisiert Konversations-User-IDs: user_a < user_b."""
return (min(a, b), max(a, b))
@router.get("/conversations")
async def list_conversations(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT c.id, c.last_msg_at,
CASE WHEN c.user_a_id=? THEN c.user_b_id ELSE c.user_a_id END AS partner_id,
CASE WHEN c.user_a_id=? THEN ub.name ELSE ua.name END AS partner_name,
(SELECT text FROM direct_messages
WHERE conversation_id=c.id AND is_deleted=0
ORDER BY created_at DESC LIMIT 1) AS last_text,
(SELECT COUNT(*) FROM direct_messages
WHERE conversation_id=c.id
AND sender_id != ?
AND is_deleted=0
AND created_at > COALESCE(
CASE WHEN c.user_a_id=? THEN c.a_read_at ELSE c.b_read_at END,
'1970-01-01'
)
) AS unread_count
FROM conversations c
JOIN users ua ON ua.id=c.user_a_id
JOIN users ub ON ub.id=c.user_b_id
WHERE c.user_a_id=? OR c.user_b_id=?
ORDER BY COALESCE(c.last_msg_at, c.created_at) DESC
""", (uid, uid, uid, uid, uid, uid)).fetchall()
return [dict(r) for r in rows]
class StartConvModel(BaseModel):
partner_id: int
@router.post("/conversations", status_code=201)
async def start_conversation(data: StartConvModel, user=Depends(get_current_user)):
uid = user["id"]
if uid == data.partner_id:
raise HTTPException(400, "Du kannst dir selbst keine Nachrichten schicken.")
a, b = _conv_key(uid, data.partner_id)
with db() as conn:
f = conn.execute("""
SELECT 1 FROM friendships
WHERE ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
AND status='accepted'
""", (uid, data.partner_id, data.partner_id, uid)).fetchone()
if not f:
raise HTTPException(403, "Ihr seid noch keine Freunde.")
existing = conn.execute(
"SELECT id FROM conversations WHERE user_a_id=? AND user_b_id=?", (a, b)
).fetchone()
if existing:
return {"conversation_id": existing["id"]}
cur = conn.execute(
"INSERT INTO conversations (user_a_id, user_b_id) VALUES (?,?)", (a, b)
)
return {"conversation_id": cur.lastrowid}
@router.get("/conversations/{conv_id}")
async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
partner = conn.execute("SELECT name FROM users WHERE id=?", (partner_id,)).fetchone()
msgs = conn.execute("""
SELECT m.id, m.sender_id, m.text, m.is_deleted, m.created_at,
u.name AS sender_name
FROM direct_messages m
JOIN users u ON u.id=m.sender_id
WHERE m.conversation_id=?
ORDER BY m.created_at ASC
LIMIT ? OFFSET ?
""", (conv_id, limit, offset)).fetchall()
return {
"conversation_id": conv_id,
"partner_id": partner_id,
"partner_name": partner["name"] if partner else "Unbekannt",
"messages": [dict(m) for m in msgs],
}
class SendMsgModel(BaseModel):
text: str
@router.post("/conversations/{conv_id}/messages", status_code=201)
async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_current_user)):
uid = user["id"]
text = data.text.strip()
if not text:
raise HTTPException(400, "Nachricht darf nicht leer sein.")
if len(text) > 2000:
raise HTTPException(400, "Nachricht zu lang (max. 2000 Zeichen).")
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404, "Konversation nicht gefunden.")
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
cur = conn.execute("""
INSERT INTO direct_messages (conversation_id, sender_id, text) VALUES (?,?,?)
""", (conv_id, uid, text))
msg_id = cur.lastrowid
conn.execute(
"UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?",
(conv_id,)
)
try:
from routes.push import send_push_to_user
preview = text[:100] + ("" if len(text) > 100 else "")
send_push_to_user(partner_id, {
"title": f"Nachricht von {user['name']}",
"body": preview,
"type": "chat_message",
"tag": f"chat-{conv_id}",
"data": {"page": "chat", "conversation_id": conv_id},
})
except Exception:
pass
return {"id": msg_id, "ok": True}
@router.post("/conversations/{conv_id}/read")
async def mark_read(conv_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conv = conn.execute(
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
(conv_id, uid, uid)
).fetchone()
if not conv:
raise HTTPException(404)
field = "a_read_at" if conv["user_a_id"] == uid else "b_read_at"
conn.execute(
f"UPDATE conversations SET {field}=datetime('now') WHERE id=?",
(conv_id,)
)
return {"ok": True}
@router.delete("/messages/{msg_id}")
async def delete_message(msg_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
msg = conn.execute(
"SELECT id FROM direct_messages WHERE id=? AND sender_id=?", (msg_id, uid)
).fetchone()
if not msg:
raise HTTPException(404, "Nachricht nicht gefunden.")
conn.execute(
"UPDATE direct_messages SET is_deleted=1, text='[gelöscht]' WHERE id=?",
(msg_id,)
)
return {"ok": True}

View file

@ -7,6 +7,7 @@ from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -159,3 +160,40 @@ async def public_dog_profile(dog_id: int):
if not dog:
raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.")
return dict(dog)
class FoundReport(BaseModel):
message: Optional[str] = None
kontakt: Optional[str] = None
# Gefunden-Meldung (kein Login nötig)
@router.post("/public/{dog_id}/found")
async def report_found(dog_id: int, data: FoundReport = FoundReport()):
with db() as conn:
row = conn.execute(
"""SELECT d.id, d.name, d.user_id
FROM dogs d
WHERE d.id=? AND d.is_public=1""",
(dog_id,)
).fetchone()
if not row:
raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.")
dog_name = row["name"]
user_id = row["user_id"]
body = data.message.strip() if data.message and data.message.strip() \
else "Jemand hat deinen Hund gefunden. Öffne die App für Details."
if data.kontakt and data.kontakt.strip():
body += f" Kontakt: {data.kontakt.strip()}"
send_push_to_user(user_id, {
"title": f"🐾 {dog_name} wurde gefunden!",
"body": body,
"data": {"page": "diary", "found": True},
"tag": f"found-{dog_id}",
})
return {"ok": True}

View file

@ -58,14 +58,24 @@ async def list_events(
radius: int = 50000,
typ: Optional[str] = None,
alle: bool = False,
quelle: Optional[str] = None,
):
today = date.today().isoformat()
with db() as conn:
q = "SELECT e.*, u.name AS veranstalter_name FROM events e LEFT JOIN users u ON u.id = e.user_id WHERE e.status = 'aktiv'"
q = """
SELECT e.*,
CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name,
e.quelle
FROM events e
LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0
WHERE e.status = 'aktiv'
"""
if not alle:
q += f" AND e.datum >= '{today}'"
if typ and typ in TYPEN:
q += f" AND e.typ = '{typ}'"
if quelle:
q += f" AND e.quelle = '{quelle}'"
q += " ORDER BY e.datum ASC, e.uhrzeit ASC"
rows = conn.execute(q).fetchall()
@ -85,14 +95,14 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
raise HTTPException(400, f"Ungültiger Typ. Erlaubt: {', '.join(TYPEN)}")
with db() as conn:
cur = conn.execute("""
INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (user_id, titel, datum, uhrzeit, lat, lon, ort_name, typ, beschreibung, link, quelle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nutzer')
""", (user['id'], data.titel, data.datum, data.uhrzeit,
data.lat, data.lon, data.ort_name,
data.typ, data.beschreibung, data.link))
row = conn.execute(
"SELECT e.*, u.name AS veranstalter_name FROM events e "
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(cur.lastrowid,)
).fetchone()
return dict(row)
@ -105,8 +115,8 @@ async def create_event(data: EventCreate, user=Depends(get_current_user)):
async def get_event(event_id: int):
with db() as conn:
row = conn.execute(
"SELECT e.*, u.name AS veranstalter_name FROM events e "
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,)
).fetchone()
if not row:
@ -123,7 +133,7 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren
ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
if not ev:
raise HTTPException(404, "Event nicht gefunden.")
if ev['user_id'] != user['id']:
if ev['user_id'] == 0 or ev['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann das Event bearbeiten.")
updates = data.model_dump(exclude_none=True)
if updates:
@ -132,8 +142,8 @@ async def update_event(event_id: int, data: EventUpdate, user=Depends(get_curren
cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute(f"UPDATE events SET {cols} WHERE id = ?", [*updates.values(), event_id])
row = conn.execute(
"SELECT e.*, u.name AS veranstalter_name FROM events e "
"LEFT JOIN users u ON u.id = e.user_id WHERE e.id = ?",
"SELECT e.*, CASE WHEN e.user_id = 0 THEN 'VDH' ELSE u.name END AS veranstalter_name, e.quelle "
"FROM events e LEFT JOIN users u ON u.id = e.user_id AND e.user_id != 0 WHERE e.id = ?",
(event_id,)
).fetchone()
return dict(row)
@ -148,6 +158,6 @@ async def delete_event(event_id: int, user=Depends(get_current_user)):
ev = conn.execute("SELECT * FROM events WHERE id = ?", (event_id,)).fetchone()
if not ev:
raise HTTPException(404, "Event nicht gefunden.")
if ev['user_id'] != user['id']:
if ev['user_id'] == 0 or ev['user_id'] != user['id']:
raise HTTPException(403, "Nur der Veranstalter kann das Event löschen.")
conn.execute("UPDATE events SET status = 'geloescht' WHERE id = ?", (event_id,))

551
backend/routes/forum.py Normal file
View file

@ -0,0 +1,551 @@
"""BAN YARO — Forum (Sprint 11)"""
import os, uuid, json
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
FORUM_DIR = os.path.join(MEDIA_DIR, "forum")
KATEGORIEN = ['allgemein', 'rasse', 'region', 'gesundheit', 'erziehung', 'tauschboerse']
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ThreadCreate(BaseModel):
kategorie: str = 'allgemein'
titel: str
text: str
class PostCreate(BaseModel):
text: str
class ThreadPatch(BaseModel):
is_pinned: Optional[int] = None
is_locked: Optional[int] = None
class LikeBody(BaseModel):
target_type: str # 'thread' | 'post'
target_id: int
class ReportBody(BaseModel):
target_type: str
target_id: int
grund: str
class LocationBody(BaseModel):
lat: Optional[float] = None
lon: Optional[float] = None
show: bool = False
class ResolveReport(BaseModel):
resolved: int = 1
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _save_upload(file: UploadFile, data: bytes) -> str:
os.makedirs(FORUM_DIR, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(FORUM_DIR, filename)
with open(path, "wb") as f:
f.write(data)
return f"/media/forum/{filename}"
def _parse_foto_urls(raw) -> list:
if not raw:
return []
try:
return json.loads(raw)
except Exception:
return []
def _user_liked(conn, user_id: int, target_type: str, target_id: int) -> bool:
if not user_id:
return False
row = conn.execute(
"SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
(user_id, target_type, target_id)
).fetchone()
return row is not None
# ------------------------------------------------------------------
# GET /api/forum/threads
# ------------------------------------------------------------------
@router.get("/threads")
async def list_threads(
kategorie: Optional[str] = None,
search: Optional[str] = None,
limit: int = 30,
offset: int = 0,
user=Depends(get_current_user_optional),
):
uid = user['id'] if user else None
with db() as conn:
q = """
SELECT t.id, t.kategorie, t.titel,
SUBSTR(t.text, 1, 120) AS text_preview,
t.antworten, t.likes, t.views,
t.is_pinned, t.is_locked, t.foto_urls,
t.created_at, t.user_id,
u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.is_deleted = 0
"""
params = []
if kategorie and kategorie != 'alle':
q += " AND t.kategorie = ?"
params.append(kategorie)
if search:
q += " AND (t.titel LIKE ? OR t.text LIKE ?)"
params.extend([f'%{search}%', f'%{search}%'])
q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(q, params).fetchall()
result = []
for r in rows:
t = dict(r)
foto_list = _parse_foto_urls(t.get('foto_urls'))
t['foto_preview'] = foto_list[0] if foto_list else None
t['foto_urls'] = foto_list
t['user_liked'] = _user_liked(conn, uid, 'thread', t['id']) if uid else False
result.append(t)
return result
# ------------------------------------------------------------------
# POST /api/forum/threads
# ------------------------------------------------------------------
@router.post("/threads", status_code=201)
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if not data.titel.strip():
raise HTTPException(400, "Titel darf nicht leer sein.")
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
if len(data.text.strip()) < 20:
raise HTTPException(400, "Text muss mindestens 20 Zeichen lang sein.")
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn:
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text)
VALUES (?, ?, ?, ?)""",
(user['id'], data.kategorie, data.titel.strip(), data.text.strip())
)
row = conn.execute(
"""SELECT t.*, u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ?""",
(cur.lastrowid,)
).fetchone()
t = dict(row)
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
t['user_liked'] = False
return t
# ------------------------------------------------------------------
# GET /api/forum/threads/{id}
# ------------------------------------------------------------------
@router.get("/threads/{thread_id}")
async def get_thread(thread_id: int, user=Depends(get_current_user_optional)):
uid = user['id'] if user else None
with db() as conn:
thread = conn.execute(
"""SELECT t.*, u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ? AND t.is_deleted = 0""",
(thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
# Increment views
conn.execute(
"UPDATE forum_threads SET views = views + 1 WHERE id = ?", (thread_id,)
)
posts = conn.execute(
"""SELECT p.*, u.name AS autor_name
FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.thread_id = ?
ORDER BY p.created_at ASC""",
(thread_id,)
).fetchall()
result = dict(thread)
result['foto_urls'] = _parse_foto_urls(result.get('foto_urls'))
result['user_liked'] = _user_liked(conn, uid, 'thread', thread_id) if uid else False
result['posts'] = []
for p in posts:
pd = dict(p)
if pd.get('is_deleted'):
result['posts'].append({
'id': pd['id'],
'thread_id': pd['thread_id'],
'is_deleted': 1,
'created_at': pd['created_at'],
})
else:
pd['foto_urls'] = _parse_foto_urls(pd.get('foto_urls'))
pd['user_liked'] = _user_liked(conn, uid, 'post', pd['id']) if uid else False
result['posts'].append(pd)
return result
# ------------------------------------------------------------------
# DELETE /api/forum/threads/{id}
# ------------------------------------------------------------------
@router.delete("/threads/{thread_id}", status_code=204)
async def delete_thread(thread_id: int, user=Depends(get_current_user)):
with db() as conn:
thread = conn.execute(
"SELECT * FROM forum_threads WHERE id = ?", (thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
conn.execute(
"UPDATE forum_threads SET is_deleted = 1 WHERE id = ?", (thread_id,)
)
# ------------------------------------------------------------------
# PATCH /api/forum/threads/{id} — Moderator: pin/lock
# ------------------------------------------------------------------
@router.patch("/threads/{thread_id}")
async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_current_user)):
if not user.get('is_moderator'):
raise HTTPException(403, "Nur Moderatoren können Threads bearbeiten.")
with db() as conn:
thread = conn.execute(
"SELECT * FROM forum_threads WHERE id = ?", (thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
updates = data.model_dump(exclude_none=True)
if updates:
cols = ', '.join(f"{k} = ?" for k in updates)
conn.execute(
f"UPDATE forum_threads SET {cols} WHERE id = ?",
[*updates.values(), thread_id]
)
row = conn.execute(
"""SELECT t.*, u.name AS autor_name
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = ?""",
(thread_id,)
).fetchone()
t = dict(row)
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
return t
# ------------------------------------------------------------------
# POST /api/forum/threads/{id}/posts
# ------------------------------------------------------------------
@router.post("/threads/{thread_id}/posts", status_code=201)
async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current_user)):
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
with db() as conn:
thread = conn.execute(
"SELECT id, is_locked, is_deleted FROM forum_threads WHERE id = ?",
(thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['is_locked']:
raise HTTPException(403, "Dieser Thread ist gesperrt.")
if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.")
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text) VALUES (?, ?, ?)",
(thread_id, user['id'], data.text.strip())
)
conn.execute(
"UPDATE forum_threads SET antworten = antworten + 1 WHERE id = ?",
(thread_id,)
)
row = conn.execute(
"""SELECT p.*, u.name AS autor_name
FROM forum_posts p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.id = ?""",
(cur.lastrowid,)
).fetchone()
pd = dict(row)
pd['foto_urls'] = []
pd['user_liked'] = False
return pd
# ------------------------------------------------------------------
# DELETE /api/forum/posts/{id}
# ------------------------------------------------------------------
@router.delete("/posts/{post_id}", status_code=204)
async def delete_post(post_id: int, user=Depends(get_current_user)):
with db() as conn:
post = conn.execute(
"SELECT * FROM forum_posts WHERE id = ?", (post_id,)
).fetchone()
if not post:
raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
conn.execute(
"UPDATE forum_posts SET is_deleted = 1 WHERE id = ?", (post_id,)
)
# Antworten-Zähler nur verringern wenn eigener soft-delete (nicht Moderator)
conn.execute(
"UPDATE forum_threads SET antworten = MAX(0, antworten - 1) WHERE id = ?",
(post['thread_id'],)
)
# ------------------------------------------------------------------
# POST /api/forum/threads/{id}/fotos
# ------------------------------------------------------------------
@router.post("/threads/{thread_id}/fotos")
async def upload_thread_foto(
thread_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
thread = conn.execute(
"SELECT * FROM forum_threads WHERE id = ? AND is_deleted = 0",
(thread_id,)
).fetchone()
if not thread:
raise HTTPException(404, "Thread nicht gefunden.")
if thread['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(thread['foto_urls'])
if len(existing) >= 5:
raise HTTPException(400, "Maximal 5 Fotos pro Thread.")
data = await file.read()
url = _save_upload(file, data)
existing.append(url)
conn.execute(
"UPDATE forum_threads SET foto_urls = ? WHERE id = ?",
(json.dumps(existing), thread_id)
)
return {"foto_url": url, "foto_urls": existing}
# ------------------------------------------------------------------
# POST /api/forum/posts/{id}/fotos
# ------------------------------------------------------------------
@router.post("/posts/{post_id}/fotos")
async def upload_post_foto(
post_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
post = conn.execute(
"SELECT * FROM forum_posts WHERE id = ? AND is_deleted = 0",
(post_id,)
).fetchone()
if not post:
raise HTTPException(404, "Beitrag nicht gefunden.")
if post['user_id'] != user['id'] and not user.get('is_moderator'):
raise HTTPException(403, "Keine Berechtigung.")
existing = _parse_foto_urls(post['foto_urls'])
if len(existing) >= 5:
raise HTTPException(400, "Maximal 5 Fotos pro Beitrag.")
data = await file.read()
url = _save_upload(file, data)
existing.append(url)
conn.execute(
"UPDATE forum_posts SET foto_urls = ? WHERE id = ?",
(json.dumps(existing), post_id)
)
return {"foto_url": url, "foto_urls": existing}
# ------------------------------------------------------------------
# POST /api/forum/like — Toggle
# ------------------------------------------------------------------
@router.post("/like")
async def toggle_like(data: LikeBody, user=Depends(get_current_user)):
if data.target_type not in ('thread', 'post'):
raise HTTPException(400, "Ungültiger Typ.")
table = f"forum_{data.target_type}s"
with db() as conn:
existing = conn.execute(
"SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
(user['id'], data.target_type, data.target_id)
).fetchone()
if existing:
conn.execute(
"DELETE FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
(user['id'], data.target_type, data.target_id)
)
conn.execute(
f"UPDATE {table} SET likes = MAX(0, likes - 1) WHERE id = ?",
(data.target_id,)
)
liked = False
else:
conn.execute(
"INSERT OR IGNORE INTO forum_likes (user_id, target_type, target_id) VALUES (?,?,?)",
(user['id'], data.target_type, data.target_id)
)
conn.execute(
f"UPDATE {table} SET likes = likes + 1 WHERE id = ?",
(data.target_id,)
)
liked = True
count_row = conn.execute(
f"SELECT likes FROM {table} WHERE id = ?", (data.target_id,)
).fetchone()
count = count_row['likes'] if count_row else 0
return {"liked": liked, "count": count}
# ------------------------------------------------------------------
# POST /api/forum/report
# ------------------------------------------------------------------
@router.post("/report", status_code=201)
async def report_content(data: ReportBody, user=Depends(get_current_user)):
if data.target_type not in ('thread', 'post'):
raise HTTPException(400, "Ungültiger Typ.")
if not data.grund.strip():
raise HTTPException(400, "Grund darf nicht leer sein.")
with db() as conn:
conn.execute(
"""INSERT INTO forum_reports (user_id, target_type, target_id, grund)
VALUES (?, ?, ?, ?)""",
(user['id'], data.target_type, data.target_id, data.grund.strip())
)
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/forum/reports — Moderator
# ------------------------------------------------------------------
@router.get("/reports")
async def list_reports(user=Depends(get_current_user)):
if not user.get('is_moderator'):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
"""SELECT r.*, u.name AS melder_name
FROM forum_reports r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.resolved = 0
ORDER BY r.created_at DESC"""
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/forum/reports/{id} — Moderator: resolve
# ------------------------------------------------------------------
@router.patch("/reports/{report_id}")
async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_current_user)):
if not user.get('is_moderator'):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
conn.execute(
"UPDATE forum_reports SET resolved = ? WHERE id = ?",
(data.resolved, report_id)
)
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/forum/members/map
# ------------------------------------------------------------------
@router.get("/members/map")
async def members_map():
with db() as conn:
rows = conn.execute(
"""SELECT SUBSTR(name, 1, INSTR(name || ' ', ' ') - 1) AS vorname,
forum_lat AS lat, forum_lon AS lon
FROM users
WHERE forum_show_location = 1
AND forum_lat IS NOT NULL
AND forum_lon IS NOT NULL"""
).fetchall()
return [dict(r) for r in rows]
# ------------------------------------------------------------------
# PATCH /api/forum/members/location
# ------------------------------------------------------------------
@router.patch("/members/location")
async def set_member_location(data: LocationBody, user=Depends(get_current_user)):
if data.show and data.lat is not None and data.lon is not None:
# Snap to ~1km grid (2 decimal places ≈ 1.1km)
snapped_lat = round(data.lat, 2)
snapped_lon = round(data.lon, 2)
with db() as conn:
conn.execute(
"""UPDATE users SET forum_lat=?, forum_lon=?, forum_show_location=1
WHERE id=?""",
(snapped_lat, snapped_lon, user['id'])
)
return {"ok": True, "lat": snapped_lat, "lon": snapped_lon}
else:
with db() as conn:
conn.execute(
"UPDATE users SET forum_show_location=0 WHERE id=?",
(user['id'],)
)
return {"ok": True, "show": False}
# ------------------------------------------------------------------
# GET /api/forum/search
# ------------------------------------------------------------------
@router.get("/search")
async def search_forum(q: Optional[str] = None, limit: int = 20):
if not q or len(q.strip()) < 2:
return []
term = f'%{q.strip()}%'
with db() as conn:
rows = conn.execute(
"""SELECT t.id, t.kategorie, t.titel, t.antworten, t.created_at,
u.name AS autor_name,
SUBSTR(t.text, 1, 200) AS text_preview
FROM forum_threads t
LEFT JOIN users u ON u.id = t.user_id
WHERE t.is_deleted = 0
AND (t.titel LIKE ? OR t.text LIKE ?)
ORDER BY t.created_at DESC
LIMIT ?""",
(term, term, limit)
).fetchall()
return [dict(r) for r in rows]

148
backend/routes/friends.py Normal file
View file

@ -0,0 +1,148 @@
"""BAN YARO — Freundschaften"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/")
async def list_friends(user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
friends = conn.execute("""
SELECT f.id, f.status, f.created_at,
CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END AS friend_id,
u.name AS friend_name
FROM friendships f
JOIN users u ON u.id = CASE WHEN f.requester_id=? THEN f.addressee_id ELSE f.requester_id END
WHERE (f.requester_id=? OR f.addressee_id=?) AND f.status='accepted'
ORDER BY u.name
""", (uid, uid, uid, uid)).fetchall()
incoming = conn.execute("""
SELECT f.id, f.created_at, u.name AS requester_name, u.id AS requester_id
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
""", (uid,)).fetchall()
outgoing = conn.execute("""
SELECT f.id, f.created_at, u.name AS addressee_name, u.id AS addressee_id
FROM friendships f
JOIN users u ON u.id=f.addressee_id
WHERE f.requester_id=? AND f.status='pending'
ORDER BY f.created_at DESC
""", (uid,)).fetchall()
return {
"friends": [dict(r) for r in friends],
"incoming": [dict(r) for r in incoming],
"outgoing": [dict(r) for r in outgoing],
}
@router.get("/search")
async def search_users(q: str = "", user=Depends(get_current_user)):
if len(q.strip()) < 2:
return []
uid = user["id"]
with db() as conn:
rows = conn.execute("""
SELECT u.id, u.name
FROM users u
WHERE u.id != ?
AND u.name LIKE ?
AND NOT EXISTS (
SELECT 1 FROM friendships f
WHERE (f.requester_id=? AND f.addressee_id=u.id)
OR (f.requester_id=u.id AND f.addressee_id=?)
)
LIMIT 20
""", (uid, f"%{q.strip()}%", uid, uid)).fetchall()
return [dict(r) for r in rows]
@router.post("/request/{target_id}", status_code=201)
async def send_request(target_id: int, user=Depends(get_current_user)):
uid = user["id"]
if uid == target_id:
raise HTTPException(400, "Du kannst dich nicht selbst als Freund hinzufügen.")
with db() as conn:
if not conn.execute("SELECT 1 FROM users WHERE id=?", (target_id,)).fetchone():
raise HTTPException(404, "Nutzer nicht gefunden.")
existing = conn.execute("""
SELECT id, status FROM friendships
WHERE (requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?)
""", (uid, target_id, target_id, uid)).fetchone()
if existing:
if existing["status"] == "accepted":
raise HTTPException(400, "Ihr seid bereits befreundet.")
raise HTTPException(400, "Anfrage bereits vorhanden.")
conn.execute(
"INSERT INTO friendships (requester_id, addressee_id) VALUES (?,?)",
(uid, target_id)
)
try:
from routes.push import send_push_to_user
send_push_to_user(target_id, {
"title": "Neue Freundschaftsanfrage",
"body": f"{user['name']} möchte dein Freund sein.",
"type": "friend_request",
"data": {"page": "friends"},
})
except Exception:
pass
return {"ok": True}
@router.post("/{friendship_id}/accept")
async def accept_request(friendship_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
f = conn.execute(
"SELECT * FROM friendships WHERE id=? AND addressee_id=? AND status='pending'",
(friendship_id, uid)
).fetchone()
if not f:
raise HTTPException(404, "Anfrage nicht gefunden.")
conn.execute(
"UPDATE friendships SET status='accepted', updated_at=datetime('now') WHERE id=?",
(friendship_id,)
)
return {"ok": True}
@router.post("/{friendship_id}/decline")
async def decline_request(friendship_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
f = conn.execute("""
SELECT id FROM friendships
WHERE id=? AND (addressee_id=? OR requester_id=?) AND status='pending'
""", (friendship_id, uid, uid)).fetchone()
if not f:
raise HTTPException(404, "Anfrage nicht gefunden.")
conn.execute("DELETE FROM friendships WHERE id=?", (friendship_id,))
return {"ok": True}
@router.delete("/{friend_user_id}")
async def remove_friend(friend_user_id: int, user=Depends(get_current_user)):
uid = user["id"]
with db() as conn:
conn.execute("""
DELETE FROM friendships
WHERE status='accepted'
AND ((requester_id=? AND addressee_id=?) OR (requester_id=? AND addressee_id=?))
""", (uid, friend_user_id, friend_user_id, uid))
return {"ok": True}

113
backend/routes/knigge.py Normal file
View file

@ -0,0 +1,113 @@
"""BAN YARO — Hunde-Knigge Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class VoteRequest(BaseModel):
szenario_id: str
answer: str
class KiRatRequest(BaseModel):
situation: str
# ------------------------------------------------------------------
# POST /api/knigge/vote — Stimme abgeben oder ändern (Auth required)
# ------------------------------------------------------------------
@router.post("/vote")
async def vote(data: VoteRequest, user=Depends(get_current_user)):
if not data.szenario_id or not data.answer:
raise HTTPException(400, "szenario_id und answer sind erforderlich.")
with db() as conn:
# Upsert: vorhandene Stimme ersetzen oder neu anlegen
conn.execute(
"""INSERT INTO knigge_votes (szenario_id, user_id, answer)
VALUES (?, ?, ?)
ON CONFLICT(szenario_id, user_id) DO UPDATE SET answer=excluded.answer""",
(data.szenario_id, user["id"], data.answer),
)
rows = conn.execute(
"""SELECT answer, COUNT(*) as cnt
FROM knigge_votes
WHERE szenario_id=?
GROUP BY answer""",
(data.szenario_id,),
).fetchall()
counts = {r["answer"]: r["cnt"] for r in rows}
return {"counts": counts, "user_answer": data.answer}
# ------------------------------------------------------------------
# GET /api/knigge/votes?szenario_id= — Stimmen abrufen (kein Auth nötig)
# ------------------------------------------------------------------
@router.get("/votes")
async def get_votes(
szenario_id: str = Query(...),
user=Depends(get_current_user_optional),
):
with db() as conn:
rows = conn.execute(
"""SELECT answer, COUNT(*) as cnt
FROM knigge_votes
WHERE szenario_id=?
GROUP BY answer""",
(szenario_id,),
).fetchall()
user_answer = None
if user:
row = conn.execute(
"SELECT answer FROM knigge_votes WHERE szenario_id=? AND user_id=?",
(szenario_id, user["id"]),
).fetchone()
if row:
user_answer = row["answer"]
counts = {r["answer"]: r["cnt"] for r in rows}
return {"counts": counts, "user_answer": user_answer}
# ------------------------------------------------------------------
# POST /api/knigge/ki-rat — KI-Situationsberater (Auth required)
# ------------------------------------------------------------------
@router.post("/ki-rat")
async def ki_rat(data: KiRatRequest, user=Depends(get_current_user)):
from ki import complete, KIUnavailableError, KIPremiumRequired
if not data.situation or not data.situation.strip():
raise HTTPException(400, "Situation darf nicht leer sein.")
system = (
"Du bist ein erfahrener Hundeexperte und Hundetrainer. "
"Deine Aufgabe ist es, Hundebesitzern kurze, praktische Ratschläge zu geben. "
"Antworte immer auf Deutsch, freundlich und verständlich."
)
prompt = (
f"Situation: {data.situation.strip()}\n\n"
"Gib einen kurzen, praktischen Rat (maximal 3 Sätze) was der Hundebesitzer tun sollte."
)
try:
rat = await complete(
prompt,
system=system,
max_tokens=300,
requires_premium=False,
user_is_premium=bool(user.get("is_premium")),
)
return {"rat": rat}
except KIPremiumRequired as e:
raise HTTPException(402, str(e))
except KIUnavailableError as e:
raise HTTPException(503, str(e))

171
backend/routes/lost.py Normal file
View file

@ -0,0 +1,171 @@
"""BAN YARO — Verlorener Hund Routes"""
import os, uuid, math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_all
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
# ------------------------------------------------------------------
# Haversine-Distanz in Metern
# ------------------------------------------------------------------
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6_371_000
p1 = math.radians(lat1)
p2 = math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class LostDogCreate(BaseModel):
name: str
rasse: Optional[str] = None
beschreibung: str
lat: float
lon: float
dog_id: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/lost — aktive Meldungen (optional nach Distanz gefiltert)
# ------------------------------------------------------------------
@router.get("")
async def list_lost(lat: Optional[float] = None, lon: Optional[float] = None,
radius_km: float = 25):
with db() as conn:
rows = conn.execute(
"""SELECT l.*, u.name AS melder_name
FROM lost_dogs l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.is_active = 1
ORDER BY l.created_at DESC"""
).fetchall()
results = []
for r in rows:
entry = dict(r)
if lat is not None and lon is not None:
dist = _haversine(lat, lon, entry["lat"], entry["lon"])
if dist > radius_km * 1000:
continue
entry["distanz_m"] = round(dist)
results.append(entry)
if lat is not None and lon is not None:
results.sort(key=lambda x: x.get("distanz_m", 0))
return results
# ------------------------------------------------------------------
# POST /api/lost — Hund vermisst melden (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def report_lost(data: LostDogCreate, user=Depends(get_current_user)):
with db() as conn:
conn.execute(
"""INSERT INTO lost_dogs (user_id, dog_id, name, rasse, beschreibung, lat, lon)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.name, data.rasse,
data.beschreibung, data.lat, data.lon)
)
row = conn.execute(
"SELECT * FROM lost_dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],)
).fetchone()
entry = dict(row)
send_push_to_all({
"type": "lost_dog_alert",
"title": f"🔍 {data.name} wird vermisst!",
"body": f"{data.rasse or 'Hund'} in deiner Nähe vermisst. Hilf bei der Suche!",
"tag": f"lost-{entry['id']}",
"data": {"page": "lost"},
})
return entry
# ------------------------------------------------------------------
# POST /api/lost/{id}/foto — Foto hochladen (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/foto")
async def upload_foto(
lost_id: int,
file: UploadFile = File(...),
user=Depends(get_current_user),
):
with db() as conn:
entry = conn.execute(
"SELECT id FROM lost_dogs WHERE id=? AND user_id=?",
(lost_id, user["id"])
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden oder keine Berechtigung.")
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"lost_{lost_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "lost", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(await file.read())
foto_url = f"/media/lost/{filename}"
with db() as conn:
conn.execute("UPDATE lost_dogs SET foto_url=? WHERE id=?", (foto_url, lost_id))
return {"foto_url": foto_url}
# ------------------------------------------------------------------
# POST /api/lost/{id}/found — als gefunden markieren (Login, eigene Meldung)
# ------------------------------------------------------------------
@router.post("/{lost_id}/found")
async def mark_found(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute(
"""UPDATE lost_dogs
SET is_active=0, gefunden_at=datetime('now')
WHERE id=?""",
(lost_id,)
)
return {"ok": True}
# ------------------------------------------------------------------
# DELETE /api/lost/{id} — eigene Meldung löschen (Login)
# ------------------------------------------------------------------
@router.delete("/{lost_id}", status_code=204)
async def delete_lost(lost_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM lost_dogs WHERE id=?", (lost_id,)
).fetchone()
if not entry:
raise HTTPException(404, "Meldung nicht gefunden.")
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
conn.execute("DELETE FROM lost_dogs WHERE id=?", (lost_id,))
return None

185
backend/routes/movies.py Normal file
View file

@ -0,0 +1,185 @@
"""BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from database import db
from auth import get_current_user, get_current_user_optional
router = APIRouter()
# ------------------------------------------------------------------
# Hardcoded Film-Daten
# ------------------------------------------------------------------
FILME = [
{"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2},
{"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0},
{"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5},
{"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8},
{"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3},
{"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8},
{"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1},
{"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0},
{"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5},
{"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6},
{"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4},
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3},
]
PROMIS = [
{"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
{"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
{"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"},
{"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
{"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
{"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": ""},
]
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class FilmVoteRequest(BaseModel):
bewertung: int # 15
class HundDesMonatsVoteRequest(BaseModel):
dog_id: int
# ------------------------------------------------------------------
# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung
# ------------------------------------------------------------------
@router.get("/filme")
async def get_filme(user=Depends(get_current_user_optional)):
user_ratings = {}
community_avgs = {}
with db() as conn:
if user:
rows = conn.execute(
"SELECT film_id, bewertung FROM movie_votes WHERE user_id=?",
(user["id"],),
).fetchall()
user_ratings = {r["film_id"]: r["bewertung"] for r in rows}
avg_rows = conn.execute(
"SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id"
).fetchall()
community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows}
result = []
for film in FILME:
f = dict(film)
f["user_rating"] = user_ratings.get(film["id"])
if film["id"] in community_avgs:
f["bewertung_avg"] = community_avgs[film["id"]]["avg"]
f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"]
else:
f["bewertung_cnt"] = 0
result.append(f)
return result
# ------------------------------------------------------------------
# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert)
# ------------------------------------------------------------------
@router.post("/filme/{film_id}/vote")
async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)):
if not any(f["id"] == film_id for f in FILME):
raise HTTPException(404, "Film nicht gefunden.")
if data.bewertung < 1 or data.bewertung > 5:
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
with db() as conn:
conn.execute(
"""INSERT INTO movie_votes (user_id, film_id, bewertung)
VALUES (?, ?, ?)
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""",
(user["id"], film_id, data.bewertung),
)
row = conn.execute(
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
(film_id,),
).fetchone()
return {
"film_id": film_id,
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
"bewertung_cnt": row["cnt"],
"user_rating": data.bewertung,
}
# ------------------------------------------------------------------
# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats
# ------------------------------------------------------------------
@router.get("/hund-des-monats")
async def get_hund_des_monats(user=Depends(get_current_user_optional)):
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
rows = conn.execute(
"""SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
COUNT(v.id) as stimmen
FROM hund_des_monats_votes v
JOIN dogs d ON d.id = v.dog_id
JOIN users u ON u.id = d.user_id
WHERE v.monat = ?
GROUP BY v.dog_id
ORDER BY stimmen DESC
LIMIT 10""",
(monat,),
).fetchall()
user_vote = None
if user:
row = conn.execute(
"SELECT dog_id FROM hund_des_monats_votes WHERE user_id=? AND monat=?",
(user["id"], monat),
).fetchone()
if row:
user_vote = row["dog_id"]
return {
"monat": monat,
"top": [dict(r) for r in rows],
"user_vote": user_vote,
}
# ------------------------------------------------------------------
# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required)
# ------------------------------------------------------------------
@router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
# Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist
dog = conn.execute(
"SELECT id, user_id, is_public FROM dogs WHERE id=?",
(data.dog_id,),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
if dog["user_id"] != user["id"] and not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
conn.execute(
"""INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
VALUES (?, ?, ?)
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""",
(user["id"], data.dog_id, monat),
)
# Aktuelle Stimmenanzahl für den gewählten Hund
row = conn.execute(
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
(data.dog_id, monat),
).fetchone()
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}

258
backend/routes/wiki.py Normal file
View file

@ -0,0 +1,258 @@
"""BAN YARO — Hunde-Wiki Routes"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from database import db
from auth import get_current_user
router = APIRouter()
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class BerichtCreate(BaseModel):
rasse: str
titel: str
text: str
# ------------------------------------------------------------------
# Hilfsfunktion Quiz-Scoring
# ------------------------------------------------------------------
def _quiz_score(rasse: dict, params: dict) -> int:
score = 0
if params.get("groesse") and rasse["groesse"] == params["groesse"]:
score += 2
# Aktivität: exakt = 2, eine Stufe daneben = 1
aktiv_map = {"niedrig": 0, "mittel": 1, "hoch": 2, "sehr_hoch": 3}
if params.get("aktivitaet"):
a_user = aktiv_map.get(params["aktivitaet"], -1)
a_rasse = aktiv_map.get(rasse["aktivitaet"], -1)
diff = abs(a_user - a_rasse)
if diff == 0:
score += 2
elif diff == 1:
score += 1
# Erfahrung: anfaenger bekommt Bonus für einfache Rassen
erf_map = {"anfaenger": 0, "fortgeschritten": 1, "experte": 2}
if params.get("erfahrung"):
e_user = erf_map.get(params["erfahrung"], -1)
e_rasse = erf_map.get(rasse["erfahrung"], -1)
if e_user >= e_rasse:
score += 2
elif e_user == e_rasse - 1:
score += 1
# Kinder
if params.get("kinder") in ("true", "True", "1"):
if rasse["kinder_geeignet"]:
score += 1
# Wohnung
if params.get("wohnung") in ("true", "True", "1"):
if rasse["wohnung_geeignet"]:
score += 2
elif params.get("wohnung") in ("false", "False", "0"):
if not rasse["wohnung_geeignet"]:
score += 1
return score
# ------------------------------------------------------------------
# GET /api/wiki/stats — Seed-Status
# ------------------------------------------------------------------
@router.get("/stats")
async def get_stats():
with db() as conn:
row = conn.execute("SELECT COUNT(*) as total FROM wiki_rassen").fetchone()
total = row["total"] if row else 0
return {"total_breeds": total, "seeded": total > 0}
# ------------------------------------------------------------------
# GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert)
# ------------------------------------------------------------------
@router.get("/rassen")
async def get_rassen(
search: str = Query(""),
gruppe: str = Query(""),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
conditions = []
args = []
if search:
conditions.append("(LOWER(name) LIKE ? OR LOWER(gruppe) LIKE ? OR LOWER(temperament) LIKE ?)")
like = f"%{search.lower()}%"
args += [like, like, like]
if gruppe:
conditions.append("gruppe = ?")
args.append(gruppe)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
args_paged = args + [limit, offset]
with db() as conn:
rows = conn.execute(f"""
SELECT id, name, gruppe, groesse, aktivitaet, erfahrung,
foto_url, slug, kinder_geeignet, wohnung_geeignet
FROM wiki_rassen
{where}
ORDER BY name ASC
LIMIT ? OFFSET ?
""", args_paged).fetchall()
count_row = conn.execute(f"""
SELECT COUNT(*) as total FROM wiki_rassen {where}
""", args).fetchone()
# Alle Gruppen für Filter-Dropdown
gruppen_rows = conn.execute(
"SELECT DISTINCT gruppe FROM wiki_rassen WHERE gruppe IS NOT NULL ORDER BY gruppe"
).fetchall()
return {
"breeds": [dict(r) for r in rows],
"total": count_row["total"] if count_row else 0,
"gruppen": [r["gruppe"] for r in gruppen_rows],
}
# ------------------------------------------------------------------
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
# ------------------------------------------------------------------
@router.get("/rassen/{rasse_slug}")
async def get_rasse(rasse_slug: str):
with db() as conn:
rasse = conn.execute(
"SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,)
).fetchone()
if not rasse:
raise HTTPException(404, "Rasse nicht gefunden.")
rows = conn.execute(
"""SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor
FROM wiki_berichte wb
JOIN users u ON u.id = wb.user_id
WHERE wb.rasse = ?
ORDER BY wb.created_at DESC
LIMIT 50""",
(rasse_slug,),
).fetchall()
result = dict(rasse)
result["berichte"] = [dict(r) for r in rows]
return result
# ------------------------------------------------------------------
# POST /api/wiki/berichte — Community-Bericht hinzufügen
# ------------------------------------------------------------------
@router.post("/berichte")
async def create_bericht(data: BerichtCreate, user=Depends(get_current_user)):
# Prüfen ob die Rasse in der DB existiert
with db() as conn:
rasse_row = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug = ?", (data.rasse,)
).fetchone()
if not rasse_row:
raise HTTPException(400, "Ungültige Rasse.")
if not data.titel.strip():
raise HTTPException(400, "Titel darf nicht leer sein.")
if not data.text.strip():
raise HTTPException(400, "Text darf nicht leer sein.")
with db() as conn:
cur = conn.execute(
"""INSERT INTO wiki_berichte (user_id, rasse, titel, text)
VALUES (?, ?, ?, ?)""",
(user["id"], data.rasse, data.titel.strip(), data.text.strip()),
)
row = conn.execute(
"SELECT wb.id, wb.titel, wb.text, wb.created_at, u.name as autor "
"FROM wiki_berichte wb JOIN users u ON u.id = wb.user_id "
"WHERE wb.id = ?",
(cur.lastrowid,),
).fetchone()
return dict(row)
# ------------------------------------------------------------------
# DELETE /api/wiki/berichte/{id} — Bericht löschen (nur eigene)
# ------------------------------------------------------------------
@router.delete("/berichte/{bericht_id}")
async def delete_bericht(bericht_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id, user_id FROM wiki_berichte WHERE id = ?",
(bericht_id,),
).fetchone()
if not row:
raise HTTPException(404, "Bericht nicht gefunden.")
if row["user_id"] != user["id"]:
raise HTTPException(403, "Nicht erlaubt.")
conn.execute("DELETE FROM wiki_berichte WHERE id = ?", (bericht_id,))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/wiki/quiz/result — Quiz-Ergebnis berechnen
# ------------------------------------------------------------------
@router.get("/quiz/result")
async def quiz_result(
groesse: str = Query(""),
aktivitaet: str = Query(""),
erfahrung: str = Query(""),
kinder: str = Query(""),
wohnung: str = Query(""),
):
params = {
"groesse": groesse,
"aktivitaet": aktivitaet,
"erfahrung": erfahrung,
"kinder": kinder,
"wohnung": wohnung,
}
with db() as conn:
rows = conn.execute(
"""SELECT id, name, gruppe, groesse, aktivitaet, erfahrung,
foto_url, slug, kinder_geeignet, wohnung_geeignet,
temperament, bred_for
FROM wiki_rassen
ORDER BY name ASC"""
).fetchall()
rassen = [dict(r) for r in rows]
if not rassen:
return {"results": []}
scored = sorted(
rassen,
key=lambda r: _quiz_score(r, params),
reverse=True,
)
top3 = [
{
"slug": r["slug"],
"name": r["name"],
"gruppe": r["gruppe"],
"groesse": r["groesse"],
"aktivitaet": r["aktivitaet"],
"erfahrung": r["erfahrung"],
"foto_url": r["foto_url"],
"kinder_geeignet": r["kinder_geeignet"],
"wohnung_geeignet":r["wohnung_geeignet"],
"temperament": r["temperament"],
"score": _quiz_score(r, params),
}
for r in scored[:3]
]
return {"results": top3}

View file

@ -4,12 +4,13 @@ Täglich: Gesundheits-Erinnerungen per Push versenden.
"""
import logging
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from database import db
from routes.push import send_push_to_user
from routes.push import send_push_to_user, send_push_to_all
import weather
logger = logging.getLogger(__name__)
@ -31,8 +32,53 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.add_job(
_job_weather_alert,
CronTrigger(hour=7, minute=30), # täglich 07:30 Uhr
id="weather_alert",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.add_job(
_job_milestone_check,
CronTrigger(hour=0, minute=5), # täglich 00:05 Uhr
id="milestone_check",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.add_job(
_job_import_events,
CronTrigger(day_of_week='sun', hour=2), # jeden Sonntag 02:00 Uhr
id="import_events",
replace_existing=True,
misfire_grace_time=7200,
)
# Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung
_scheduler.add_job(
_job_import_events,
'date',
run_date=datetime.now() + timedelta(seconds=10),
id="import_events_startup",
replace_existing=True,
)
# Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen
_scheduler.add_job(
_job_seed_breeds,
'date',
run_date=datetime.now() + timedelta(seconds=15),
id="seed_breeds_startup",
replace_existing=True,
)
# Einmalig beim Start (nach 45s Verzögerung) — fehlende Rassen aus Wikidata ergänzen
_scheduler.add_job(
_job_seed_wikidata_breeds,
'date',
run_date=datetime.now() + timedelta(seconds=45),
id="seed_wikidata_startup",
replace_existing=True,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00.")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.")
def stop():
@ -122,3 +168,254 @@ async def _job_poison_archive():
count = result.rowcount
if count:
logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.")
# ------------------------------------------------------------------
# JOB: Wetter-Alarm (Hitzepfoten / Gewitter)
# ------------------------------------------------------------------
async def _job_weather_alert():
"""
Holt Tagesprognose für mehrere deutsche Städte.
Sendet Push-Notification wenn:
- Temperatur >= 28°C (Asphalt-Warnung für Pfoten)
- Gewitter wahrscheinlich
Hitze hat Vorrang: Bei Hitze wird kein Gewitter-Push mehr gesendet.
"""
logger.info("Wetter-Alert Job läuft")
try:
summary = await weather.get_weather_summary()
except Exception as e:
logger.error(f"Wetter-Alert: Fehler beim Abruf: {e}")
return
max_temp = summary["max_temp_c"]
thunderstorm = summary["thunderstorm"]
if max_temp >= 28:
sent = send_push_to_all({
"type": "weather_heat",
"title": "☀️ Heißer Asphalt heute",
"body": f"Bis {max_temp:.0f}°C heute — Asphalt kann über 50°C heiß werden. Frühmorgens oder abends gassi gehen!",
"data": {"tag": "weather-heat"},
})
logger.info(f"Wetter-Alert Hitze: {max_temp:.1f}°C — {sent} Push gesendet.")
return # Kein Gewitter-Push mehr nötig wenn Hitze bereits gemeldet
if thunderstorm:
sent = send_push_to_all({
"type": "weather_thunder",
"title": "⛈️ Gewitter möglich",
"body": "Heute Gewitter wahrscheinlich. Gassi-Tour früh einplanen und Hund beruhigen.",
"data": {"tag": "weather-thunder"},
})
logger.info(f"Wetter-Alert Gewitter — {sent} Push gesendet.")
return
logger.info("Wetter-Alert: Keine Warnung nötig heute.")
# ------------------------------------------------------------------
# JOB: Geburtstags- und Monats-Meilensteine
# Läuft täglich um 00:05 Uhr (Europe/Berlin).
# Prüft alle Hunde mit gesetztem Geburtstag und erstellt bei Treffern
# einen Tagebucheintrag (is_milestone=1) + Push-Notification.
# ------------------------------------------------------------------
async def _job_milestone_check():
"""
Prüft für jeden Hund mit bekanntem Geburtstag ob heute ein
Meilenstein-Tag ist:
- Jahrestag (1. Geburtstag, 2. Geburtstag, )
- Monatsjubiläum in den ersten 12 Monaten (1 Monat, 2 Monate, , 11 Monate)
Doppelt-Schutz: Wenn bereits ein Meilenstein-Eintrag mit demselben
Titel für heute existiert, wird kein zweiter erstellt.
"""
today = date.today()
logger.info(f"Meilenstein-Check läuft für {today}")
with db() as conn:
dogs = conn.execute("""
SELECT d.id, d.name, d.user_id, d.geburtstag
FROM dogs d
WHERE d.geburtstag IS NOT NULL
AND d.geburtstag != ''
""").fetchall()
created_total = 0
for dog in dogs:
try:
bday = date.fromisoformat(dog["geburtstag"])
except ValueError:
logger.warning(f"Meilenstein: ungültiges Geburtstag für Hund {dog['id']}: {dog['geburtstag']!r}")
continue
milestone = _compute_milestone(today, bday, dog["name"])
if milestone is None:
continue
titel, text = milestone
with db() as conn:
# Doppelt-Schutz: kein zweiter Eintrag am selben Tag mit gleichem Titel
exists = conn.execute("""
SELECT id FROM diary
WHERE dog_id = ? AND datum = ? AND titel = ? AND is_milestone = 1
""", (dog["id"], str(today), titel)).fetchone()
if exists:
logger.info(f"Meilenstein bereits vorhanden: Hund {dog['id']} '{titel}'")
continue
# Tagebucheintrag anlegen
cur = conn.execute("""
INSERT INTO diary (dog_id, datum, typ, titel, text, is_milestone)
VALUES (?, ?, 'milestone', ?, ?, 1)
""", (dog["id"], str(today), titel, text))
entry_id = cur.lastrowid
# Junction-Tabelle befüllen
conn.execute("""
INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?, ?)
""", (entry_id, dog["id"]))
# Push an Besitzer
send_push_to_user(dog["user_id"], {
"type": "milestone",
"title": titel,
"body": text,
"data": {"page": "diary"},
"tag": f"milestone-{dog['id']}-{today}",
})
logger.info(f"Meilenstein erstellt: Hund {dog['id']} '{titel}' → diary_id={entry_id}")
created_total += 1
logger.info(f"Meilenstein-Check fertig — {created_total} Einträge erstellt.")
# ------------------------------------------------------------------
# JOB: VDH-Events importieren
# ------------------------------------------------------------------
async def _job_import_events():
"""
Scrapt Veranstaltungen von vdh.de und importiert neue Events in die DB.
Bereits vorhandene external_ids werden übersprungen (Upsert-Logik).
"""
try:
from scraper.events_vdh import fetch_vdh_events
except ImportError as e:
logger.error(f"Event-Import: Scraper konnte nicht geladen werden: {e}")
return
try:
events = await fetch_vdh_events()
except Exception as e:
logger.error(f"Event-Import: Fehler beim Scrapen: {e}")
return
imported = 0
with db() as conn:
for ev in events:
try:
exists = conn.execute(
"SELECT id FROM events WHERE external_id = ?",
(ev['external_id'],)
).fetchone()
if not exists:
conn.execute("""
INSERT INTO events (user_id, titel, datum, ort_name, typ, link, quelle, external_id, status)
VALUES (0, ?, ?, ?, ?, ?, 'vdh', ?, 'aktiv')
""", (
ev['titel'],
ev['datum'],
ev.get('ort_name'),
ev['typ'],
ev.get('link'),
ev['external_id'],
))
imported += 1
except Exception as e:
logger.warning(f"Event-Import: Fehler beim Speichern von '{ev.get('titel')}': {e}")
logger.info(f"Event-Import: {imported} neue Events importiert (von {len(events)} geparsten).")
# ------------------------------------------------------------------
# JOB: Rassen aus TheDogAPI seeden
# ------------------------------------------------------------------
async def _job_seed_breeds():
"""Lädt alle Hunderassen von TheDogAPI und speichert sie in wiki_rassen."""
try:
from scraper.breeds import fetch_and_seed_breeds, mirror_breed_photos
except ImportError as e:
logger.error(f"Breed-Seed: Scraper konnte nicht geladen werden: {e}")
return
try:
count = await fetch_and_seed_breeds()
logger.info(f"Breed seed job done: {count} breeds")
mirrored = await mirror_breed_photos()
logger.info(f"Breed photo mirror done: {mirrored} photos")
except Exception as e:
logger.error(f"Breed-Seed: Fehler: {e}")
# ------------------------------------------------------------------
# JOB: Fehlende Rassen aus Wikidata ergänzen
# ------------------------------------------------------------------
async def _job_seed_wikidata_breeds():
"""Lädt fehlende Hunderassen von Wikidata und spiegelt Fotos lokal."""
try:
from scraper.wikidata_breeds import fetch_and_seed_wikidata_breeds, mirror_wikidata_photos
except ImportError as e:
logger.error(f"Wikidata-Seed: Scraper konnte nicht geladen werden: {e}")
return
try:
count = await fetch_and_seed_wikidata_breeds()
logger.info(f"Wikidata breed seed done: {count} neue Rassen")
mirrored = await mirror_wikidata_photos()
logger.info(f"Wikidata photo mirror done: {mirrored} Fotos")
except Exception as e:
logger.error(f"Wikidata-Seed: Fehler: {e}")
def _compute_milestone(today: date, bday: date, dog_name: str):
"""
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
sonst None.
Regeln:
- Jahrestag (Monat + Tag stimmen überein, Jahrgang 1):
"🎂 <Name> ist X Jahr(e) alt!"
- Monatsjubiläum in den ersten 11 Monaten (Geburtsmonats-Tag):
"🐾 <Name> ist heute X Monat(e) alt!"
"""
# Jahrestag?
if today.month == bday.month and today.day == bday.day:
years = today.year - bday.year
if years <= 0:
return None # Geburtstag selbst (Tag 0) → kein Eintrag
years_label = f"{years} Jahr" if years == 1 else f"{years} Jahre"
titel = f"🎂 {dog_name} ist {years_label} alt!"
text = (
f"Heute feiern wir {dog_name}s {years}. Geburtstag! 🐾🎉 "
f"Herzlichen Glückwunsch zum {years_label}!"
)
return titel, text
# Monatsjubiläum (nur innerhalb des ersten Lebensjahres)?
# today liegt im selben Monatstag wie der Geburtstag aber in einem anderen Monat.
if today.day == bday.day:
# Vollständige Monate seit Geburt berechnen
months = (today.year - bday.year) * 12 + (today.month - bday.month)
if 1 <= months <= 11:
months_label = f"{months} Monat" if months == 1 else f"{months} Monate"
titel = f"🐾 {dog_name} ist heute {months_label} alt!"
text = (
f"{dog_name} wird heute {months_label} alt — "
f"was für ein tolles kleines Hundeleben! 🥳"
)
return titel, text
return None

View file

138
backend/scraper/breeds.py Normal file
View file

@ -0,0 +1,138 @@
"""Fetches breed data from TheDogAPI and seeds the wiki_rassen table."""
import httpx, re, logging, os
from database import db
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds")
logger = logging.getLogger(__name__)
def _slug(name: str) -> str:
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
def _derive_groesse(weight_max_kg: float) -> str:
if weight_max_kg <= 10: return 'klein'
if weight_max_kg <= 25: return 'mittel'
if weight_max_kg <= 40: return 'gross'
return 'sehr_gross'
def _derive_aktivitaet(bred_for: str, temperament: str, group: str) -> str:
text = f"{bred_for or ''} {temperament or ''} {group or ''}".lower()
high_keywords = ['herding', 'hunting', 'sporting', 'working', 'energetic', 'active', 'agile']
low_keywords = ['companion', 'toy', 'lap', 'gentle', 'calm', 'quiet']
if any(k in text for k in high_keywords): return 'hoch'
if any(k in text for k in low_keywords): return 'niedrig'
return 'mittel'
def _derive_erfahrung(temperament: str, group: str) -> str:
text = f"{temperament or ''} {group or ''}".lower()
expert = ['stubborn', 'independent', 'dominant', 'terrier', 'herding']
advanced = ['protective', 'reserved', 'working', 'guard']
if any(k in text for k in expert): return 'fortgeschritten'
if any(k in text for k in advanced): return 'fortgeschritten'
return 'anfaenger'
def _derive_kinder(temperament: str) -> int:
if not temperament: return 1
bad = ['aggressive', 'aloof', 'reserved with strangers']
return 0 if any(k in temperament.lower() for k in bad) else 1
def _parse_weight_kg(weight_metric: str):
"""Parse '7 - 14' or '14' -> (min, max) in kg"""
try:
parts = [p.strip() for p in weight_metric.replace(',', '.').split('-')]
nums = [float(p) for p in parts if p]
if len(nums) >= 2: return nums[0], nums[1]
if len(nums) == 1: return nums[0], nums[0]
except Exception: pass
return None, None
async def mirror_breed_photos():
"""Download CDN breed photos to local storage and update foto_url in DB."""
os.makedirs(BREEDS_DIR, exist_ok=True)
with db() as conn:
rows = conn.execute(
"SELECT id, external_id, foto_url FROM wiki_rassen WHERE foto_url LIKE 'http%' AND foto_url NOT LIKE '/media/%'"
).fetchall()
if not rows:
logger.info("Breed photos: nothing to mirror")
return 0
mirrored = 0
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
for row_id, ext_id, cdn_url in rows:
local_path = os.path.join(BREEDS_DIR, f"{ext_id}.jpg")
local_url = f"/media/breeds/{ext_id}.jpg"
# Skip if already downloaded
if os.path.exists(local_path):
with db() as conn:
conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?", (local_url, row_id))
mirrored += 1
continue
try:
r = await client.get(cdn_url)
if r.status_code == 200:
with open(local_path, "wb") as f:
f.write(r.content)
with db() as conn:
conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?", (local_url, row_id))
mirrored += 1
else:
logger.warning(f"Breed photo {ext_id}: HTTP {r.status_code}")
except Exception as e:
logger.warning(f"Breed photo {ext_id} download failed: {e}")
logger.info(f"Breed photos mirrored: {mirrored}/{len(rows)}")
return mirrored
async def fetch_and_seed_breeds():
"""Fetch all breeds from TheDogAPI and upsert into wiki_rassen."""
api_key = os.getenv("THEDOGAPI_KEY", "")
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get('https://api.thedogapi.com/v1/breeds',
headers={'x-api-key': api_key})
r.raise_for_status()
breeds = r.json()
except Exception as e:
logger.error(f"TheDogAPI fetch failed: {e}")
return 0
seeded = 0
with db() as conn:
for b in breeds:
try:
w_min, w_max = _parse_weight_kg(b.get('weight', {}).get('metric', '') or '')
groesse = _derive_groesse(w_max or 20)
aktivitaet = _derive_aktivitaet(b.get('bred_for',''), b.get('temperament',''), b.get('breed_group',''))
erfahrung = _derive_erfahrung(b.get('temperament',''), b.get('breed_group',''))
kinder = _derive_kinder(b.get('temperament',''))
wohnung = 1 if groesse == 'klein' and aktivitaet in ('niedrig','mittel') else 0
foto_url = b.get('image', {}).get('url') or None
slug = _slug(b['name'])
conn.execute("""
INSERT INTO wiki_rassen
(external_id, name, gruppe, herkunft, temperament,
gewicht_min_kg, gewicht_max_kg, groesse, lebensdauer,
foto_url, bred_for, aktivitaet, wohnung_geeignet,
kinder_geeignet, erfahrung, slug)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(external_id) DO UPDATE SET
foto_url=excluded.foto_url,
temperament=excluded.temperament
""", (
b['id'], b['name'],
b.get('breed_group'), b.get('origin'), b.get('temperament'),
w_min, w_max, groesse, b.get('life_span'),
foto_url, b.get('bred_for'), aktivitaet, wohnung, kinder, erfahrung, slug
))
seeded += 1
except Exception as e:
logger.warning(f"Breed {b.get('name')} seed failed: {e}")
logger.info(f"Breeds seeded: {seeded}")
return seeded

View file

@ -0,0 +1,317 @@
"""
BAN YARO VDH Veranstaltungs-Scraper
Scrapt Hundeveranstaltungen von vdh.de.
Bei Fehler oder 0 Ergebnissen: Fallback auf hartcodierte Events.
"""
import logging
import re
from datetime import datetime
from html.parser import HTMLParser
import httpx
logger = logging.getLogger(__name__)
FALLBACK_EVENTS = [
{"titel": "VDH-Europasiegershow 2026", "datum": "2026-06-14", "ort_name": "Dortmund", "typ": "ausstellung", "link": "https://www.vdh.de", "external_id": "vdh-fallback-europasieger-2026"},
{"titel": "Internationale Hundeausstellung Frankfurt", "datum": "2026-05-03", "ort_name": "Frankfurt am Main", "typ": "ausstellung", "link": "https://www.vdh.de", "external_id": "vdh-fallback-frankfurt-2026"},
{"titel": "VDH-Bundessiegerprüfung Agility", "datum": "2026-07-19", "ort_name": "Leipzig", "typ": "wettkampf", "link": "https://www.vdh.de", "external_id": "vdh-fallback-agility-2026"},
{"titel": "Rassehundetreffen München", "datum": "2026-08-22", "ort_name": "München", "typ": "treffen", "link": "https://www.vdh.de", "external_id": "vdh-fallback-muenchen-2026"},
{"titel": "Hundesport-Turnier Berlin", "datum": "2026-09-12", "ort_name": "Berlin", "typ": "wettkampf", "link": "https://www.vdh.de", "external_id": "vdh-fallback-berlin-2026"},
]
# Mapping VDH-Kategorienamen → interne Typen
_TYP_MAP = {
"ausstellung": "ausstellung",
"show": "ausstellung",
"siegershow": "ausstellung",
"agility": "wettkampf",
"wettkampf": "wettkampf",
"turnier": "wettkampf",
"prüfung": "wettkampf",
"training": "training",
"treffen": "treffen",
"markt": "markt",
}
# Monatsnamen Deutsch → Zahl
_MONATE = {
"januar": 1, "februar": 2, "märz": 3, "maerz": 3,
"april": 4, "mai": 5, "juni": 6, "juli": 7,
"august": 8, "september": 9, "oktober": 10,
"november": 11, "dezember": 12,
}
def _guess_typ(text: str) -> str:
"""Bestimmt den Event-Typ anhand des Titels."""
t = text.lower()
for keyword, typ in _TYP_MAP.items():
if keyword in t:
return typ
return "sonstiges"
def _parse_date(raw: str) -> str | None:
"""
Versucht verschiedene Datumsformate zu parsen.
Gibt YYYY-MM-DD zurück oder None.
"""
raw = raw.strip()
# ISO: 2026-05-03
m = re.match(r'^(\d{4})-(\d{2})-(\d{2})$', raw)
if m:
return raw
# DD.MM.YYYY oder D.M.YYYY
m = re.match(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})$', raw)
if m:
d, mo, y = m.groups()
return f"{y}-{int(mo):02d}-{int(d):02d}"
# DD. Monatsname YYYY (z.B. "14. Juni 2026")
m = re.match(r'^(\d{1,2})\.\s*(\w+)\s+(\d{4})$', raw)
if m:
d, mon_str, y = m.groups()
mon_num = _MONATE.get(mon_str.lower())
if mon_num:
return f"{y}-{mon_num:02d}-{int(d):02d}"
# Monatsname DD, YYYY (englisch, Fallback)
try:
dt = datetime.strptime(raw, "%B %d, %Y")
return dt.strftime("%Y-%m-%d")
except ValueError:
pass
return None
class _VDHParser(HTMLParser):
"""
Einfacher Zustandsautomat-Parser für die VDH-Veranstaltungsseite.
Sucht nach typischen Strukturen: article, li.event, div mit Datums-/Titel-Klassen.
"""
def __init__(self):
super().__init__()
self._events: list[dict] = []
self._current: dict | None = None
self._depth = 0
self._start_depth = 0
self._capture = None # 'titel' | 'datum' | 'ort'
self._buf = ""
self._in_event = False
# ---------- Hilfsmethoden ----------
def _is_event_container(self, tag, attrs):
"""Erkennt Start eines Event-Blocks."""
a = dict(attrs)
cls = a.get("class", "")
return (
tag == "article"
or (tag in ("li", "div") and any(
kw in cls for kw in ("event", "veranstaltung", "termin", "entry", "item")
))
)
def _is_title_tag(self, tag, attrs):
a = dict(attrs)
cls = a.get("class", "")
return tag in ("h2", "h3", "h4") or any(
kw in cls for kw in ("title", "titel", "name", "heading")
)
def _is_date_tag(self, tag, attrs):
a = dict(attrs)
cls = a.get("class", "")
it = a.get("itemprop", "")
return (
tag in ("time",)
or any(kw in cls for kw in ("date", "datum", "time"))
or it in ("startDate", "endDate")
)
def _is_location_tag(self, tag, attrs):
a = dict(attrs)
cls = a.get("class", "")
it = a.get("itemprop", "")
return (
any(kw in cls for kw in ("location", "ort", "venue", "place", "city"))
or it in ("location", "addressLocality")
)
# ---------- SAX-Events ----------
def handle_starttag(self, tag, attrs):
self._depth += 1
a = dict(attrs)
if not self._in_event and self._is_event_container(tag, attrs):
self._in_event = True
self._start_depth = self._depth
self._current = {"titel": "", "datum": "", "ort_name": "", "link": ""}
# Direkter Link auf dem Container?
if tag == "a" and "href" in a:
self._current["link"] = a["href"]
return
if self._in_event:
# Link innerhalb des Event-Blocks
if tag == "a" and "href" in a and not self._current.get("link"):
href = a["href"]
if "vdh.de" in href or href.startswith("/"):
self._current["link"] = href
# <time datetime="…">
if tag == "time":
dt = a.get("datetime", "")
if dt:
parsed = _parse_date(dt)
if parsed:
self._current["datum"] = parsed
if self._is_title_tag(tag, attrs):
self._capture = "titel"
self._buf = ""
elif self._is_date_tag(tag, attrs) and not self._current.get("datum"):
self._capture = "datum"
self._buf = ""
elif self._is_location_tag(tag, attrs):
self._capture = "ort"
self._buf = ""
def handle_endtag(self, tag):
if self._capture:
val = self._buf.strip()
if self._capture == "titel" and val:
self._current["titel"] = val
elif self._capture == "datum" and val and not self._current.get("datum"):
parsed = _parse_date(val)
if parsed:
self._current["datum"] = parsed
elif self._capture == "ort" and val:
self._current["ort_name"] = val
self._capture = None
self._buf = ""
self._depth -= 1
if self._in_event and self._depth < self._start_depth:
self._in_event = False
ev = self._current
# Nur speichern wenn wir Titel + Datum haben
if ev and ev.get("titel") and ev.get("datum"):
self._events.append(ev)
self._current = None
def handle_data(self, data):
if self._capture:
self._buf += data
def get_events(self) -> list[dict]:
return self._events
def _build_external_id(ev: dict) -> str:
"""Erzeugt einen stabilen Dedup-Key aus Datum + Titel."""
raw = f"vdh-{ev['datum']}-{ev['titel']}"
# Einfache Normalisierung: lowercase, Sonderzeichen raus
key = re.sub(r'[^a-z0-9]+', '-', raw.lower()).strip('-')
return key[:120]
async def fetch_vdh_events() -> list[dict]:
"""
Scrapt VDH-Veranstaltungen und gibt eine Liste von Dicts zurück:
{titel, datum, ort_name, typ, link, external_id}
Bei Fehler oder 0 Ergebnissen: Fallback auf FALLBACK_EVENTS.
"""
urls = [
"https://www.vdh.de/veranstaltungen/ausstellungen/",
"https://www.vdh.de/veranstaltungen/",
]
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "de-DE,de;q=0.9,en;q=0.5",
}
raw_events: list[dict] = []
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
for url in urls:
try:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
html = resp.text
parser = _VDHParser()
parser.feed(html)
found = parser.get_events()
if found:
logger.info(f"VDH-Scraper: {len(found)} Events von {url} geparst.")
raw_events = found
break
else:
logger.info(f"VDH-Scraper: Keine Events auf {url} gefunden, nächste URL versuchen.")
except httpx.HTTPStatusError as e:
logger.warning(f"VDH-Scraper HTTP-Fehler {e.response.status_code} für {url}: {e}")
except httpx.RequestError as e:
logger.warning(f"VDH-Scraper Netzwerkfehler für {url}: {e}")
except Exception as e:
logger.warning(f"VDH-Scraper unbekannter Fehler für {url}: {e}")
if not raw_events:
logger.warning("VDH-Scraper: Keine Daten erhalten — verwende Fallback-Events.")
return list(FALLBACK_EVENTS)
# Normalisieren
today = datetime.today().strftime("%Y-%m-%d")
result = []
seen_ids: set[str] = set()
for ev in raw_events:
datum = ev.get("datum", "")
# Nur zukünftige Events
if datum < today:
continue
titel = ev.get("titel", "").strip()
if not titel or len(titel) < 3:
continue
link = ev.get("link", "")
if link and link.startswith("/"):
link = "https://www.vdh.de" + link
entry = {
"titel": titel,
"datum": datum,
"ort_name": ev.get("ort_name") or None,
"typ": _guess_typ(titel),
"link": link or "https://www.vdh.de",
"external_id": _build_external_id(ev),
}
if entry["external_id"] not in seen_ids:
seen_ids.add(entry["external_id"])
result.append(entry)
if not result:
logger.warning("VDH-Scraper: Nach Filterung 0 zukünftige Events — verwende Fallback-Events.")
return list(FALLBACK_EVENTS)
logger.info(f"VDH-Scraper: {len(result)} zukünftige Events nach Normalisierung.")
return result

View file

@ -0,0 +1,196 @@
"""Fetches missing dog breed data from Wikidata SPARQL and seeds wiki_rassen."""
import httpx, re, logging, os
from database import db
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds")
SPARQL_URL = "https://query.wikidata.org/sparql"
# GROUP BY + SAMPLE so each breed appears once even if it has multiple images
SPARQL_QUERY = """
SELECT ?breed
(SAMPLE(?nameDE) AS ?nameDE)
(SAMPLE(?nameEN) AS ?nameEN)
(SAMPLE(?image) AS ?image)
(SAMPLE(?countryDE) AS ?countryDE)
(SAMPLE(?descDE) AS ?descDE)
(SAMPLE(?descEN) AS ?descEN)
WHERE {
?breed wdt:P31 wd:Q39367 .
OPTIONAL { ?breed rdfs:label ?nameDE FILTER(LANG(?nameDE) = "de") }
OPTIONAL { ?breed rdfs:label ?nameEN FILTER(LANG(?nameEN) = "en") }
FILTER(BOUND(?nameDE) || BOUND(?nameEN))
OPTIONAL { ?breed wdt:P18 ?image }
OPTIONAL {
?breed wdt:P495 ?country .
?country rdfs:label ?countryDE FILTER(LANG(?countryDE) = "de")
}
OPTIONAL { ?breed schema:description ?descDE FILTER(LANG(?descDE) = "de") }
OPTIONAL { ?breed schema:description ?descEN FILTER(LANG(?descEN) = "en") }
}
GROUP BY ?breed
ORDER BY ?nameDE ?nameEN
"""
def _slug(name: str) -> str:
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
def _normalise(name: str) -> str:
"""Lowercase + remove diacritics for name deduplication."""
import unicodedata
nfkd = unicodedata.normalize('NFKD', name.lower())
return re.sub(r'[^a-z0-9 ]', '', nfkd).strip()
async def fetch_and_seed_wikidata_breeds():
"""Query Wikidata for dog breeds and insert only those missing from wiki_rassen."""
# -- fetch from SPARQL -------------------------------------------------
try:
async with httpx.AsyncClient(
timeout=60,
headers={"Accept": "application/sparql-results+json",
"User-Agent": "BanYaro/1.0 (https://banyaro.app; contact@banyaro.app)"}
) as client:
r = await client.get(SPARQL_URL, params={"query": SPARQL_QUERY})
r.raise_for_status()
data = r.json()
except Exception as e:
logger.error(f"Wikidata SPARQL fetch failed: {e}")
return 0
bindings = data.get("results", {}).get("bindings", [])
logger.info(f"Wikidata: {len(bindings)} breed entries received")
# -- load existing names for deduplication -----------------------------
with db() as conn:
existing = conn.execute("SELECT name FROM wiki_rassen").fetchall()
existing_norm = {_normalise(row[0]) for row in existing}
seeded = 0
with db() as conn:
for b in bindings:
name = (b.get("nameDE", {}).get("value") or
b.get("nameEN", {}).get("value") or "").strip()
if not name:
continue
# skip if already in DB (by normalised name)
if _normalise(name) in existing_norm:
continue
qid = b["breed"]["value"].rsplit("/", 1)[-1] # e.g. "Q312440"
ext_id = f"wd_{qid}"
image_url = b.get("image", {}).get("value") or None
herkunft = b.get("countryDE", {}).get("value") or None
desc = (b.get("descDE", {}).get("value") or
b.get("descEN", {}).get("value") or None)
slug_base = _slug(name)
# make slug unique if collision exists
slug = slug_base
suffix = 1
while True:
row = conn.execute(
"SELECT 1 FROM wiki_rassen WHERE slug=? AND external_id != ?",
(slug, ext_id)
).fetchone()
if not row:
break
slug = f"{slug_base}-{suffix}"
suffix += 1
try:
conn.execute("""
INSERT INTO wiki_rassen
(external_id, name, gruppe, herkunft, temperament,
gewicht_min_kg, gewicht_max_kg, groesse, lebensdauer,
foto_url, bred_for, aktivitaet, wohnung_geeignet,
kinder_geeignet, erfahrung, slug)
VALUES (?,?,?,?,?,NULL,NULL,'mittel',NULL,?,NULL,'mittel',0,1,'anfaenger',?)
ON CONFLICT(external_id) DO UPDATE SET
foto_url = CASE
WHEN excluded.foto_url IS NOT NULL AND wiki_rassen.foto_url IS NULL
THEN excluded.foto_url
ELSE wiki_rassen.foto_url
END,
herkunft = COALESCE(wiki_rassen.herkunft, excluded.herkunft),
temperament = COALESCE(wiki_rassen.temperament, excluded.temperament)
""", (ext_id, name, None, herkunft, desc, image_url, slug))
existing_norm.add(_normalise(name)) # avoid re-inserting within same run
seeded += 1
except Exception as e:
logger.warning(f"Wikidata breed '{name}' seed failed: {e}")
logger.info(f"Wikidata breeds seeded: {seeded}")
return seeded
async def mirror_wikidata_photos():
"""Download Wikimedia Commons photos for Wikidata breeds that still have external URLs."""
os.makedirs(BREEDS_DIR, exist_ok=True)
with db() as conn:
rows = conn.execute(
"""SELECT id, external_id, foto_url FROM wiki_rassen
WHERE external_id LIKE 'wd_%'
AND foto_url LIKE 'http%'
AND foto_url NOT LIKE '/media/%'"""
).fetchall()
if not rows:
logger.info("Wikidata photos: nothing to mirror")
return 0
mirrored = 0
import asyncio
async with httpx.AsyncClient(
timeout=30,
follow_redirects=True,
headers={"User-Agent": "BanYaro/1.0 (https://banyaro.app)"}
) as client:
for i, (row_id, ext_id, img_url) in enumerate(rows):
qid = ext_id.replace("wd_", "")
local_path = os.path.join(BREEDS_DIR, f"{qid}.jpg")
local_url = f"/media/breeds/{qid}.jpg"
if os.path.exists(local_path):
with db() as conn:
conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?",
(local_url, row_id))
mirrored += 1
continue
# Wikimedia Commons: append ?width=600 for scaled download
fetch_url = img_url if "?" in img_url else img_url + "?width=600"
retries = 2
for attempt in range(retries):
try:
await asyncio.sleep(0.3) # 300ms zwischen Requests → ~3/s
r = await client.get(fetch_url)
if r.status_code == 200 and r.headers.get("content-type", "").startswith("image"):
with open(local_path, "wb") as f:
f.write(r.content)
with db() as conn:
conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?",
(local_url, row_id))
mirrored += 1
break
elif r.status_code == 429:
wait = 10 * (attempt + 1)
logger.info(f"Rate limited, warte {wait}s…")
await asyncio.sleep(wait)
else:
logger.warning(f"Wikidata photo {qid}: HTTP {r.status_code}")
break
except Exception as e:
logger.warning(f"Wikidata photo {qid} failed: {e}")
break
if i % 50 == 0 and i > 0:
logger.info(f"Wikidata photos: {mirrored}/{i+1} bisher")
logger.info(f"Wikidata photos mirrored: {mirrored}/{len(rows)}")
return mirrored

File diff suppressed because it is too large Load diff

View file

@ -475,3 +475,18 @@
@media (max-width: 400px) {
.grid-3 { grid-template-columns: 1fr 1fr; }
}
/* ============================================================
Phosphor Icons SVG Sprite
============================================================ */
.ph-icon {
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
fill: currentColor;
flex-shrink: 0;
}
.nav-item .ph-icon { width: 24px; height: 24px; }
.nav-item-center .ph-icon { width: 22px; height: 22px; }
.header-menu-btn .ph-icon { width: 22px; height: 22px; }

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="arrow-left" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/></symbol>
<symbol id="arrow-right" viewBox="0 0 256 256"><path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z"/></symbol>
<symbol id="bell" viewBox="0 0 256 256"><path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/></symbol>
<symbol id="book-open" viewBox="0 0 256 256"><path d="M232,48H160a40,40,0,0,0-32,16A40,40,0,0,0,96,48H24a8,8,0,0,0-8,8V200a8,8,0,0,0,8,8H96a24,24,0,0,1,24,24,8,8,0,0,0,16,0,24,24,0,0,1,24-24h72a8,8,0,0,0,8-8V56A8,8,0,0,0,232,48ZM96,192H32V64H96a24,24,0,0,1,24,24V200A39.81,39.81,0,0,0,96,192Zm128,0H160a39.81,39.81,0,0,0-24,8V88a24,24,0,0,1,24-24h64Z"/></symbol>
<symbol id="books" viewBox="0 0 256 256"><path d="M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z"/></symbol>
<symbol id="calendar-dots" viewBox="0 0 256 256"><path d="M208,32H184V24a8,8,0,0,0-16,0v8H88V24a8,8,0,0,0-16,0v8H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM72,48v8a8,8,0,0,0,16,0V48h80v8a8,8,0,0,0,16,0V48h24V80H48V48ZM208,208H48V96H208V208Zm-68-76a12,12,0,1,1-12-12A12,12,0,0,1,140,132Zm44,0a12,12,0,1,1-12-12A12,12,0,0,1,184,132ZM96,172a12,12,0,1,1-12-12A12,12,0,0,1,96,172Zm44,0a12,12,0,1,1-12-12A12,12,0,0,1,140,172Zm44,0a12,12,0,1,1-12-12A12,12,0,0,1,184,172Z"/></symbol>
<symbol id="camera" viewBox="0 0 256 256"><path d="M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z"/></symbol>
<symbol id="caret-down" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z"/></symbol>
<symbol id="caret-up" viewBox="0 0 256 256"><path d="M213.66,165.66a8,8,0,0,1-11.32,0L128,91.31,53.66,165.66a8,8,0,0,1-11.32-11.32l80-80a8,8,0,0,1,11.32,0l80,80A8,8,0,0,1,213.66,165.66Z"/></symbol>
<symbol id="chat-circle-dots" viewBox="0 0 256 256"><path d="M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128ZM84,116a12,12,0,1,0,12,12A12,12,0,0,0,84,116Zm88,0a12,12,0,1,0,12,12A12,12,0,0,0,172,116Zm60,12A104,104,0,0,1,79.12,219.82L45.07,231.17a16,16,0,0,1-20.24-20.24l11.35-34.05A104,104,0,1,1,232,128Zm-16,0A88,88,0,1,0,51.81,172.06a8,8,0,0,1,.66,6.54L40,216,77.4,203.53a7.85,7.85,0,0,1,2.53-.42,8,8,0,0,1,4,1.08A88,88,0,0,0,216,128Z"/></symbol>
<symbol id="check" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/></symbol>
<symbol id="dog" viewBox="0 0 256 256"><path d="M239.71,125l-16.42-88a16,16,0,0,0-19.61-12.58l-.31.09L150.85,40h-45.7L52.63,24.56l-.31-.09A16,16,0,0,0,32.71,37.05L16.29,125a15.77,15.77,0,0,0,9.12,17.52A16.26,16.26,0,0,0,32.12,144,15.48,15.48,0,0,0,40,141.84V184a40,40,0,0,0,40,40h96a40,40,0,0,0,40-40V141.85a15.5,15.5,0,0,0,7.87,2.16,16.31,16.31,0,0,0,6.72-1.47A15.77,15.77,0,0,0,239.71,125ZM32,128h0L48.43,40,90.5,52.37Zm144,80H136V195.31l13.66-13.65a8,8,0,0,0-11.32-11.32L128,180.69l-10.34-10.35a8,8,0,0,0-11.32,11.32L120,195.31V208H80a24,24,0,0,1-24-24V123.11L107.92,56h40.15L200,123.11V184A24,24,0,0,1,176,208Zm48-80L165.5,52.37,207.57,40,224,128ZM104,140a12,12,0,1,1-12-12A12,12,0,0,1,104,140Zm72,0a12,12,0,1,1-12-12A12,12,0,0,1,176,140Z"/></symbol>
<symbol id="eye-slash" viewBox="0 0 256 256"><path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"/></symbol>
<symbol id="eye" viewBox="0 0 256 256"><path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></symbol>
<symbol id="film-slate" viewBox="0 0 256 256"><path d="M216,104H102.09L210,75.51a8,8,0,0,0,5.68-9.84l-8.16-30a15.93,15.93,0,0,0-19.42-11.13L35.81,64.74a15.75,15.75,0,0,0-9.7,7.4,15.51,15.51,0,0,0-1.55,12L32,111.56c0,.14,0,.29,0,.44v88a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V112A8,8,0,0,0,216,104ZM192.16,40l6,22.07-22.62,6L147.42,51.83Zm-66.69,17.6,28.12,16.24-36.94,9.75L88.53,67.37Zm-79.4,44.62-6-22.08,26.5-7L94.69,89.4ZM208,200H48V120H208v80Z"/></symbol>
<symbol id="fire" viewBox="0 0 256 256"><path d="M183.89,153.34a57.6,57.6,0,0,1-46.56,46.55A8.75,8.75,0,0,1,136,200a8,8,0,0,1-1.32-15.89c16.57-2.79,30.63-16.85,33.44-33.45a8,8,0,0,1,15.78,2.68ZM216,144a88,88,0,0,1-176,0c0-27.92,11-56.47,32.66-84.85a8,8,0,0,1,11.93-.89l24.12,23.41,22-60.41a8,8,0,0,1,12.63-3.41C165.21,36,216,84.55,216,144Zm-16,0c0-46.09-35.79-85.92-58.21-106.33L119.52,98.74a8,8,0,0,1-13.09,3L80.06,76.16C64.09,99.21,56,122,56,144a72,72,0,0,0,144,0Z"/></symbol>
<symbol id="gear" viewBox="0 0 256 256"><path d="M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z"/></symbol>
<symbol id="handshake" viewBox="0 0 256 256"><path d="M254.3,107.91,228.78,56.85a16,16,0,0,0-21.47-7.15L182.44,62.13,130.05,48.27a8.14,8.14,0,0,0-4.1,0L73.56,62.13,48.69,49.7a16,16,0,0,0-21.47,7.15L1.7,107.9a16,16,0,0,0,7.15,21.47l27,13.51,55.49,39.63a8.06,8.06,0,0,0,2.71,1.25l64,16a8,8,0,0,0,7.6-2.1l55.07-55.08,26.42-13.21a16,16,0,0,0,7.15-21.46Zm-54.89,33.37L165,113.72a8,8,0,0,0-10.68.61C136.51,132.27,116.66,130,104,122L147.24,80h31.81l27.21,54.41ZM41.53,64,62,74.22,36.43,125.27,16,115.06Zm116,119.13L99.42,168.61l-49.2-35.14,28-56L128,64.28l9.8,2.59-45,43.68-.08.09a16,16,0,0,0,2.72,24.81c20.56,13.13,45.37,11,64.91-5L188,152.66Zm62-57.87-25.52-51L214.47,64,240,115.06Zm-87.75,92.67a8,8,0,0,1-7.75,6.06,8.13,8.13,0,0,1-1.95-.24L80.41,213.33a7.89,7.89,0,0,1-2.71-1.25L51.35,193.26a8,8,0,0,1,9.3-13l25.11,17.94L126,208.24A8,8,0,0,1,131.82,217.94Z"/></symbol>
<symbol id="heart" viewBox="0 0 256 256"><path d="M178,40c-20.65,0-38.73,8.88-50,23.89C116.73,48.88,98.65,40,78,40a62.07,62.07,0,0,0-62,62c0,70,103.79,126.66,108.21,129a8,8,0,0,0,7.58,0C136.21,228.66,240,172,240,102A62.07,62.07,0,0,0,178,40ZM128,214.8C109.74,204.16,32,155.69,32,102A46.06,46.06,0,0,1,78,56c19.45,0,35.78,10.36,42.6,27a8,8,0,0,0,14.8,0c6.82-16.67,23.15-27,42.6-27a46.06,46.06,0,0,1,46,46C224,155.61,146.24,204.15,128,214.8Z"/></symbol>
<symbol id="house-line" viewBox="0 0 256 256"><path d="M240,208H224V136l2.34,2.34A8,8,0,0,0,237.66,127L139.31,28.68a16,16,0,0,0-22.62,0L18.34,127a8,8,0,0,0,11.32,11.31L32,136v72H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM48,120l80-80,80,80v88H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48Zm96,88H112V160h32Z"/></symbol>
<symbol id="image" viewBox="0 0 256 256"><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V158.75l-26.07-26.06a16,16,0,0,0-22.63,0l-20,20-44-44a16,16,0,0,0-22.62,0L40,149.37V56ZM40,172l52-52,80,80H40Zm176,28H194.63l-36-36,20-20L216,181.38V200ZM144,100a12,12,0,1,1,12,12A12,12,0,0,1,144,100Z"/></symbol>
<symbol id="list" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/></symbol>
<symbol id="lock-open" viewBox="0 0 256 256"><path d="M208,80H96V56a32,32,0,0,1,32-32c15.37,0,29.2,11,32.16,25.59a8,8,0,0,0,15.68-3.18C171.32,24.15,151.2,8,128,8A48.05,48.05,0,0,0,80,56V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80Zm0,128H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
<symbol id="magnifying-glass" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></symbol>
<symbol id="map-pin" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/></symbol>
<symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
<symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
<symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol>
<symbol id="pencil-simple" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"/></symbol>
<symbol id="plus" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></symbol>
<symbol id="spinner" viewBox="0 0 256 256"><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm37.25,58.75a8,8,0,0,0,5.66-2.35l22.63-22.62a8,8,0,0,0-11.32-11.32L167.6,77.09a8,8,0,0,0,5.65,13.66ZM224,120H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></symbol>
<symbol id="star" viewBox="0 0 256 256"><path d="M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z"/></symbol>
<symbol id="syringe" viewBox="0 0 256 256"><path d="M237.66,66.34l-48-48a8,8,0,0,0-11.32,11.32L196.69,48,168,76.69,133.66,42.34a8,8,0,0,0-11.32,11.32L128.69,60l-84,84A15.86,15.86,0,0,0,40,155.31v49.38L18.34,226.34a8,8,0,0,0,11.32,11.32L51.31,216h49.38A15.86,15.86,0,0,0,112,211.31l84-84,6.34,6.35a8,8,0,0,0,11.32-11.32L179.31,88,208,59.31l18.34,18.35a8,8,0,0,0,11.32-11.32ZM100.69,200H56V155.31l18-18,20.34,20.35a8,8,0,0,0,11.32-11.32L85.31,126,98,113.31l20.34,20.35a8,8,0,0,0,11.32-11.32L109.31,102,140,71.31,184.69,116Z"/></symbol>
<symbol id="trash" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"/></symbol>
<symbol id="upload" viewBox="0 0 256 256"><path d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,77.66,120,43.31V128a8,8,0,0,0,16,0V43.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,77.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"/></symbol>
<symbol id="user" viewBox="0 0 256 256"><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></symbol>
<symbol id="warning-octagon" viewBox="0 0 256 256"><path d="M120,136V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0ZM232,91.55v72.9a15.86,15.86,0,0,1-4.69,11.31l-51.55,51.55A15.86,15.86,0,0,1,164.45,232H91.55a15.86,15.86,0,0,1-11.31-4.69L28.69,175.76A15.86,15.86,0,0,1,24,164.45V91.55a15.86,15.86,0,0,1,4.69-11.31L80.24,28.69A15.86,15.86,0,0,1,91.55,24h72.9a15.86,15.86,0,0,1,11.31,4.69l51.55,51.55A15.86,15.86,0,0,1,232,91.55Zm-16,0L164.45,40H91.55L40,91.55v72.9L91.55,216h72.9L216,164.45ZM128,160a12,12,0,1,0,12,12A12,12,0,0,0,128,160Z"/></symbol>
<symbol id="warning" viewBox="0 0 256 256"><path d="M236.8,188.09,149.35,36.22h0a24.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.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/></symbol>
<symbol id="x" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></symbol>
<symbol id="info" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/></symbol>
<symbol id="download-simple" viewBox="0 0 256 256"><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></symbol>
<symbol id="floppy-disk" viewBox="0 0 256 256"><path d="M219.31,72,184,36.69A15.86,15.86,0,0,0,172.69,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V83.31A15.86,15.86,0,0,0,219.31,72ZM168,208H88V152h80Zm40,0H184V152a16,16,0,0,0-16-16H88a16,16,0,0,0-16,16v56H48V48H172.69L208,83.31ZM160,72a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h56A8,8,0,0,1,160,72Z"/></symbol>
<symbol id="arrow-square-out" viewBox="0 0 256 256"><path d="M224,104a8,8,0,0,1-16,0V59.32l-66.33,66.34a8,8,0,0,1-11.32-11.32L196.68,48H152a8,8,0,0,1,0-16h64a8,8,0,0,1,8,8Zm-40,24a8,8,0,0,0-8,8v72H48V80h72a8,8,0,0,0,0-16H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V136A8,8,0,0,0,184,128Z"/></symbol>
<symbol id="sign-out" viewBox="0 0 256 256"><path d="M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z"/></symbol>
<symbol id="lock" viewBox="0 0 256 256"><path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
<symbol id="users" viewBox="0 0 256 256"><path d="M117.25,157.92a60,60,0,1,0-66.5,0A95.83,95.83,0,0,0,3.53,195.63a8,8,0,1,0,13.4,8.74,80,80,0,0,1,134.14,0,8,8,0,0,0,13.4-8.74A95.83,95.83,0,0,0,117.25,157.92ZM40,108a44,44,0,1,1,44,44A44.05,44.05,0,0,1,40,108Zm210.14,98.7a8,8,0,0,1-11.07-2.33A79.83,79.83,0,0,0,172,168a8,8,0,0,1,0-16,44,44,0,1,0-16.34-84.87,8,8,0,1,1-5.94-14.85,60,60,0,0,1,55.53,105.64,95.83,95.83,0,0,1,47.22,37.71A8,8,0,0,1,250.14,206.7Z"/></symbol>
<symbol id="push-pin" viewBox="0 0 256 256"><path d="M235.32,81.37,174.63,20.69a16,16,0,0,0-22.63,0L98.37,74.49c-10.66-3.34-35-7.37-60.4,13.14a16,16,0,0,0-1.29,23.78L85,159.71,42.34,202.34a8,8,0,0,0,11.32,11.32L96.29,171l48.29,48.29A16,16,0,0,0,155.9,224c.38,0,.75,0,1.13,0a15.93,15.93,0,0,0,11.64-6.33c19.64-26.1,17.75-47.32,13.19-60L235.33,104A16,16,0,0,0,235.32,81.37ZM224,92.69h0l-57.27,57.46a8,8,0,0,0-1.49,9.22c9.46,18.93-1.8,38.59-9.34,48.62L48,100.08c12.08-9.74,23.64-12.31,32.48-12.31A40.13,40.13,0,0,1,96.81,91a8,8,0,0,0,9.25-1.51L163.32,32,224,92.68Z"/></symbol>
<symbol id="clock" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></symbol>
<symbol id="fork-knife" viewBox="0 0 256 256"><path d="M72,88V40a8,8,0,0,1,16,0V88a8,8,0,0,1-16,0ZM216,40V224a8,8,0,0,1-16,0V176H152a8,8,0,0,1-8-8,268.75,268.75,0,0,1,7.22-56.88c9.78-40.49,28.32-67.63,53.63-78.47A8,8,0,0,1,216,40ZM200,53.9c-32.17,24.57-38.47,84.42-39.7,106.1H200ZM119.89,38.69a8,8,0,1,0-15.78,2.63L112,88.63a32,32,0,0,1-64,0l7.88-47.31a8,8,0,1,0-15.78-2.63l-8,48A8.17,8.17,0,0,0,32,88a48.07,48.07,0,0,0,40,47.32V224a8,8,0,0,0,16,0V135.32A48.07,48.07,0,0,0,128,88a8.17,8.17,0,0,0-.11-1.31Z"/></symbol>
<symbol id="shopping-cart" viewBox="0 0 256 256"><path d="M230.14,58.87A8,8,0,0,0,224,56H62.68L56.6,22.57A8,8,0,0,0,48.73,16H24a8,8,0,0,0,0,16h18L67.56,172.29a24,24,0,0,0,5.33,11.27,28,28,0,1,0,44.4,8.44h45.42A27.75,27.75,0,0,0,160,204a28,28,0,1,0,28-28H91.17a8,8,0,0,1-7.87-6.57L80.13,152h116a24,24,0,0,0,23.61-19.71l12.16-66.86A8,8,0,0,0,230.14,58.87ZM104,204a12,12,0,1,1-12-12A12,12,0,0,1,104,204Zm96,0a12,12,0,1,1-12-12A12,12,0,0,1,200,204Zm4-74.57A8,8,0,0,1,196.1,136H77.22L65.59,72H214.41Z"/></symbol>
<symbol id="first-aid" viewBox="0 0 256 256"><path d="M216,88H168V40a16,16,0,0,0-16-16H104A16,16,0,0,0,88,40V88H40a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16H88v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V168h48a16,16,0,0,0,16-16V104A16,16,0,0,0,216,88Zm0,64H160a8,8,0,0,0-8,8v56H104V160a8,8,0,0,0-8-8H40V104H96a8,8,0,0,0,8-8V40h48V96a8,8,0,0,0,8,8h56Z"/></symbol>
<symbol id="graduation-cap" viewBox="0 0 256 256"><path d="M251.76,88.94l-120-64a8,8,0,0,0-7.52,0l-120,64a8,8,0,0,0,0,14.12L32,117.87v48.42a15.91,15.91,0,0,0,4.06,10.65C49.16,191.53,78.51,216,128,216a130,130,0,0,0,48-8.76V240a8,8,0,0,0,16,0V199.51a115.63,115.63,0,0,0,27.94-22.57A15.91,15.91,0,0,0,224,166.29V117.87l27.76-14.81a8,8,0,0,0,0-14.12ZM128,200c-43.27,0-68.72-21.14-80-33.71V126.4l76.24,40.66a8,8,0,0,0,7.52,0L176,143.47v46.34C163.4,195.69,147.52,200,128,200Zm80-33.75a97.83,97.83,0,0,1-16,14.25V134.93l16-8.53ZM188,118.94l-.22-.13-56-29.87a8,8,0,0,0-7.52,14.12L171,128l-43,22.93L25,96,128,41.07,231,96Z"/></symbol>
<symbol id="flag" viewBox="0 0 256 256"><path d="M42.76,50A8,8,0,0,0,40,56V224a8,8,0,0,0,16,0V179.77c26.79-21.16,49.87-9.75,76.45,3.41,16.4,8.11,34.06,16.85,53,16.85,13.93,0,28.54-4.75,43.82-18a8,8,0,0,0,2.76-6V56A8,8,0,0,0,218.76,50c-28,24.23-51.72,12.49-79.21-1.12C111.07,34.76,78.78,18.79,42.76,50ZM216,172.25c-26.79,21.16-49.87,9.74-76.45-3.41-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16,49.87-9.75,76.45,3.4,25,12.35,52.82,26.13,83.55,8.4Z"/></symbol>
<symbol id="trophy" viewBox="0 0 256 256"><path d="M232,64H208V48a8,8,0,0,0-8-8H56a8,8,0,0,0-8,8V64H24A16,16,0,0,0,8,80V96a40,40,0,0,0,40,40h3.65A80.13,80.13,0,0,0,120,191.61V216H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16H136V191.58c31.94-3.23,58.44-25.64,68.08-55.58H208a40,40,0,0,0,40-40V80A16,16,0,0,0,232,64ZM48,120A24,24,0,0,1,24,96V80H48v32q0,4,.39,8Zm144-8.9c0,35.52-29,64.64-64,64.9a64,64,0,0,1-64-64V56H192ZM232,96a24,24,0,0,1-24,24h-.5a81.81,81.81,0,0,0,.5-8.9V80h24Z"/></symbol>
<symbol id="scales" viewBox="0 0 256 256"><path d="M239.43,133l-32-80h0a8,8,0,0,0-9.16-4.84L136,62V40a8,8,0,0,0-16,0V65.58L54.26,80.19A8,8,0,0,0,48.57,85h0v.06L16.57,165a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32a7.92,7.92,0,0,0-.57-3L66.92,93.77,120,82V208H104a8,8,0,0,0,0,16h48a8,8,0,0,0,0-16H136V78.42L187,67.1,160.57,133a7.92,7.92,0,0,0-.57,3c0,23.31,24.54,32,40,32s40-8.69,40-32A7.92,7.92,0,0,0,239.43,133ZM56,184c-7.53,0-22.76-3.61-23.93-14.64L56,109.54l23.93,59.82C78.76,180.39,63.53,184,56,184Zm144-32c-7.53,0-22.76-3.61-23.93-14.64L200,77.54l23.93,59.82C222.76,148.39,207.53,152,200,152Z"/></symbol>
<symbol id="leaf" viewBox="0 0 256 256"><path d="M223.45,40.07a8,8,0,0,0-7.52-7.52C139.8,28.08,78.82,51,52.82,94a87.09,87.09,0,0,0-12.76,49c.57,15.92,5.21,32,13.79,47.85l-19.51,19.5a8,8,0,0,0,11.32,11.32l19.5-19.51C81,210.73,97.09,215.37,113,215.94q1.67.06,3.33.06A86.93,86.93,0,0,0,162,203.18C205,177.18,227.93,116.21,223.45,40.07ZM153.75,189.5c-22.75,13.78-49.68,14-76.71.77l88.63-88.62a8,8,0,0,0-11.32-11.32L65.73,179c-13.19-27-13-54,.77-76.71,22.09-36.47,74.6-56.44,141.31-54.06C210.2,114.89,190.22,167.41,153.75,189.5Z"/></symbol>
<symbol id="file-text" viewBox="0 0 256 256"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z"/></symbol>
<symbol id="sun" viewBox="0 0 256 256"><path d="M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z"/></symbol>
<symbol id="moon" viewBox="0 0 256 256"><path d="M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z"/></symbol>
<symbol id="shield" viewBox="0 0 256 256"><path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.27,47,25.53a8,8,0,0,0,4.2,0c1-.26,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0Z"/></symbol>
<symbol id="robot" viewBox="0 0 256 256"><path d="M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z"/></symbol>
<symbol id="tag" viewBox="0 0 256 256"><path d="M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z"/></symbol>
<symbol id="clipboard-text" viewBox="0 0 256 256"><path d="M168,152a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,152Zm-8-40H96a8,8,0,0,0,0,16h64a8,8,0,0,0,0-16Zm56-64V216a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V48A16,16,0,0,1,56,32H92.26a47.92,47.92,0,0,1,71.48,0H200A16,16,0,0,1,216,48ZM96,64h64a32,32,0,0,0-64,0ZM200,48H173.25A47.93,47.93,0,0,1,176,64v8a8,8,0,0,1-8,8H88a8,8,0,0,1-8-8V64a47.93,47.93,0,0,1,2.75-16H56V216H200Z"/></symbol>
<symbol id="drop" viewBox="0 0 256 256"><path d="M174,47.75a254.19,254.19,0,0,0-41.45-38.3,8,8,0,0,0-9.18,0A254.19,254.19,0,0,0,82,47.75C54.51,79.32,40,112.6,40,144a88,88,0,0,0,176,0C216,112.6,201.49,79.32,174,47.75ZM128,216a72.08,72.08,0,0,1-72-72c0-57.23,55.47-105,72-118,16.53,13,72,60.75,72,118A72.08,72.08,0,0,1,128,216Zm55.89-62.66a57.6,57.6,0,0,1-46.56,46.55A8.75,8.75,0,0,1,136,200a8,8,0,0,1-1.32-15.89c16.57-2.79,30.63-16.85,33.44-33.45a8,8,0,0,1,15.78,2.68Z"/></symbol>
<symbol id="target" viewBox="0 0 256 256"><path d="M221.87,83.16A104.1,104.1,0,1,1,195.67,49l22.67-22.68a8,8,0,0,1,11.32,11.32l-96,96a8,8,0,0,1-11.32-11.32l27.72-27.72a40,40,0,1,0,17.87,31.09,8,8,0,1,1,16-.9,56,56,0,1,1-22.38-41.65L184.3,60.39a87.88,87.88,0,1,0,23.13,29.67,8,8,0,0,1,14.44-6.9Z"/></symbol>
<symbol id="car" viewBox="0 0 256 256"><path d="M240,104H229.2L201.42,41.5A16,16,0,0,0,186.8,32H69.2a16,16,0,0,0-14.62,9.5L26.8,104H16a8,8,0,0,0,0,16h8v80a16,16,0,0,0,16,16H64a16,16,0,0,0,16-16V184h96v16a16,16,0,0,0,16,16h24a16,16,0,0,0,16-16V120h8a8,8,0,0,0,0-16ZM69.2,48H186.8l24.89,56H44.31ZM64,200H40V184H64Zm128,0V184h24v16Zm24-32H40V120H216ZM56,144a8,8,0,0,1,8-8H80a8,8,0,0,1,0,16H64A8,8,0,0,1,56,144Zm112,0a8,8,0,0,1,8-8h16a8,8,0,0,1,0,16H176A8,8,0,0,1,168,144Z"/></symbol>
<symbol id="skull" viewBox="0 0 256 256"><path d="M92,104a28,28,0,1,0,28,28A28,28,0,0,0,92,104Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,92,144Zm72-40a28,28,0,1,0,28,28A28,28,0,0,0,164,104Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,164,144ZM128,16C70.65,16,24,60.86,24,116c0,34.1,18.27,66,48,84.28V216a16,16,0,0,0,16,16h80a16,16,0,0,0,16-16V200.28C213.73,182,232,150.1,232,116,232,60.86,185.35,16,128,16Zm44.12,172.69a8,8,0,0,0-4.12,7V216H152V192a8,8,0,0,0-16,0v24H120V192a8,8,0,0,0-16,0v24H88V195.69a8,8,0,0,0-4.12-7C56.81,173.69,40,145.84,40,116c0-46.32,39.48-84,88-84s88,37.68,88,84C216,145.83,199.19,173.69,172.12,188.69Z"/></symbol>
<symbol id="gender-male" viewBox="0 0 256 256"><path d="M216,32H168a8,8,0,0,0,0,16h28.69L154.62,90.07a80,80,0,1,0,11.31,11.31L208,59.32V88a8,8,0,0,0,16,0V40A8,8,0,0,0,216,32ZM149.24,197.29a64,64,0,1,1,0-90.53A64.1,64.1,0,0,1,149.24,197.29Z"/></symbol>
<symbol id="gender-female" viewBox="0 0 256 256"><path d="M208,96a80,80,0,1,0-88,79.6V200H88a8,8,0,0,0,0,16h32v24a8,8,0,0,0,16,0V216h32a8,8,0,0,0,0-16H136V175.6A80.11,80.11,0,0,0,208,96ZM64,96a64,64,0,1,1,64,64A64.07,64.07,0,0,1,64,96Z"/></symbol>
<symbol id="chair" viewBox="0 0 256 256"><path d="M208,136H176V104h16a16,16,0,0,0,16-16V40a16,16,0,0,0-16-16H64A16,16,0,0,0,48,40V88a16,16,0,0,0,16,16H80v32H48a16,16,0,0,0-16,16v16a16,16,0,0,0,16,16h8v40a8,8,0,0,0,16,0V184H184v40a8,8,0,0,0,16,0V184h8a16,16,0,0,0,16-16V152A16,16,0,0,0,208,136ZM64,40H192V88H64Zm32,64h64v32H96Zm112,64H48V152H208v16Z"/></symbol>
<symbol id="person-simple-run" viewBox="0 0 256 256"><path d="M152,88a32,32,0,1,0-32-32A32,32,0,0,0,152,88Zm0-48a16,16,0,1,1-16,16A16,16,0,0,1,152,40Zm67.31,100.68c-.61.28-7.49,3.28-19.67,3.28-13.85,0-34.55-3.88-60.69-20a169.31,169.31,0,0,1-15.41,32.34,104.29,104.29,0,0,1,31.31,15.81C173.92,186.65,184,207.35,184,232a8,8,0,0,1-16,0c0-41.7-34.69-56.71-54.14-61.85-.55.7-1.12,1.41-1.69,2.1-19.64,23.8-44.25,36.18-71.63,36.18A92.29,92.29,0,0,1,31.2,208,8,8,0,0,1,32.8,192c25.92,2.58,48.47-7.49,67-30,12.49-15.14,21-33.61,25.25-47C86.13,92.35,61.27,111.63,61,111.84A8,8,0,1,1,51,99.36c1.5-1.2,37.22-29,89.51,6.57,45.47,30.91,71.93,20.31,72.18,20.19a8,8,0,1,1,6.63,14.56Z"/></symbol>
<symbol id="pencil" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM51.31,160,136,75.31,152.69,92,68,176.68ZM48,179.31,76.69,208H48Zm48,25.38L79.31,188,164,103.31,180.69,120Zm96-96L147.31,64l24-24L216,84.68Z"/></symbol>
<symbol id="user-minus" viewBox="0 0 256 256"><path d="M256,136a8,8,0,0,1-8,8H200a8,8,0,0,1,0-16h48A8,8,0,0,1,256,136ZM152,80a48,48,0,1,0-48,48A48.05,48.05,0,0,0,152,80Zm-80,0a32,32,0,1,1,32,32A32,32,0,0,1,72,80Zm112,96H16a8,8,0,0,0-8,8c0,44.18,39.4,80,88,80s88-35.82,88-80A8,8,0,0,0,184,176Zm-88,72c-38.46,0-70.27-26.54-71.87-60H167.87C166.27,221.46,134.46,248,96,248Z"/></symbol>
<symbol id="user-plus" viewBox="0 0 256 256"><path d="M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136ZM152,80a48,48,0,1,0-48,48A48.05,48.05,0,0,0,152,80Zm-80,0a32,32,0,1,1,32,32A32,32,0,0,1,72,80Zm112,96H16a8,8,0,0,0-8,8c0,44.18,39.4,80,88,80s88-35.82,88-80A8,8,0,0,0,184,176Zm-88,72c-38.46,0-70.27-26.54-71.87-60H167.87C166.27,221.46,134.46,248,96,248Z"/></symbol>
<symbol id="paper-plane-tilt" viewBox="0 0 256 256"><path d="M231.4,44.34a8,8,0,0,0-7.69-2L28.16,108a8,8,0,0,0-.53,15.09l73.5,24.5,24.5,73.5A8,8,0,0,0,133.23,227c.27,0,.53,0,.8,0a8,8,0,0,0,6.92-4L231.82,52A8,8,0,0,0,231.4,44.34Zm-97.19,154.2L115,143.61l46.2-46.2-11.32-11.31-46.2,46.2L48.47,113.84l155.7-51.72Z"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -22,8 +22,8 @@
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/layout.css?v=62">
<link rel="stylesheet" href="/css/components.css?v=62">
<link rel="stylesheet" href="/css/layout.css?v=65">
<link rel="stylesheet" href="/css/components.css?v=65">
</head>
<body>
@ -43,50 +43,64 @@
<div class="sidebar-nav">
<span class="sidebar-section-label">Mein Hund</span>
<div class="sidebar-item active" data-page="diary">
<span class="sidebar-item-icon">📖</span> Tagebuch
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch
</div>
<div class="sidebar-item" data-page="health">
<span class="sidebar-item-icon">💉</span> Gesundheit
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg> Gesundheit
</div>
<span class="sidebar-section-label">Entdecken</span>
<div class="sidebar-item" data-page="map">
<span class="sidebar-item-icon">🗺️</span> Karte
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg> Karte
</div>
<div class="sidebar-item" data-page="routes">
<span class="sidebar-item-icon">🥾</span> Routen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> Routen
</div>
<div class="sidebar-item" data-page="events">
<span class="sidebar-item-icon">🎯</span> Events
<div class="sidebar-item" data-page="events">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Events
</div>
<span class="sidebar-section-label">Soziales</span>
<div class="sidebar-item" data-page="friends">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg> Freunde
<span class="sidebar-item-badge" id="friends-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="chat">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Nachrichten
<span class="sidebar-item-badge" id="chat-badge" style="display:none">0</span>
</div>
<span class="sidebar-section-label">Community</span>
<div class="sidebar-item" data-page="poison">
<span class="sidebar-item-icon">⚠️</span> Giftköder
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder
<span class="sidebar-item-badge" id="poison-badge" style="display:none">0</span>
</div>
<div class="sidebar-item" data-page="walks">
<span class="sidebar-item-icon">🦮</span> Gassi-Treffen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen
</div>
<div class="sidebar-item" data-page="sitting">
<span class="sidebar-item-icon">🏠</span> Sitting
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#house-line"></use></svg> Sitting
</div>
<div class="sidebar-item" data-page="forum">
<span class="sidebar-item-icon">💬</span> Forum
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg> Forum
</div>
<div class="sidebar-item" data-page="lost">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg> Verlorener Hund
<span class="sidebar-item-badge" id="lost-badge" style="display:none">0</span>
</div>
<span class="sidebar-section-label">Wissen</span>
<div class="sidebar-item" data-page="wiki">
<span class="sidebar-item-icon">📚</span> Wiki
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#books"></use></svg> Wiki
</div>
<div class="sidebar-item" data-page="knigge">
<span class="sidebar-item-icon">🤝</span> Knigge
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg> Knigge
</div>
<div class="sidebar-item" data-page="movies">
<span class="sidebar-item-icon">🎬</span> Filme
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#film-slate"></use></svg> Filme
</div>
<div class="sidebar-item sidebar-item--user" id="sidebar-user">
<span class="sidebar-item-icon">👤</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#user"></use></svg>
<span id="sidebar-username">Anmelden</span>
</div>
</div>
@ -99,12 +113,12 @@
<!-- MOBILE HEADER -->
<header id="app-header">
<button class="header-back hidden" id="header-back" aria-label="Zurück">&#8592;</button>
<button class="header-back hidden" id="header-back" aria-label="Zurück"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#arrow-left"></use></svg></button>
<div id="header-dog-switcher" class="dog-switcher">
<span class="header-title" id="header-title">Ban Yaro</span>
</div>
<div id="header-actions"></div>
<button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"></button>
<button class="header-menu-btn" id="header-menu-btn" aria-label="Menü"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg></button>
</header>
<!-- HAUPT-INHALTSBEREICH -->
@ -165,35 +179,47 @@
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-lost">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-settings">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-friends">
<div class="page-body page-container"></div>
</section>
<section class="page" id="page-chat">
<div class="page-body page-container-chat"></div>
</section>
</main>
<!-- MOBILE BOTTOM NAVIGATION -->
<nav id="bottom-nav" role="navigation" aria-label="Hauptnavigation">
<div class="nav-item active" data-page="diary">
<span class="nav-item-icon">📖</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>
<span class="nav-item-label">Tagebuch</span>
</div>
<div class="nav-item" data-page="health">
<span class="nav-item-icon">💉</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
<span class="nav-item-label">Gesundheit</span>
</div>
<!-- Mittlerer + Button -->
<div class="nav-item nav-item-center" id="nav-add">
<span class="nav-item-icon">+</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
</div>
<div class="nav-item" data-page="poison">
<span class="nav-item-icon">
⚠️
<span style="position:relative;display:inline-flex">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg>
<span class="nav-badge hidden" id="poison-nav-badge">0</span>
</span>
<span class="nav-item-label">Alarm</span>
</div>
<div class="nav-item" data-page="settings">
<span class="nav-item-icon">👤</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gear"></use></svg>
<span class="nav-item-label">Ich</span>
</div>
</nav>
@ -207,9 +233,9 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=62"></script>
<script src="/js/ui.js?v=62"></script>
<script src="/js/app.js?v=62"></script>
<script src="/js/api.js?v=65"></script>
<script src="/js/ui.js?v=65"></script>
<script src="/js/app.js?v=65"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -248,6 +248,73 @@ const API = (() => {
updateRequest(id, status) { return patch(`/sitting/requests/${id}`, { status }); },
};
// ----------------------------------------------------------
// FORUM
// ----------------------------------------------------------
const forum = {
threads(params = {}) {
const q = new URLSearchParams(params).toString();
return get(`/forum/threads${q ? '?' + q : ''}`);
},
thread(id) { return get(`/forum/threads/${id}`); },
create(data) { return post('/forum/threads', data); },
deleteThread(id) { return del(`/forum/threads/${id}`); },
patchThread(id, data) { return patch(`/forum/threads/${id}`, data); },
addPost(threadId, data){ return post(`/forum/threads/${threadId}/posts`, data); },
deletePost(id) { return del(`/forum/posts/${id}`); },
uploadThreadFoto(id, file) {
const fd = new FormData(); fd.append('file', file);
return upload(`/forum/threads/${id}/fotos`, fd);
},
uploadPostFoto(id, file) {
const fd = new FormData(); fd.append('file', file);
return upload(`/forum/posts/${id}/fotos`, fd);
},
like(targetType, targetId) {
return post('/forum/like', { target_type: targetType, target_id: targetId });
},
report(targetType, targetId, grund) {
return post('/forum/report', { target_type: targetType, target_id: targetId, grund });
},
reports() { return get('/forum/reports'); },
resolveReport(id) { return patch(`/forum/reports/${id}`, { resolved: 1 }); },
membersMap() { return get('/forum/members/map'); },
setLocation(lat, lon, show) {
return patch('/forum/members/location', { lat, lon, show });
},
search(q) { return get(`/forum/search?q=${encodeURIComponent(q)}`); },
// Legacy aliases (keep old names working)
listThreads(params = {}) { return forum.threads(params); },
getThread(id) { return forum.thread(id); },
createThread(data) { return forum.create(data); },
createPost(tid, data) { return forum.addPost(tid, data); },
};
// ----------------------------------------------------------
// VERLORENER HUND
// ----------------------------------------------------------
const lost = {
list(lat = null, lon = null, radius_km = 25) {
const params = new URLSearchParams({ radius_km });
if (lat !== null) { params.set('lat', lat); params.set('lon', lon); }
return get(`/lost?${params}`);
},
report(data) { return post('/lost', data); },
uploadFoto(id, form) { return upload(`/lost/${id}/foto`, form); },
markFound(id) { return post(`/lost/${id}/found`); },
delete(id) { return del(`/lost/${id}`); },
};
// ----------------------------------------------------------
// KNIGGE
// ----------------------------------------------------------
const knigge = {
vote: (szenario_id, answer) => post('/knigge/vote', { szenario_id, answer }),
votes: (szenario_id) => get(`/knigge/votes?szenario_id=${encodeURIComponent(szenario_id)}`),
kiRat: (situation) => post('/knigge/ki-rat', { situation }),
};
// ----------------------------------------------------------
// WETTER
// ----------------------------------------------------------
@ -255,6 +322,30 @@ const API = (() => {
alerts(lat, lon) { return get(`/weather/alerts?lat=${lat}&lon=${lon}`); },
};
// ----------------------------------------------------------
// FREUNDE
// ----------------------------------------------------------
const friends = {
list() { return get('/friends/'); },
search(q) { return get(`/friends/search?q=${encodeURIComponent(q)}`); },
sendRequest(userId) { return post(`/friends/request/${userId}`, {}); },
accept(friendshipId) { return post(`/friends/${friendshipId}/accept`, {}); },
decline(friendshipId) { return post(`/friends/${friendshipId}/decline`, {}); },
remove(friendUserId) { return del(`/friends/${friendUserId}`); },
};
// ----------------------------------------------------------
// DIREKTNACHRICHTEN
// ----------------------------------------------------------
const chat = {
conversations() { return get('/chat/conversations'); },
start(partnerId) { return post('/chat/conversations', { partner_id: partnerId }); },
messages(convId, offset=0) { return get(`/chat/conversations/${convId}?offset=${offset}`); },
send(convId, text) { return post(`/chat/conversations/${convId}/messages`, { text }); },
markRead(convId) { return post(`/chat/conversations/${convId}/read`, {}); },
deleteMessage(msgId) { return del(`/chat/messages/${msgId}`); },
};
// ----------------------------------------------------------
// PUSH NOTIFICATIONS
// ----------------------------------------------------------
@ -325,7 +416,8 @@ const API = (() => {
return {
get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, weather, push,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat,
subscribeToPush, getLocation,
APIError,
};

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '62'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '66'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {
@ -37,12 +37,15 @@ events: { title: 'Events', module: null },
knigge: { title: 'Knigge', module: null },
movies: { title: 'Filme', module: null },
settings: { title: 'Einstellungen', module: null },
lost: { title: 'Verlorener Hund', module: null },
friends: { title: 'Freunde', module: null },
chat: { title: 'Nachrichten', module: null },
};
// ----------------------------------------------------------
// ROUTER
// ----------------------------------------------------------
function navigate(pageId, pushHistory = true) {
function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return;
// Aktive Seite ausblenden
@ -70,14 +73,20 @@ events: { title: 'Events', module: null },
UI.scrollTop();
// Seiten-Modul lazy laden (einmalig)
_loadPage(pageId);
_loadPage(pageId, params);
}
async function _loadPage(pageId) {
async function _loadPage(pageId, params = {}) {
const page = pages[pageId];
if (page.module) {
// Bereits geladen → nur refresh aufrufen wenn vorhanden
page.module.refresh?.();
const hasParams = params && Object.keys(params).length > 0;
if (hasParams) {
// Re-init mit neuen Params (z.B. Chat mit bestimmter Konversation)
const container = document.querySelector(`#page-${pageId} .page-body`);
page.module.init?.(container, state, params);
} else {
page.module.refresh?.();
}
return;
}
@ -96,7 +105,7 @@ events: { title: 'Events', module: null },
await _loadScript(`/js/pages/${pageId}.js`);
const mod = window[`Page_${pageId.replace(/-/g, '_')}`];
if (mod?.init) {
await mod.init(container, state);
await mod.init(container, state, params);
page.module = mod;
} else {
// Platzhalter wenn Seite noch nicht gebaut
@ -214,16 +223,16 @@ events: { title: 'Events', module: null },
body: `
<div class="flex flex-col gap-3">
<button class="btn btn-secondary w-full" data-quick="diary">
📖 Tagebuch-Eintrag
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch-Eintrag
</button>
<button class="btn btn-secondary w-full" data-quick="health">
💉 Gesundheits-Eintrag
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg> Gesundheits-Eintrag
</button>
<button class="btn btn-danger w-full" data-quick="poison">
Giftköder melden
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning-octagon"></use></svg> Giftköder melden
</button>
<button class="btn btn-nature w-full" data-quick="walk">
🦮 Gassi-Treffen erstellen
<button class="btn btn-nature w-full" data-quick="walk">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gassi-Treffen erstellen
</button>
</div>
`,
@ -423,9 +432,16 @@ if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(
// Erste Seite laden: Hash aus URL oder Standard 'diary'.
// Bewusst NACH _checkAuth(), damit _loadPage() nur einmal aufgerufen wird
// (vorher war Hash-Navigation auch in _bindNavigation() → doppelter Aufruf).
const hash = location.hash.replace('#', '');
const startPage = (hash && pages[hash]) ? hash : 'diary';
navigate(startPage, false);
const rawHash = location.hash.replace('#', '');
const [hashPage, hashQuery] = rawHash.split('?');
const hashParams = {};
if (hashQuery) {
new URLSearchParams(hashQuery).forEach((v, k) => {
hashParams[k] = isNaN(v) ? v : Number(v);
});
}
const startPage = (hashPage && pages[hashPage]) ? hashPage : 'diary';
navigate(startPage, false, hashParams);
}
// ----------------------------------------------------------

View file

@ -0,0 +1,344 @@
/* ============================================================
BAN YARO Chat-Seite
============================================================ */
window.Page_chat = (() => {
let _container = null;
let _view = 'list'; // 'list' | 'thread'
let _convId = null;
let _partnerName = '';
let _myId = null;
let _pollTimer = null;
let _lastMsgId = 0;
// ----------------------------------------------------------
async function init(container, appState, params = {}) {
_container = container;
_myId = appState?.user?.id || null;
if (params.conversation_id) {
await _openThread(params.conversation_id);
} else {
await _showList();
}
}
// ----------------------------------------------------------
// Conversation list
// ----------------------------------------------------------
async function _showList() {
_view = 'list';
_stopPolling();
_convId = null;
_container.innerHTML = `
<div style="background:var(--c-surface)">
<div style="padding:var(--space-4) var(--space-4) var(--space-2)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold)">Nachrichten</h2>
</div>
<div id="chat-list-body"></div>
</div>
`;
await _loadList();
await _updateChatBadge();
}
async function _loadList() {
const el = document.getElementById('chat-list-body');
if (!el) return;
try {
const convs = await API.chat.conversations();
if (!convs.length) {
el.innerHTML = `
<div class="empty-state" style="padding:var(--space-12) var(--space-4)">
<svg class="ph-icon" style="font-size:3rem;opacity:0.3">
<use href="/icons/phosphor.svg#chat-circle-dots"></use>
</svg>
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
Noch keine Nachrichten.<br>
Schreibe einem Freund über die Freunde-Seite!
</p>
</div>
`;
return;
}
el.innerHTML = convs.map(c => {
const initials = (c.partner_name || '?')[0].toUpperCase();
const preview = c.last_text
? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '')
: '<em style="opacity:0.6">Noch keine Nachrichten</em>';
const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : '';
const badge = c.unread_count > 0
? `<span class="chat-unread-badge">${c.unread_count}</span>`
: '';
return `
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
<div class="chat-conv-avatar">${initials}</div>
<div class="chat-conv-info">
<div class="chat-conv-name">${_esc(c.partner_name)}</div>
<div class="chat-conv-preview">${preview}</div>
</div>
<div class="chat-conv-meta">
<span class="chat-conv-time">${timeStr}</span>
${badge}
</div>
</div>
`;
}).join('');
} catch (e) {
if (e.status === 401) {
document.getElementById('chat-list-body').innerHTML =
`<div class="empty-state"><p>Bitte melde dich an.</p></div>`;
}
}
}
// ----------------------------------------------------------
// Thread (message view)
// ----------------------------------------------------------
async function _openThread(convId) {
_convId = convId;
_view = 'thread';
_stopPolling();
_container.innerHTML = `
<div class="chat-thread" id="chat-thread">
<div class="chat-thread-header">
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button>
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
<span class="chat-thread-partner" id="chat-partner-name"></span>
</div>
<div class="chat-messages" id="chat-messages">
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted);font-size:var(--text-sm)">
Laden
</div>
</div>
<div class="chat-input-bar">
<textarea id="chat-input" class="chat-input" rows="1"
placeholder="Nachricht…" maxlength="2000"></textarea>
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
</button>
</div>
</div>
`;
// Auto-resize textarea
const input = document.getElementById('chat-input');
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 100) + 'px';
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
Page_chat._send();
}
});
await _loadMessages(true);
await API.chat.markRead(_convId).catch(() => {});
await _updateChatBadge();
// Poll every 4s while thread is open
_pollTimer = setInterval(async () => {
if (_view === 'thread' && _convId === convId) {
await _pollNew();
}
}, 4000);
}
async function _loadMessages(scroll = false) {
const el = document.getElementById('chat-messages');
if (!el) return;
try {
const data = await API.chat.messages(_convId);
_partnerName = data.partner_name;
const nameEl = document.getElementById('chat-partner-name');
const avEl = document.getElementById('chat-partner-av');
if (nameEl) nameEl.textContent = data.partner_name;
if (avEl) avEl.textContent = (data.partner_name || '?')[0].toUpperCase();
if (!data.messages.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--c-text-muted);font-size:var(--text-sm)">
Schreibe die erste Nachricht!
</div>
`;
_lastMsgId = 0;
return;
}
_lastMsgId = data.messages[data.messages.length - 1].id;
el.innerHTML = _renderMessages(data.messages);
if (scroll) _scrollToBottom(el);
} catch (e) {
if (el) el.innerHTML = `<div class="empty-state"><p>Fehler beim Laden.</p></div>`;
}
}
async function _pollNew() {
const el = document.getElementById('chat-messages');
if (!el) return;
try {
const data = await API.chat.messages(_convId);
if (!data.messages.length) return;
const newest = data.messages[data.messages.length - 1];
if (newest.id <= _lastMsgId) return;
// New messages arrived
_lastMsgId = newest.id;
const wasAtBottom = _isScrolledToBottom(el);
el.innerHTML = _renderMessages(data.messages);
if (wasAtBottom) _scrollToBottom(el);
await API.chat.markRead(_convId).catch(() => {});
await _updateChatBadge();
} catch (e) {
// silent
}
}
function _renderMessages(msgs) {
let html = '';
let lastDate = null;
for (const m of msgs) {
const dateStr = m.created_at.substring(0, 10);
if (dateStr !== lastDate) {
html += `<div class="chat-date-divider">${_fmtDate(m.created_at)}</div>`;
lastDate = dateStr;
}
const isMine = m.sender_id === _myId;
const rowClass = isMine ? 'chat-bubble-row--mine' : 'chat-bubble-row--theirs';
const bubClass = isMine ? 'chat-bubble--mine' : 'chat-bubble--theirs';
const delClass = m.is_deleted ? ' chat-bubble--deleted' : '';
const timeStr = _fmtTime(m.created_at);
const deleteBtn = isMine && !m.is_deleted
? `<button class="btn btn-ghost" style="padding:2px;opacity:0.4;font-size:var(--text-xs)"
onclick="Page_chat._deleteMsg(${m.id})" title="Löschen">
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>`
: '';
html += `
<div class="chat-bubble-row ${rowClass}">
<div>
<div class="chat-bubble ${bubClass}${delClass}">${_esc(m.text)}</div>
<div class="chat-bubble-time" style="display:flex;align-items:center;gap:2px;justify-content:${isMine ? 'flex-end' : 'flex-start'}">
${timeStr} ${deleteBtn}
</div>
</div>
</div>
`;
}
return html;
}
// ----------------------------------------------------------
async function _send() {
const input = document.getElementById('chat-input');
const btn = document.getElementById('chat-send-btn');
if (!input) return;
const text = input.value.trim();
if (!text || !_convId) return;
btn.disabled = true;
try {
await API.chat.send(_convId, text);
input.value = '';
input.style.height = 'auto';
await _loadMessages(true);
} catch (e) {
UI.toast(e.message, 'danger');
} finally {
btn.disabled = false;
input.focus();
}
}
// ----------------------------------------------------------
async function _deleteMsg(msgId) {
try {
await API.chat.deleteMessage(msgId);
await _loadMessages(false);
} catch (e) {
UI.toast(e.message, 'danger');
}
}
// ----------------------------------------------------------
async function _updateChatBadge() {
try {
const convs = await API.chat.conversations();
const total = convs.reduce((s, c) => s + (c.unread_count || 0), 0);
const badge = document.getElementById('chat-badge');
if (badge) {
badge.textContent = total;
badge.style.display = total > 0 ? '' : 'none';
}
} catch (e) {
// silent
}
}
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
function _stopPolling() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
function _scrollToBottom(el) {
el.scrollTop = el.scrollHeight;
}
function _isScrolledToBottom(el) {
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}
function _fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T') + 'Z');
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function _fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T') + 'Z');
const now = new Date();
const diff = Math.floor((now - d) / 86400000);
if (diff === 0) return 'Heute';
if (diff === 1) return 'Gestern';
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
function _esc(s) {
if (!s) return '';
return String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/\n/g, '<br>');
}
// ----------------------------------------------------------
return {
init,
_showList,
_openThread,
_send,
_deleteMsg,
};
})();

View file

@ -16,12 +16,12 @@ window.Page_diary = (() => {
const LIMIT = 20;
const TYPEN = {
eintrag: { label: 'Eintrag', icon: '📖' },
foto: { label: 'Foto', icon: '📷' },
meilenstein:{ label: 'Meilenstein',icon: '🏆' },
training: { label: 'Training', icon: '🎯' },
gesundheit: { label: 'Gesundheit', icon: '💉' },
ausflug: { label: 'Ausflug', icon: '🚗' },
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
foto: { label: 'Foto', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>' },
meilenstein:{ label: 'Meilenstein',icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>' },
training: { label: 'Training', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#target"></use></svg>' },
gesundheit: { label: 'Gesundheit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
ausflug: { label: 'Ausflug', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>' },
};
// ----------------------------------------------------------
@ -72,7 +72,7 @@ window.Page_diary = (() => {
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '🐕',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil, um das Tagebuch zu nutzen.',
action: `<button class="btn btn-primary" id="diary-goto-profile">Profil erstellen</button>`,
@ -99,7 +99,7 @@ window.Page_diary = (() => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_escape(dog.foto_url)}" alt="${_escape(dog.name)}">`
: `<span style="font-size:2.5rem">🐕</span>`;
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
@ -186,7 +186,7 @@ window.Page_diary = (() => {
if (_entries.length === 0) {
listEl.innerHTML = UI.emptyState({
icon: '📖',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>',
title: 'Noch keine Einträge',
text: 'Halte besondere Momente mit deinem Hund fest.',
action: `<button class="btn btn-primary" id="diary-first-entry">Ersten Eintrag erstellen</button>`,
@ -243,6 +243,11 @@ window.Page_diary = (() => {
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
: '';
// Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein')
const milestoneBadge = e.is_milestone
? `<div class="diary-card-milestone-badge">${UI.icon('calendar-dots')} Meilenstein</div>`
: '';
// Mehrere Hunde: kleine Avatare in der Karte
const dogAvatars = _dogAvatarRow(e.dog_ids || []);
@ -250,6 +255,7 @@ window.Page_diary = (() => {
<div class="diary-card${isMile ? ' diary-card--milestone' : ''}" data-entry-id="${e.id}">
${photo}
<div class="diary-card-body">
${milestoneBadge}
<div class="diary-card-meta">
<span class="diary-card-type">${typ.icon} ${typ.label}</span>
<span class="diary-card-date">${dateStr}</span>
@ -269,7 +275,7 @@ window.Page_diary = (() => {
const dog = _appState.dogs.find(d => d.id === did);
if (!dog) return '';
return `<div class="diary-dog-av" title="${_escape(dog.name)}">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>`;
}).join('');
return `<div class="diary-dog-row">${avatars}</div>`;
@ -299,7 +305,7 @@ window.Page_diary = (() => {
const dog = _appState.dogs.find(d => d.id === did);
return dog ? `<div class="diary-dog-chip">
<div class="diary-dog-av">
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : '<span>🐕</span>'}
${dog.foto_url ? `<img src="${_escape(dog.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(dog.name)}</span>
</div>` : '';
@ -308,7 +314,7 @@ window.Page_diary = (() => {
: '';
const body = `
${isMile ? '<div class="diary-detail-milestone-badge">🏆 Meilenstein</div>' : ''}
${isMile ? `<div class="diary-detail-milestone-badge">${UI.icon('trophy')} Meilenstein</div>` : ''}
${photo}
<div style="display:flex;gap:var(--space-2);align-items:center;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${typ.icon} ${typ.label}</span>
@ -374,7 +380,7 @@ window.Page_diary = (() => {
<input type="checkbox" name="extra_dog" value="${d.id}"
${entryDogIds.includes(d.id) ? 'checked' : ''}>
<div class="diary-dog-av">
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : '<span>🐕</span>'}
${d.foto_url ? `<img src="${_escape(d.foto_url)}" alt="">` : `<span>${UI.icon('dog')}</span>`}
</div>
<span>${_escape(d.name)}</span>
</label>`).join('')}

View file

@ -47,7 +47,7 @@ window.Page_dog_profile = (() => {
async function _render() {
if (!_appState.user) {
_container.innerHTML = UI.emptyState({
icon : '🐕',
icon : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
title : 'Anmelden erforderlich',
text : 'Melde dich an, um ein Hundeprofil anzulegen.',
action: `<button class="btn btn-primary" id="profile-goto-login">Anmelden</button>`,
@ -85,13 +85,13 @@ window.Page_dog_profile = (() => {
: `<div style="width:120px;height:120px;border-radius:50%;
background:var(--c-surface-2);display:flex;
align-items:center;justify-content:center;
font-size:3.5rem;border:3px solid var(--c-border)">🐕</div>`}
font-size:3.5rem;border:3px solid var(--c-border)">${UI.icon('dog')}</div>`}
<label style="position:absolute;bottom:4px;right:4px;
background:var(--c-primary);color:#fff;border-radius:50%;
width:30px;height:30px;display:flex;align-items:center;
justify-content:center;cursor:pointer;font-size:14px"
title="Foto ändern">
📷
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
<input type="file" id="dp-photo-input" accept="image/*"
style="display:none">
</label>
@ -110,7 +110,7 @@ window.Page_dog_profile = (() => {
${geburtstag ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">🎂 Geburtstag</div>
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Geburtstag</div>
<div style="font-weight:500;font-size:var(--text-sm)">${geburtstag}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
${_calcAlter(dog.geburtstag)}
@ -120,7 +120,7 @@ window.Page_dog_profile = (() => {
${dog.geschlecht ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">${dog.geschlecht === 'm' ? '♂' : '♀'} Geschlecht</div>
margin-bottom:2px">${dog.geschlecht === 'm' ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-male"></use></svg>' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>'} Geschlecht</div>
<div style="font-weight:500;font-size:var(--text-sm)">
${dog.geschlecht === 'm' ? 'Rüde' : 'Hündin'}
</div>
@ -129,14 +129,14 @@ window.Page_dog_profile = (() => {
${dog.gewicht_kg ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px"> Gewicht</div>
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>
</div>
` : ''}
${dog.chip_nr ? `
<div class="card" style="padding:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px">💾 Chip-Nr.</div>
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Chip-Nr.</div>
<div style="font-size:var(--text-xs);font-weight:500;
word-break:break-all">${_esc(dog.chip_nr)}</div>
</div>
@ -151,6 +151,34 @@ window.Page_dog_profile = (() => {
</div>
` : ''}
${dog.is_public ? `
<div style="background:var(--c-primary-subtle);border:1px solid var(--c-primary-light);
border-radius:var(--radius-md);padding:var(--space-4);
margin-bottom:var(--space-5);text-align:left">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:var(--space-2);font-weight:var(--weight-medium)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> NFC-Link
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);
flex-wrap:wrap">
<code id="dp-nfc-link"
style="flex:1;font-size:var(--text-sm);background:var(--c-surface);
border:1px solid var(--c-border);border-radius:var(--radius-sm);
padding:var(--space-2) var(--space-3);color:var(--c-text);
word-break:break-all">banyaro.app/hund/${dog.id}</code>
<button class="btn btn-secondary btn-sm" id="dp-copy-link-btn"
style="flex-shrink:0;padding:var(--space-2) var(--space-3);
font-size:var(--text-sm);min-height:36px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Kopieren
</button>
</div>
<p style="margin:var(--space-2) 0 0;font-size:var(--text-xs);
color:var(--c-text-muted)">
Dieser Link kann auf ein NFC-Tag gebrannt werden
</p>
</div>
` : ''}
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary w-full" id="dp-edit-btn">
Profil bearbeiten
@ -182,6 +210,26 @@ window.Page_dog_profile = (() => {
UI.toast.error(err.message || 'Fehler beim Hochladen.');
}
});
// NFC-Link kopieren
document.getElementById('dp-copy-link-btn')?.addEventListener('click', async () => {
const url = `https://banyaro.app/hund/${dog.id}`;
try {
await navigator.clipboard.writeText(url);
UI.toast.success('Link kopiert!');
} catch {
// Fallback für ältere Browser
const el = document.getElementById('dp-nfc-link');
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('copy');
sel.removeAllRanges();
UI.toast.success('Link kopiert!');
}
});
// Edit- und Add-Klicks laufen über Event-Delegation in init() — keine direkten Listener nötig.
}
@ -192,7 +240,7 @@ window.Page_dog_profile = (() => {
_container.innerHTML = `
<div style="padding:var(--space-4) 0 var(--space-2)">
<div style="text-align:center;margin-bottom:var(--space-5)">
<div style="font-size:3rem;margin-bottom:var(--space-2)">🐕</div>
<div style="font-size:3rem;margin-bottom:var(--space-2)">${UI.icon('dog')}</div>
<h2 style="font-size:var(--text-xl);font-weight:700;margin:0 0 var(--space-2)">
Hund anlegen
</h2>
@ -215,7 +263,7 @@ window.Page_dog_profile = (() => {
body: _formHTML(null, true),
footer: `
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
<button type="submit" form="dp-form" class="btn btn-primary flex-1">🐕 Hund anlegen</button>
<button type="submit" form="dp-form" class="btn btn-primary flex-1">${UI.icon('dog')} Hund anlegen</button>
`,
});
_bindForm(null, true);
@ -317,7 +365,7 @@ window.Page_dog_profile = (() => {
background:var(--c-surface-2);border:2px solid var(--c-border);
display:${dog?.foto_url ? 'block' : 'none'}">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;margin:0">
📷 Foto auswählen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg> Foto auswählen
<input type="file" name="foto" accept="image/*" style="display:none"
id="dp-form-foto">
</label>
@ -329,7 +377,7 @@ window.Page_dog_profile = (() => {
${dog ? `<button type="button" class="btn btn-secondary flex-1"
id="dp-form-cancel">Abbrechen</button>` : ''}
<button type="submit" class="btn btn-primary flex-1">
${dog ? 'Speichern' : '🐕 Hund anlegen'}
${dog ? 'Speichern' : `${UI.icon('dog')} Hund anlegen`}
</button>
</div>` : ''}

View file

@ -34,10 +34,18 @@ window.Page_events = (() => {
let _state = null;
let _events = [];
let _filter = 'alle';
let _quellFilter = 'alle'; // 'alle' | 'vdh' | 'nutzer'
let _view = 'liste'; // liste | karte
let _map = null;
let _markers = [];
// ----------------------------------------------------------
// Phosphor-Icon-Helper
// ----------------------------------------------------------
function _icon(name) {
return `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
// ----------------------------------------------------------
// init
// ----------------------------------------------------------
@ -59,11 +67,11 @@ window.Page_events = (() => {
_container.innerHTML = `
<div class="events-toolbar">
<div class="events-view-toggle">
<button class="events-view-btn active" data-ev-view="liste"> Liste</button>
<button class="events-view-btn" data-ev-view="karte">🗺 Karte</button>
<button class="events-view-btn active" data-ev-view="liste">${UI.icon('list')} Liste</button>
<button class="events-view-btn" data-ev-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<div style="flex:1"></div>
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">+ Event</button>` : ''}
${_state.user ? `<button class="btn btn-primary btn-sm" id="ev-new-btn">${UI.icon('plus')} Event</button>` : ''}
</div>
<div class="events-filter-bar" id="ev-filter-bar">
@ -74,6 +82,14 @@ window.Page_events = (() => {
`).join('')}
</div>
<div class="events-source-bar" id="ev-source-bar">
<button class="events-source-btn active" data-ev-quelle="alle">Alle Quellen</button>
<button class="events-source-btn events-source-vdh" data-ev-quelle="vdh">
<span class="ev-vdh-badge">VDH</span> VDH-Events
</button>
<button class="events-source-btn" data-ev-quelle="nutzer">Von Nutzern</button>
</div>
<div class="events-list" id="ev-list"></div>
<div class="events-map" id="ev-map" style="display:none"></div>
`;
@ -96,6 +112,17 @@ window.Page_events = (() => {
}
}
// ----------------------------------------------------------
// Gefilterte Events ermitteln
// ----------------------------------------------------------
function _filtered() {
let evs = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
if (_quellFilter !== 'alle') {
evs = evs.filter(e => (e.quelle || 'nutzer') === _quellFilter);
}
return evs;
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
@ -103,7 +130,7 @@ window.Page_events = (() => {
const listEl = document.getElementById('ev-list');
if (!listEl) return;
const filtered = _filter === 'alle' ? _events : _events.filter(e => e.typ === _filter);
const filtered = _filtered();
if (!filtered.length) {
listEl.innerHTML = UI.emptyState({ icon: '🎪', title: 'Keine Events', text: 'Noch keine Veranstaltungen geplant.' });
return;
@ -140,6 +167,8 @@ window.Page_events = (() => {
const typ = TYPEN.find(t => t.id === ev.typ) || TYPEN[TYPEN.length - 1];
const color = TYP_COLOR[ev.typ] || '#6b7280';
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
return `
<div class="events-card" data-ev-id="${ev.id}" style="border-left-color:${color}">
<div class="events-date-badge">
@ -148,14 +177,22 @@ window.Page_events = (() => {
<span class="month">${mon}</span>
</div>
<div class="events-card-body">
<div class="events-card-title">${UI.escHtml(ev.titel)}</div>
<div class="events-card-title">
${UI.escHtml(ev.titel)}
${isVdh ? `<span class="ev-vdh-badge" title="Vom VDH importiert">VDH</span>` : ''}
</div>
<div class="events-card-meta">
<span class="events-badge" style="background:${color}20;color:${color}">${typ.icon} ${typ.label}</span>
${ev.uhrzeit ? `· ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· 📍 ${UI.escHtml(ev.ort_name)}` : ''}
${ev.uhrzeit ? `· ${_icon('clock')} ${ev.uhrzeit} Uhr` : ''}
${ev.ort_name ? `· ${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}` : ''}
</div>
${ev.link ? `<div class="events-card-actions">
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
${_icon('arrow-square-out')} Details
</a>
</div>` : ''}
</div>
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">✏️</button>` : ''}
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''}
</div>
`;
}
@ -223,22 +260,33 @@ window.Page_events = (() => {
const d = new Date(ev.datum + 'T00:00:00');
const datum = d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const isOwn = _state.user?.id === ev.user_id;
const isVdh = ev.quelle === 'vdh';
const body = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="events-badge" style="background:${color}20;color:${color};font-size:var(--text-sm)">${typ.icon} ${typ.label}</span>
${isVdh ? `<span class="ev-vdh-badge">VDH</span>` : ''}
</div>
<div class="events-detail-row">📅 ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">📍 ${UI.escHtml(ev.ort_name)}</div>` : ''}
<div class="events-detail-row">${_icon('calendar-dots')} ${datum}${ev.uhrzeit ? ' · ' + ev.uhrzeit + ' Uhr' : ''}</div>
${ev.ort_name ? `<div class="events-detail-row">${_icon('map-pin')} ${UI.escHtml(ev.ort_name)}</div>` : ''}
${ev.beschreibung ? `<div class="events-detail-desc">${UI.escHtml(ev.beschreibung)}</div>` : ''}
${ev.link ? `<div class="events-detail-row">🔗 <a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a></div>` : ''}
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}</div>
${ev.link ? `<div class="events-detail-row">
${_icon('arrow-square-out')}
<a href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">Mehr Infos</a>
</div>` : ''}
<div class="events-detail-row" style="color:var(--c-text-muted);font-size:var(--text-xs)">
${_icon('user')} Veranstalter: ${UI.escHtml(ev.veranstalter_name || '')}
</div>
`;
const footer = isOwn ? `
<button class="btn btn-secondary" id="ev-detail-edit"> Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">🗑 Löschen</button>
` : '';
<button class="btn btn-secondary" id="ev-detail-edit">${_icon('pencil-simple')} Bearbeiten</button>
<button class="btn btn-danger" id="ev-detail-del">${_icon('trash')} Löschen</button>
` : (ev.link ? `
<a class="btn btn-primary" href="${UI.escHtml(ev.link)}" target="_blank" rel="noopener">
${_icon('arrow-square-out')} Zur Veranstaltung
</a>
` : '');
UI.modal.open({ title: UI.escHtml(ev.titel), body, footer });
@ -368,7 +416,16 @@ window.Page_events = (() => {
// Click-Handler
// ----------------------------------------------------------
function _onClick(e) {
// Filter
// Quelle-Filter
const sourceBtn = e.target.closest('[data-ev-quelle]');
if (sourceBtn) {
_quellFilter = sourceBtn.dataset.evQuelle;
document.querySelectorAll('[data-ev-quelle]').forEach(b => b.classList.toggle('active', b.dataset.evQuelle === _quellFilter));
_renderList();
return;
}
// Typ-Filter
const filterBtn = e.target.closest('[data-ev-typ]');
if (filterBtn) {
_filter = filterBtn.dataset.evTyp;
@ -387,8 +444,7 @@ window.Page_events = (() => {
if (_view === 'karte') {
listEl.style.display = 'none';
mapEl.style.display = 'block';
const filtered = _filter === 'alle' ? _events : _events.filter(ev => ev.typ === _filter);
_renderMap(filtered);
_renderMap(_filtered());
} else {
listEl.style.display = '';
mapEl.style.display = 'none';
@ -399,6 +455,9 @@ window.Page_events = (() => {
// Neu-Button
if (e.target.closest('#ev-new-btn')) { openNew(); return; }
// Externer Link — nicht als Karten-Klick behandeln
if (e.target.closest('.ev-ext-link')) return;
// Bearbeiten-Icon auf Karte
const editBtn = e.target.closest('[data-ev-edit]');
if (editBtn) {

View file

@ -0,0 +1,890 @@
/* ============================================================
BAN YARO Forum (Sprint 11)
Kategorien, Threads, Antworten, Likes, Reports,
Foto-Upload, Mitgliederkarte, Moderations-Panel
============================================================ */
window.Page_forum = (() => {
let _container = null;
let _appState = null;
let _threads = [];
let _aktivKat = 'alle';
let _offset = 0;
let _searchTimer = null;
let _searching = false;
let _mapLoaded = false;
let _leafletLoaded = false;
let _map = null;
let _activeSection = 'list'; // 'list' | 'map'
const LIMIT = 30;
const KATEGORIEN = [
{ key: 'alle', label: 'Alle' },
{ key: 'allgemein', label: 'Allgemein' },
{ key: 'rasse', label: 'Rasse' },
{ key: 'region', label: 'Region' },
{ key: 'gesundheit', label: 'Gesundheit' },
{ key: 'erziehung', label: 'Erziehung' },
{ key: 'tauschboerse', label: 'Tauschbörse' },
];
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const diff = (now - d) / 1000;
if (diff < 60) return 'gerade eben';
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`;
return d.toLocaleDateString('de-DE');
}
function _initial(name) {
return (name || '?').charAt(0).toUpperCase();
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadThreads(true);
}
function refresh() {
_loadThreads(true);
}
function onDogChange() {}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
const isMod = !!_appState.user?.is_moderator;
_container.innerHTML = `
<div class="forum-layout">
<!-- Header -->
<div class="forum-header">
<h2 class="forum-header-title">Forum</h2>
<div class="forum-header-actions">
${isMod ? `<button class="btn btn-ghost btn-sm" id="forum-mod-btn" title="Moderationsberichte">${UI.icon('warning')}</button>` : ''}
<button class="btn btn-primary btn-sm" id="forum-new-btn">${UI.icon('plus')} Neues Thema</button>
</div>
</div>
<!-- Kategorie-Tabs -->
<div class="forum-category-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<button class="forum-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}">${_esc(k.label)}</button>
`).join('')}
<button class="forum-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
</div>
<!-- Suchleiste -->
<div class="forum-search-wrap">
<input type="search" class="forum-search" id="forum-search"
placeholder="Forum durchsuchen…" autocomplete="off">
</div>
<!-- Thread-Liste / Karte / Suche -->
<div id="forum-main">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt</p>
</div>
</div>
`;
// Tab-Klicks
document.getElementById('forum-tabs').addEventListener('click', e => {
const btn = e.target.closest('[data-kat], [data-section]');
if (!btn) return;
if (btn.dataset.section === 'map') {
_aktivKat = 'alle';
_activeSection = 'map';
document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_renderMembersMap();
return;
}
_aktivKat = btn.dataset.kat;
_activeSection = 'list';
document.querySelectorAll('.forum-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_offset = 0;
_threads = [];
_loadThreads(true);
});
// Suche
document.getElementById('forum-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
if (!q) {
_searching = false;
_renderList();
return;
}
_searchTimer = setTimeout(() => _doSearch(q), 400);
});
// Neues Thema
document.getElementById('forum-new-btn').addEventListener('click', () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
_showCreateForm();
});
// Moderations-Panel
document.getElementById('forum-mod-btn')?.addEventListener('click', _showModPanel);
}
// ----------------------------------------------------------
// Threads laden
// ----------------------------------------------------------
async function _loadThreads(reset = false) {
if (reset) { _offset = 0; _threads = []; }
const mainEl = document.getElementById('forum-main');
if (mainEl && reset) {
mainEl.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-8)">Lädt…</p>`;
}
try {
const params = { limit: LIMIT, offset: _offset };
if (_aktivKat !== 'alle') params.kategorie = _aktivKat;
const rows = await API.forum.threads(params);
_threads = reset ? rows : [..._threads, ...rows];
_offset += rows.length;
_renderList(rows.length < LIMIT);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden.');
}
}
// ----------------------------------------------------------
// Thread-Liste rendern
// ----------------------------------------------------------
function _renderList(noMore = false) {
if (_searching) return;
const el = document.getElementById('forum-main');
if (!el) return;
if (!_threads.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('chat-circle-dots')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Beiträge in dieser Kategorie.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="forum-first-btn">
Ersten Beitrag erstellen
</button>
</div>`;
document.getElementById('forum-first-btn')?.addEventListener('click', () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
_showCreateForm();
});
return;
}
el.innerHTML = `
<div class="forum-list-inner" id="forum-thread-list">
${_threads.map(_threadCardHTML).join('')}
</div>
${!noMore ? `<div style="text-align:center;padding:var(--space-4)">
<button class="btn btn-secondary btn-sm" id="forum-loadmore">Mehr laden</button>
</div>` : ''}
`;
el.querySelectorAll('.forum-thread-card').forEach(card => {
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
});
document.getElementById('forum-loadmore')?.addEventListener('click', () => {
_loadThreads(false);
});
}
function _threadCardHTML(t) {
const preview = t.text_preview
? _esc(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '')
: '';
const pinBadge = t.is_pinned ? `<span class="forum-pin-badge" title="Angepinnt">${UI.icon('push-pin')}</span>` : '';
const lockBadge = t.is_locked ? `<span class="forum-lock-badge" title="Gesperrt">${UI.icon('lock')}</span>` : '';
const fotoHtml = t.foto_preview
? `<img class="forum-card-thumb" src="${_esc(t.foto_preview)}" alt="" loading="lazy">`
: '';
return `
<div class="forum-thread-card" data-id="${t.id}">
<div class="forum-card-top">
<span class="forum-category-badge forum-category-badge--${_esc(t.kategorie)}">${_esc(t.kategorie)}</span>
${pinBadge}${lockBadge}
</div>
<div class="forum-card-content">
<div class="forum-card-main">
<div class="forum-card-title">${_esc(t.titel)}</div>
${preview ? `<div class="forum-card-preview">${preview}</div>` : ''}
<div class="forum-card-meta">
<span>${UI.icon('user')} ${_esc(t.autor_name || 'Unbekannt')}</span>
<span>${UI.icon('calendar-dots')} ${_fmtDate(t.created_at)}</span>
<span>${UI.icon('chat-circle-dots')} ${t.antworten || 0}</span>
<span class="${t.user_liked ? 'forum-liked' : ''}">${UI.icon('heart')} ${t.likes || 0}</span>
</div>
</div>
${fotoHtml}
</div>
</div>`;
}
// ----------------------------------------------------------
// Suche
// ----------------------------------------------------------
async function _doSearch(q) {
_searching = true;
const el = document.getElementById('forum-main');
if (el) el.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">Suche…</p>`;
try {
const results = await API.forum.search(q);
if (!document.getElementById('forum-main')) return;
if (!results.length) {
document.getElementById('forum-main').innerHTML = `
<div style="text-align:center;padding:var(--space-8)">
<div style="font-size:2rem;margin-bottom:var(--space-2)">${UI.icon('magnifying-glass')}</div>
<p style="color:var(--c-text-secondary)">Keine Ergebnisse für ${_esc(q)}"</p>
</div>`;
return;
}
document.getElementById('forum-main').innerHTML = `
<div class="forum-list-inner">
${results.map(t => _threadCardHTML({ ...t, foto_preview: null, is_pinned: 0, is_locked: 0, user_liked: false })).join('')}
</div>`;
document.getElementById('forum-main').querySelectorAll('.forum-thread-card').forEach(card => {
card.addEventListener('click', () => _openThread(parseInt(card.dataset.id)));
});
} catch (err) {
UI.toast.error(err.message || 'Suchfehler.');
}
}
// ----------------------------------------------------------
// Thread-Detail-Modal
// ----------------------------------------------------------
async function _openThread(threadId) {
let thread;
try {
thread = await API.forum.thread(threadId);
} catch (err) {
UI.toast.error(err.message);
return;
}
const uid = _appState.user?.id;
const isMod = !!_appState.user?.is_moderator;
const isOwn = uid && uid === thread.user_id;
const modToolbar = (isMod) ? `
<div class="forum-mod-toolbar">
<button class="btn btn-ghost btn-sm forum-mod-pin" title="${thread.is_pinned ? 'Unpin' : 'Anpinnen'}">
${UI.icon('push-pin')} ${thread.is_pinned ? 'Unpin' : 'Pin'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-lock" title="${thread.is_locked ? 'Entsperren' : 'Sperren'}">
${UI.icon('lock')} ${thread.is_locked ? 'Entsperren' : 'Sperren'}
</button>
<button class="btn btn-ghost btn-sm forum-mod-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Thread</button>
</div>` : '';
const fotoGallery = (thread.foto_urls?.length)
? `<div class="forum-foto-grid">${thread.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
: '';
const likeClass = thread.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
const postsHtml = (thread.posts?.length)
? thread.posts.map(p => _postHTML(p, uid, isMod)).join('')
: `<p style="color:var(--c-text-muted);font-style:italic;padding:var(--space-3) 0">Noch keine Antworten.</p>`;
const replySection = _appState.user && !thread.is_locked ? `
<div class="forum-reply-form">
<label class="form-label">Antwort schreiben</label>
<textarea class="form-control" id="forum-reply-text" rows="3"
placeholder="Deine Antwort…"></textarea>
<div class="forum-reply-actions">
<label class="btn btn-ghost btn-sm forum-upload-label" title="Foto anhängen">
${UI.icon('camera')}
<input type="file" accept="image/*" id="forum-reply-file" style="display:none">
</label>
<div id="forum-reply-previews" class="forum-upload-previews"></div>
</div>
</div>` : (!_appState.user
? `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">Bitte anmelden um zu antworten.</p>`
: `<p style="color:var(--c-text-muted);font-size:0.85rem;margin-top:var(--space-3)">${UI.icon('lock')} Dieser Thread ist gesperrt.</p>`
);
const body = `
<div class="forum-thread-detail">
${modToolbar}
<div class="forum-thread-header-row">
<span class="forum-category-badge forum-category-badge--${_esc(thread.kategorie)}">${_esc(thread.kategorie)}</span>
<span style="color:var(--c-text-muted);font-size:0.8rem">${_fmtDate(thread.created_at)}</span>
${thread.is_pinned ? `<span>${UI.icon('push-pin')}</span>` : ''}
${thread.is_locked ? `<span>${UI.icon('lock')}</span>` : ''}
</div>
<div class="forum-thread-body">
<p style="white-space:pre-wrap;word-break:break-word">${_esc(thread.text)}</p>
${fotoGallery}
</div>
<div class="forum-thread-author-row">
<div class="forum-avatar">${_esc(_initial(thread.autor_name))}</div>
<span style="font-size:0.85rem;color:var(--c-text-secondary)">${_esc(thread.autor_name || 'Unbekannt')}</span>
<div style="margin-left:auto;display:flex;gap:var(--space-2);align-items:center">
<button class="${likeClass}" id="thread-like-btn" data-count="${thread.likes || 0}">
${UI.icon('heart')} <span id="thread-like-count">${thread.likes || 0}</span>
</button>
${_appState.user && !isOwn ? `<button class="forum-report-btn" id="thread-report-btn">${UI.icon('flag')}</button>` : ''}
</div>
</div>
<div class="forum-posts-section">
<div class="forum-posts-divider">${thread.antworten || 0} Antworten</div>
<div id="forum-posts-list">${postsHtml}</div>
</div>
${replySection}
</div>
`;
const footer = _appState.user ? `
${(isOwn || isMod) ? `<button type="button" class="btn btn-ghost btn-sm" id="ft-delete-thread" style="color:var(--c-danger)">${UI.icon('trash')} Löschen</button>` : ''}
<button type="button" class="btn btn-secondary" id="ft-close">Schließen</button>
${(!thread.is_locked && _appState.user) ? `<button type="button" class="btn btn-primary" id="ft-reply">Antworten</button>` : ''}
` : `<button type="button" class="btn btn-primary" id="ft-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} ${_esc(thread.titel)}`, body, footer });
// Close
document.getElementById('ft-close')?.addEventListener('click', UI.modal.close);
// Like thread
document.getElementById('thread-like-btn')?.addEventListener('click', async () => {
if (!_appState.user) { UI.toast.info('Bitte erst anmelden.'); return; }
const btn = document.getElementById('thread-like-btn');
try {
const res = await API.forum.like('thread', thread.id);
thread.user_liked = res.liked;
thread.likes = res.count;
btn.classList.toggle('active', res.liked);
const countEl = document.getElementById('thread-like-count');
if (countEl) countEl.textContent = res.count;
// Update local state
const idx = _threads.findIndex(t => t.id === thread.id);
if (idx !== -1) { _threads[idx].likes = res.count; _threads[idx].user_liked = res.liked; }
} catch (err) { UI.toast.error(err.message); }
});
// Report thread
document.getElementById('thread-report-btn')?.addEventListener('click', () => {
_showReportForm('thread', thread.id);
});
// Delete thread
document.getElementById('ft-delete-thread')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Thread löschen?',
message: 'Der Thread wird unwiderruflich entfernt.',
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.forum.deleteThread(thread.id);
_threads = _threads.filter(t => t.id !== thread.id);
UI.modal.close();
_renderList(true);
UI.toast.success('Thread gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Moderator: pin/lock/delete
document.querySelector('.forum-mod-pin')?.addEventListener('click', async () => {
try {
await API.forum.patchThread(thread.id, { is_pinned: thread.is_pinned ? 0 : 1 });
UI.toast.success('Gespeichert.');
UI.modal.close();
_loadThreads(true);
} catch (err) { UI.toast.error(err.message); }
});
document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => {
try {
await API.forum.patchThread(thread.id, { is_locked: thread.is_locked ? 0 : 1 });
UI.toast.success('Gespeichert.');
UI.modal.close();
_loadThreads(true);
} catch (err) { UI.toast.error(err.message); }
});
document.querySelector('.forum-mod-delete-thread')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({ title: 'Thread löschen?', message: 'Moderator-Löschung.', confirmText: 'Löschen', danger: true });
if (!ok) return;
try {
await API.forum.deleteThread(thread.id);
_threads = _threads.filter(t => t.id !== thread.id);
UI.modal.close();
_renderList(true);
UI.toast.success('Thread gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Foto-Vollbild
document.getElementById('modal-container')?.querySelectorAll('.forum-foto-img').forEach(img => {
img.addEventListener('click', () => {
window.open(img.dataset.src || img.src, '_blank');
});
});
// Reply file preview
const replyFileInput = document.getElementById('forum-reply-file');
replyFileInput?.addEventListener('change', () => {
const previews = document.getElementById('forum-reply-previews');
if (!previews) return;
previews.innerHTML = '';
const files = Array.from(replyFileInput.files || []);
files.slice(0, 5).forEach(file => {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.src = url;
img.className = 'forum-upload-thumb';
previews.appendChild(img);
});
});
// Post-Löschen-Buttons binden
const postsListEl = document.getElementById('forum-posts-list');
if (postsListEl) _bindPostActions(postsListEl, thread.id, uid, isMod);
// Reply abschicken
document.getElementById('ft-reply')?.addEventListener('click', async () => {
const btn = document.getElementById('ft-reply');
const text = document.getElementById('forum-reply-text')?.value?.trim();
if (!text) { UI.toast.warning('Bitte Text eingeben.'); return; }
await UI.asyncButton(btn, async () => {
const post = await API.forum.addPost(thread.id, { text });
// Foto hochladen falls vorhanden
const files = Array.from(document.getElementById('forum-reply-file')?.files || []);
for (const file of files.slice(0, 5)) {
try {
await API.forum.uploadPostFoto(post.id, file);
} catch (e) { /* Foto-Upload-Fehler ignorieren */ }
}
thread.antworten = (thread.antworten || 0) + 1;
const idx = _threads.findIndex(t => t.id === thread.id);
if (idx !== -1) _threads[idx].antworten = thread.antworten;
const listEl = document.getElementById('forum-posts-list');
if (listEl) {
const placeholder = listEl.querySelector('p[style*="italic"]');
if (placeholder) listEl.innerHTML = '';
listEl.insertAdjacentHTML('beforeend', _postHTML(post, uid, isMod));
_bindPostActions(listEl, thread.id, uid, isMod);
}
document.getElementById('forum-reply-text').value = '';
const previews = document.getElementById('forum-reply-previews');
if (previews) previews.innerHTML = '';
UI.toast.success('Antwort gesendet.');
});
});
}
// ----------------------------------------------------------
// Post HTML
// ----------------------------------------------------------
function _postHTML(p, uid, isMod) {
if (p.is_deleted) {
return `<div class="forum-post forum-post--deleted" data-post-id="${p.id}">
<em>Beitrag wurde entfernt</em>
</div>`;
}
const isOwn = uid && uid === p.user_id;
const fotoHtml = (p.foto_urls?.length)
? `<div class="forum-foto-grid">${p.foto_urls.map(u =>
`<img src="${_esc(u)}" class="forum-foto-img" data-src="${_esc(u)}" alt="" loading="lazy">`
).join('')}</div>`
: '';
const likeClass = p.user_liked ? 'forum-like-btn active' : 'forum-like-btn';
const canDelete = isOwn || isMod;
return `
<div class="forum-post" data-post-id="${p.id}" data-user-id="${p.user_id || ''}">
<div class="forum-post-header">
<div class="forum-avatar forum-avatar--sm">${_esc(_initial(p.autor_name))}</div>
<span class="forum-post-author">${_esc(p.autor_name || 'Unbekannt')}</span>
<span class="forum-post-date">${_fmtDate(p.created_at)}</span>
</div>
<div class="forum-post-body">
<div class="forum-post-text">${_esc(p.text)}</div>
${fotoHtml}
</div>
<div class="forum-post-actions">
<button class="${likeClass} forum-post-like" data-post-id="${p.id}">
${UI.icon('heart')} <span class="forum-post-like-count">${p.likes || 0}</span>
</button>
${(!isOwn && uid) ? `<button class="forum-report-btn forum-post-report" data-post-id="${p.id}">${UI.icon('flag')}</button>` : ''}
${canDelete ? `<button class="btn btn-ghost btn-sm forum-post-delete" data-post-id="${p.id}" style="color:var(--c-danger);margin-left:auto">${UI.icon('trash')}</button>` : ''}
</div>
</div>`;
}
// ----------------------------------------------------------
// Post-Aktionen binden
// ----------------------------------------------------------
function _bindPostActions(container, threadId, uid, isMod) {
// Like
container.querySelectorAll('.forum-post-like:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', async () => {
if (!uid) { UI.toast.info('Bitte erst anmelden.'); return; }
const postId = parseInt(btn.dataset.postId);
try {
const res = await API.forum.like('post', postId);
btn.classList.toggle('active', res.liked);
const countEl = btn.querySelector('.forum-post-like-count');
if (countEl) countEl.textContent = res.count;
} catch (err) { UI.toast.error(err.message); }
});
});
// Report
container.querySelectorAll('.forum-post-report:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', () => {
_showReportForm('post', parseInt(btn.dataset.postId));
});
});
// Delete
container.querySelectorAll('.forum-post-delete:not([data-bound])').forEach(btn => {
btn.dataset.bound = '1';
btn.addEventListener('click', async () => {
const postId = parseInt(btn.dataset.postId);
const postEl = container.querySelector(`[data-post-id="${postId}"]`);
const ok = await UI.modal.confirm({
title: 'Antwort löschen?',
message: 'Dieser Beitrag wird entfernt.',
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.forum.deletePost(postId);
if (postEl) {
postEl.innerHTML = '<em style="color:var(--c-text-muted)">Beitrag wurde entfernt</em>';
postEl.className = 'forum-post forum-post--deleted';
}
const idx = _threads.findIndex(t => t.id === threadId);
if (idx !== -1 && _threads[idx].antworten > 0) _threads[idx].antworten--;
UI.toast.success('Beitrag gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
});
// Foto-Fullscreen
container.querySelectorAll('.forum-foto-img:not([data-bound])').forEach(img => {
img.dataset.bound = '1';
img.addEventListener('click', () => window.open(img.dataset.src || img.src, '_blank'));
});
}
// ----------------------------------------------------------
// Report-Formular
// ----------------------------------------------------------
function _showReportForm(targetType, targetId) {
const body = `
<form id="forum-report-form">
<div class="form-group">
<label class="form-label">Grund der Meldung</label>
<select class="form-control" name="grund">
<option value="spam">Spam</option>
<option value="beleidigung">Beleidigung / Hassrede</option>
<option value="falschinfo">Falsche Informationen</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rep-cancel">Abbrechen</button>
<button type="submit" form="forum-report-form" class="btn btn-danger flex-1">Melden</button>`;
UI.modal.open({ title: `${UI.icon('flag')} Inhalt melden`, body, footer });
document.getElementById('rep-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('forum-report-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="forum-report-form"][type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
await API.forum.report(targetType, targetId, fd.grund);
UI.modal.close();
UI.toast.success('Gemeldet. Danke!');
});
});
}
// ----------------------------------------------------------
// Neues Thema
// ----------------------------------------------------------
function _showCreateForm() {
const katOptions = KATEGORIEN.filter(k => k.key !== 'alle').map(k =>
`<option value="${k.key}" ${k.key === _aktivKat ? 'selected' : ''}>${_esc(k.label)}</option>`
).join('');
const body = `
<form id="forum-thread-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Kategorie</label>
<select class="form-control" name="kategorie">${katOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Titel *</label>
<input class="form-control" type="text" name="titel"
placeholder="Worum geht es?" required maxlength="200">
</div>
<div class="form-group">
<label class="form-label">Text * (min. 20 Zeichen)</label>
<textarea class="form-control" name="text" rows="5"
placeholder="Beschreibe dein Thema ausführlich…" required></textarea>
</div>
<div class="form-group">
<label class="form-label">Fotos (max. 5)</label>
<div class="forum-upload-area">
<label class="btn btn-secondary btn-sm" for="forum-thread-files">${UI.icon('camera')} Fotos auswählen</label>
<input type="file" id="forum-thread-files" accept="image/*" multiple style="display:none">
</div>
<div id="forum-thread-previews" class="forum-upload-previews" style="margin-top:var(--space-2)"></div>
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="ff-cancel">Abbrechen</button>
<button type="submit" form="forum-thread-form" class="btn btn-primary flex-1">${UI.icon('chat-circle-dots')} Erstellen</button>`;
UI.modal.open({ title: '+ Neues Thema', body, footer });
document.getElementById('ff-cancel')?.addEventListener('click', UI.modal.close);
// Foto-Vorschau
document.getElementById('forum-thread-files')?.addEventListener('change', e => {
const previews = document.getElementById('forum-thread-previews');
if (!previews) return;
previews.innerHTML = '';
Array.from(e.target.files || []).slice(0, 5).forEach(file => {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'forum-upload-thumb';
previews.appendChild(img);
});
});
document.getElementById('forum-thread-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="forum-thread-form"][type="submit"]');
const fd = UI.formData(e.target);
if ((fd.text || '').trim().length < 20) {
UI.toast.warning('Text muss mindestens 20 Zeichen lang sein.');
return;
}
await UI.asyncButton(btn, async () => {
const created = await API.forum.create({
kategorie: fd.kategorie,
titel: (fd.titel || '').trim(),
text: (fd.text || '').trim(),
});
// Fotos hochladen
const files = Array.from(document.getElementById('forum-thread-files')?.files || []);
for (const file of files.slice(0, 5)) {
try {
await API.forum.uploadThreadFoto(created.id, file);
} catch (e) { /* ignorieren */ }
}
_threads.unshift({
...created,
text_preview: created.text?.slice(0, 120) || '',
foto_preview: null,
});
UI.modal.close();
_renderList();
UI.toast.success('Thema erstellt!');
});
});
}
// ----------------------------------------------------------
// Mitgliederkarte
// ----------------------------------------------------------
async function _renderMembersMap() {
const el = document.getElementById('forum-main');
if (!el) return;
el.innerHTML = `
<div class="forum-members-section">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<strong>Mitglieder auf der Karte</strong>
${_appState.user ? `
<label class="forum-location-toggle">
<input type="checkbox" id="forum-loc-toggle" ${_appState.user.forum_show_location ? 'checked' : ''}>
<span>Meinen (ungefähren) Standort teilen</span>
</label>` : ''}
</div>
<div id="forum-map" class="forum-map-container"></div>
</div>`;
// Location-Toggle
document.getElementById('forum-loc-toggle')?.addEventListener('change', async e => {
const show = e.target.checked;
if (show) {
try {
const pos = await API.getLocation();
await API.forum.setLocation(pos.lat, pos.lon, true);
UI.toast.success('Standort geteilt.');
_loadMembersOnMap();
} catch (err) {
e.target.checked = false;
UI.toast.error('Standort konnte nicht ermittelt werden.');
}
} else {
try {
await API.forum.setLocation(null, null, false);
UI.toast.success('Standort versteckt.');
} catch (err) { UI.toast.error(err.message); }
}
});
await _loadLeaflet();
const mapEl = document.getElementById('forum-map');
if (!mapEl) return;
_map = L.map(mapEl, { zoomControl: true }).setView([51.0, 10.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(_map);
_loadMembersOnMap();
}
async function _loadMembersOnMap() {
if (!_map) return;
try {
const members = await API.forum.membersMap();
members.forEach(m => {
L.marker([m.lat, m.lon])
.bindPopup(`<strong>${_esc(m.vorname || '?')}</strong>`)
.addTo(_map);
});
} catch (err) {
console.error('Mitgliederkarte Fehler:', err);
}
}
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
// CSS
if (!document.querySelector('link[href*="leaflet.css"]')) {
const lCss = document.createElement('link');
lCss.rel = 'stylesheet';
lCss.href = '/css/leaflet.css';
document.head.appendChild(lCss);
}
// JS
await new Promise((resolve, reject) => {
if (window.L) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// Moderations-Panel
// ----------------------------------------------------------
async function _showModPanel() {
let reports;
try {
reports = await API.forum.reports();
} catch (err) {
UI.toast.error(err.message);
return;
}
const body = reports.length
? `<div class="forum-mod-reports">
${reports.map(r => `
<div class="forum-mod-report-item" data-id="${r.id}">
<div style="font-size:var(--text-sm)">
<strong>${_esc(r.target_type)} #${r.target_id}</strong>
${_esc(r.grund)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
von ${_esc(r.melder_name || '?')} · ${_fmtDate(r.created_at)}
</div>
<button class="btn btn-sm btn-secondary forum-resolve-btn" data-id="${r.id}" style="margin-top:var(--space-2)">
${UI.icon('check')} Erledigt
</button>
</div>`).join('')}
</div>`
: `<p style="color:var(--c-text-muted);text-align:center;padding:var(--space-6)">Keine offenen Berichte.</p>`;
const footer = `<button type="button" class="btn btn-primary flex-1" id="mod-close">Schließen</button>`;
UI.modal.open({ title: `${UI.icon('scales')} Moderationsberichte`, body, footer });
document.getElementById('mod-close')?.addEventListener('click', UI.modal.close);
document.querySelectorAll('.forum-resolve-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
try {
await API.forum.resolveReport(id);
btn.closest('.forum-mod-report-item')?.remove();
UI.toast.success('Erledigt.');
} catch (err) { UI.toast.error(err.message); }
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -0,0 +1,282 @@
/* ============================================================
BAN YARO Freunde-Seite
============================================================ */
window.Page_friends = (() => {
let _container = null;
let _searchTimer = null;
// ----------------------------------------------------------
function init(container) {
_container = container;
render();
}
// ----------------------------------------------------------
async function render() {
_container.innerHTML = `
<div style="padding:var(--space-4)">
<h2 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin-bottom:var(--space-4)">
Freunde
</h2>
<!-- Suche -->
<div class="friends-search-row">
<input id="fr-search" class="form-input" type="search"
placeholder="Namen suchen…" autocomplete="off" style="flex:1">
</div>
<div id="fr-search-results"></div>
<!-- Incoming requests -->
<div id="fr-incoming"></div>
<!-- Outgoing -->
<div id="fr-outgoing"></div>
<!-- Friends list -->
<div id="fr-list"></div>
</div>
`;
document.getElementById('fr-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => _doSearch(e.target.value.trim()), 400);
});
await _loadFriends();
}
// ----------------------------------------------------------
async function _loadFriends() {
try {
const data = await API.friends.list();
_renderIncoming(data.incoming);
_renderOutgoing(data.outgoing);
_renderFriends(data.friends);
_updateBadge(data.incoming.length);
} catch (e) {
if (e.status === 401) {
document.getElementById('fr-list').innerHTML =
`<div class="empty-state"><p>Bitte melde dich an, um Freunde zu verwalten.</p></div>`;
}
}
}
// ----------------------------------------------------------
function _updateBadge(count) {
const el = document.getElementById('friends-badge');
if (!el) return;
el.textContent = count;
el.style.display = count > 0 ? '' : 'none';
}
// ----------------------------------------------------------
function _renderIncoming(list) {
const el = document.getElementById('fr-incoming');
if (!list.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
Anfragen (${list.length})
</h3>
${list.map(r => `
<div class="friend-request-card">
<div class="friend-avatar">${_initial(r.requester_name)}</div>
<div style="flex:1">
<div style="font-weight:var(--weight-semibold)">${_esc(r.requester_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">möchte mit dir befreundet sein</div>
</div>
<div class="friend-item-actions">
<button class="btn btn-primary btn-sm"
onclick="Page_friends._accept(${r.id})">
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._decline(${r.id})">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
</div>
`).join('')}
`;
}
// ----------------------------------------------------------
function _renderOutgoing(list) {
const el = document.getElementById('fr-outgoing');
if (!list.length) { el.innerHTML = ''; return; }
el.innerHTML = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
Gesendete Anfragen
</h3>
${list.map(r => `
<div class="friend-item">
<div class="friend-avatar">${_initial(r.addressee_name)}</div>
<div class="friend-item-name">${_esc(r.addressee_name)}</div>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">ausstehend</span>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._cancel(${r.id})"
title="Anfrage zurückziehen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
`).join('')}
`;
}
// ----------------------------------------------------------
function _renderFriends(list) {
const el = document.getElementById('fr-list');
if (!list.length) {
el.innerHTML = `
<div class="empty-state" style="margin-top:var(--space-6)">
<svg class="ph-icon" style="font-size:3rem;opacity:0.3"><use href="/icons/phosphor.svg#users"></use></svg>
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">
Noch keine Freunde. Suche oben nach Nutzern!
</p>
</div>
`;
return;
}
el.innerHTML = `
<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin:var(--space-4) 0 var(--space-2)">
Freunde (${list.length})
</h3>
${list.map(f => `
<div class="friend-item">
<div class="friend-avatar">${_initial(f.friend_name)}</div>
<div class="friend-item-name">${_esc(f.friend_name)}</div>
<div class="friend-item-actions">
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._openChat(${f.friend_id})"
title="Nachricht senden">
<svg class="ph-icon"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
onclick="Page_friends._removeFriend(${f.friend_id}, '${_esc(f.friend_name)}')"
title="Freund entfernen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-minus"></use></svg>
</button>
</div>
</div>
`).join('')}
`;
}
// ----------------------------------------------------------
async function _doSearch(q) {
const el = document.getElementById('fr-search-results');
if (q.length < 2) { el.innerHTML = ''; return; }
try {
const results = await API.friends.search(q);
if (!results.length) {
el.innerHTML = `<div class="friends-search-results">
<div style="padding:var(--space-3) var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">
Keine Nutzer gefunden.
</div>
</div>`;
return;
}
el.innerHTML = `
<div class="friends-search-results">
${results.map(u => `
<div class="friend-result-item">
<div class="friend-avatar">${_initial(u.name)}</div>
<div style="flex:1;font-size:var(--text-sm)">${_esc(u.name)}</div>
<button class="btn btn-primary btn-sm"
onclick="Page_friends._sendRequest(${u.id}, this)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
Anfrage
</button>
</div>
`).join('')}
</div>
`;
} catch (e) {
el.innerHTML = '';
}
}
// ----------------------------------------------------------
async function _sendRequest(userId, btn) {
btn.disabled = true;
try {
await API.friends.sendRequest(userId);
UI.toast('Freundschaftsanfrage gesendet!', 'success');
document.getElementById('fr-search').value = '';
document.getElementById('fr-search-results').innerHTML = '';
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
btn.disabled = false;
}
}
async function _accept(id) {
try {
await API.friends.accept(id);
UI.toast('Freundschaft angenommen!', 'success');
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _decline(id) {
try {
await API.friends.decline(id);
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _cancel(id) {
try {
await API.friends.decline(id);
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _removeFriend(userId, name) {
if (!confirm(`${name} als Freund entfernen?`)) return;
try {
await API.friends.remove(userId);
UI.toast('Freund entfernt.', 'info');
await _loadFriends();
} catch (e) {
UI.toast(e.message, 'danger');
}
}
async function _openChat(userId) {
try {
const { conversation_id } = await API.chat.start(userId);
App.navigate('chat', true, { conversation_id });
} catch (e) {
UI.toast(e.message, 'danger');
}
}
// ----------------------------------------------------------
function _initial(name) {
return (name || '?')[0].toUpperCase();
}
function _esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ----------------------------------------------------------
return {
init,
_sendRequest, _accept, _decline, _cancel, _removeFriend, _openChat,
};
})();

View file

@ -13,13 +13,14 @@ window.Page_health = (() => {
let _activeTab = 'impfung';
const TABS = [
{ key: 'impfung', label: 'Impfpass', icon: '💉' },
{ key: 'tierarzt', label: 'Besuche', icon: '🩺' },
{ key: 'gewicht', label: 'Gewicht', icon: '⚖️' },
{ key: 'medikament', label: 'Medikamente',icon: '💊' },
{ key: 'allergie', label: 'Allergien', icon: '🌿' },
{ key: 'dokument', label: 'Dokumente', icon: '📄' },
{ key: 'praxen', label: 'Praxen', icon: '🏥' },
{ key: 'impfung', label: 'Impfpass', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>' },
{ key: 'tierarzt', label: 'Besuche', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'gewicht', label: 'Gewicht', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>' },
{ key: 'medikament', label: 'Medikamente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'symptomcheck', label: 'Symptom-Check', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>' },
];
// ----------------------------------------------------------
@ -56,7 +57,7 @@ window.Page_health = (() => {
async function _render() {
if (!_appState.activeDog) {
_container.innerHTML = UI.emptyState({
icon: '💉',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
@ -81,7 +82,7 @@ window.Page_health = (() => {
const isActive = dog.id === activeDogId;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}">`
: `<span style="font-size:2.5rem">🐕</span>`;
: `<span>${UI.icon('dog')}</span>`;
return `
<div class="diary-picker-card${isActive ? ' diary-picker-card--active' : ''}"
data-dog-id="${dog.id}">
@ -119,7 +120,7 @@ window.Page_health = (() => {
_container.innerHTML = `
<div class="health-header">
<button class="btn btn-secondary btn-sm" id="health-ki-btn">
KI-Zusammenfassung
${UI.icon('star')} KI-Zusammenfassung
</button>
</div>
<div id="health-reminders"></div>
@ -165,13 +166,17 @@ window.Page_health = (() => {
if (!items.length) { el.innerHTML = ''; return; }
const ICONS = { impfung: '💉', entwurmung: '🪱', medikament: '💊' };
const ICONS = {
impfung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
entwurmung: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg>',
medikament: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>',
};
el.innerHTML = `
<div style="padding:var(--space-3) var(--space-4) 0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">
📅 Anstehende Erinnerungen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calendar-dots"></use></svg> Anstehende Erinnerungen
</div>
${items.map(e => {
const ampel = _impfAmpel(e.naechstes);
@ -185,7 +190,7 @@ window.Page_health = (() => {
padding:var(--space-2) var(--space-3);margin-bottom:var(--space-1);
background:var(--c-surface);border-radius:var(--radius-md);
border-left:3px solid ${ampel.color === 'red' ? '#ef4444' : ampel.color === 'yellow' ? '#f59e0b' : '#22c55e'}">
<span style="font-size:1.2rem">${ICONS[e._typ] || '📋'}</span>
<span style="font-size:1.2rem">${ICONS[e._typ] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg>'}</span>
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-medium);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
@ -199,7 +204,7 @@ window.Page_health = (() => {
data-action="reminder-erledigt"
data-entry-id="${e.id}" data-entry-typ="${e._typ}"
style="flex-shrink:0;white-space:nowrap">
Erledigt
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Erledigt
</button>
</div>`;
}).join('')}
@ -302,13 +307,14 @@ window.Page_health = (() => {
const entries = _data[_activeTab] || [];
switch (_activeTab) {
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'impfung': content.innerHTML = _renderImpfungen(entries); break;
case 'tierarzt': content.innerHTML = _renderTierarzt(entries); break;
case 'gewicht': content.innerHTML = _renderGewicht(entries); break;
case 'medikament': content.innerHTML = _renderMedikamente(entries); break;
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'symptomcheck': _renderSymptomCheck(content); break;
}
_bindTabEvents(content);
@ -321,7 +327,7 @@ window.Page_health = (() => {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Impfung eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '💉', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>', title: 'Noch keine Impfungen', text: 'Trage alle Impfungen ein, um nichts zu verpassen.', action: addBtn
});
const items = entries.map(e => {
@ -337,7 +343,7 @@ window.Page_health = (() => {
${UI.time.format(e.datum + 'T00:00:00')}
${e.charge_nr ? ` · Ch.-Nr: ${_esc(e.charge_nr)}` : ''}
</div>
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">🏥 ${_esc(vetName)}</div>` : ''}
${vetName ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(vetName)}</div>` : ''}
${e.naechstes ? `<div class="health-card-next ampel-text-${ampel.color}">
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''}
@ -365,7 +371,7 @@ window.Page_health = (() => {
function _renderTierarzt(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Besuch eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '🩺', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Tierarztbesuche', text: 'Halte alle Tierarztbesuche fest.', action: addBtn
});
const items = entries.map(e => {
@ -383,7 +389,7 @@ window.Page_health = (() => {
${praxisName ? `
<div style="display:flex;align-items:center;gap:var(--space-1);
margin-top:var(--space-1);font-size:var(--text-sm);color:var(--c-text-secondary)">
🏥 ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxisName)}${praxisOrt ? ` · ${_esc(praxisOrt)}` : ''}
</div>` : ''}
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
@ -403,7 +409,7 @@ window.Page_health = (() => {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Gewicht eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '⚖️', title: 'Noch keine Gewichtseinträge', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg>', title: 'Noch keine Gewichtseinträge', action: addBtn
});
const sorted = [...entries].sort((a, b) => a.datum.localeCompare(b.datum));
@ -529,7 +535,7 @@ window.Page_health = (() => {
function _renderMedikamente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Medikament eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '💊', title: 'Noch keine Medikamente', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Medikamente', action: addBtn
});
const aktive = entries.filter(e => e.aktiv);
@ -554,7 +560,7 @@ window.Page_health = (() => {
return `
<div class="health-list">
${renderGroup(aktive, '💊 Aktuelle Medikamente')}
${renderGroup(aktive, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Aktuelle Medikamente')}
${renderGroup(inaktive, 'Vergangene Medikamente')}
</div>
<div style="text-align:center;padding:var(--space-4)">${addBtn}</div>
@ -567,7 +573,7 @@ window.Page_health = (() => {
function _renderAllergien(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Allergie eintragen</button>`;
if (!entries.length) return UI.emptyState({
icon: '🌿', title: 'Noch keine Allergien eingetragen', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', title: 'Noch keine Allergien eingetragen', action: addBtn
});
const SCHWEREGRAD = { leicht: '🟡', mittel: '🟠', schwer: '🔴' };
@ -598,7 +604,7 @@ window.Page_health = (() => {
function _renderDokumente(entries) {
const addBtn = `<button class="btn btn-primary btn-sm" data-action="add-entry">+ Dokument hinzufügen</button>`;
if (!entries.length) return UI.emptyState({
icon: '📄', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>', title: 'Noch keine Dokumente', text: 'Lade Impfpässe, Befunde und mehr hoch.', action: addBtn
});
const items = entries.map(e => {
@ -610,7 +616,7 @@ window.Page_health = (() => {
? `<img src="${e.datei_url}" class="health-doc-thumb" alt="Vorschau"
style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0">`
: `<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center;
font-size:2rem;flex-shrink:0">${isPdf ? '📄' : '📎'}</div>`}
font-size:2rem;flex-shrink:0"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg></div>`}
<div class="health-card-body">
<div class="health-card-title">${_esc(e.bezeichnung)}</div>
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
@ -619,7 +625,7 @@ window.Page_health = (() => {
? `<a href="${e.datei_url}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="margin-top:var(--space-2);display:inline-flex"
onclick="event.stopPropagation()">
${isPdf ? '📄 PDF öffnen' : '🖼️ Bild öffnen'}
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
</a>`
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
</div>
@ -668,7 +674,7 @@ window.Page_health = (() => {
${fields}
${entry.datei_url
? (entry.datei_typ === 'pdf'
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)">📄 PDF öffnen</a>`
? `<a href="${entry.datei_url}" target="_blank" class="btn btn-secondary btn-sm" style="margin-top:var(--space-3)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen</a>`
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
: ''}
</div>
@ -717,7 +723,7 @@ window.Page_health = (() => {
if (praxis) {
const adresse = [praxis.strasse, [praxis.plz, praxis.ort].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const tel = praxis.telefon ? ` · <a href="tel:${_esc(praxis.telefon)}">${_esc(praxis.telefon)}</a>` : '';
rows.push(['Praxis', `🏥 ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
rows.push(['Praxis', `<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> ${_esc(praxis.name)}${adresse ? `<br><small style="color:var(--c-text-secondary)">${_esc(adresse)}${tel}</small>` : tel}`]);
}
} else if (e.tierarzt_name) {
rows.push(['Tierarzt', _esc(e.tierarzt_name)]);
@ -940,7 +946,7 @@ window.Page_health = (() => {
: `<div class="form-group">
<div style="padding:var(--space-3);background:var(--c-bg);border-radius:var(--radius-md);
font-size:var(--text-sm);color:var(--c-text-secondary)">
🏥 Noch keine Praxis angelegt
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Noch keine Praxis angelegt
<button type="button" class="btn btn-ghost btn-sm" style="padding:0;font-size:inherit"
data-action="goto-praxen">Praxis im Tab Praxen anlegen</button>
</div>
@ -1062,7 +1068,7 @@ window.Page_health = (() => {
const inaktive = _praxen.filter(p => !p.aktiv);
if (!_praxen.length) return UI.emptyState({
icon: '🏥', title: 'Noch keine Praxis eingetragen',
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', title: 'Noch keine Praxis eingetragen',
text: 'Trage deine Tierarztpraxis ein für schnellen Zugriff.',
action: addBtn
});
@ -1070,7 +1076,7 @@ window.Page_health = (() => {
const renderCard = p => `
<div class="health-card praxis-card${!p.aktiv ? ' health-card--inactive' : ''}"
data-praxis-id="${p.id}" data-action="open-praxis">
<div style="font-size:1.5rem">${p.ist_notfallpraxis ? '🚨' : '🏥'}</div>
<div style="font-size:1.5rem">${p.ist_notfallpraxis ? '🚨' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>'}</div>
<div class="health-card-body">
<div class="health-card-title">
${_esc(p.name)}
@ -1225,6 +1231,111 @@ window.Page_health = (() => {
});
}
// ----------------------------------------------------------
// SYMPTOM-CHECK
// ----------------------------------------------------------
function _renderSymptomCheck(content) {
content.innerHTML = `
<div style="padding:var(--space-4)">
<div class="card" style="padding:var(--space-4)">
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Einschätzung kein Ersatz für den Tierarzt.
</p>
<div class="form-group">
<label class="form-label" for="symptom-input">Symptome</label>
<textarea id="symptom-input" class="form-control" rows="4"
placeholder="z.B. frisst nicht, trinkt viel, schläft mehr als sonst..."></textarea>
</div>
<button id="symptom-submit-btn" class="btn btn-primary" style="width:100%">
Symptome analysieren <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
</button>
<div id="symptom-result" style="display:none;margin-top:var(--space-5)"></div>
</div>
</div>
`;
content.querySelector('#symptom-submit-btn').addEventListener('click', async function () {
const btn = this;
const textarea = content.querySelector('#symptom-input');
const resultEl = content.querySelector('#symptom-result');
const symptoms = textarea.value.trim();
if (!symptoms) {
UI.toast.warning('Bitte Symptome eingeben.');
return;
}
await UI.asyncButton(btn, async () => {
resultEl.style.display = 'none';
resultEl.innerHTML = '';
let result;
try {
result = await API.post(
`/dogs/${_appState.activeDog.id}/health/symptom-check`,
{ symptoms }
);
} catch (err) {
if (err.status === 402) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-warning,#f59e0b)">
<p style="margin:0;font-size:var(--text-sm)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg> Dieses Feature benötigt Ban Yaro Plus oder einen laufenden KI-Server.
</p>
</div>`;
} else if (err.status === 503) {
resultEl.innerHTML = `
<div class="card" style="padding:var(--space-4);border:1px solid var(--c-danger,#ef4444)">
<p style="margin:0;font-size:var(--text-sm)">
KI-Server nicht erreichbar. Bitte später versuchen.
</p>
</div>`;
} else {
UI.toast.error(err.message || 'Fehler bei der Symptomanalyse.');
return;
}
resultEl.style.display = '';
return;
}
const DRINGLICHKEIT = {
beobachten: { badgeClass: 'badge-success', label: '🟢 Beobachten' },
tierarzt: { badgeClass: 'badge-warning', label: '🟡 Zum Tierarzt' },
notfall: { badgeClass: 'badge-danger', label: '🔴 Notfall — sofort zum Tierarzt!' },
};
const d = DRINGLICHKEIT[result.dringlichkeit] || { badgeClass: 'badge-primary', label: _esc(result.dringlichkeit) };
const hinweiseHtml = (result.hinweise || []).length
? `<ul style="margin:var(--space-2) 0 0;padding-left:var(--space-5);font-size:var(--text-sm)">
${result.hinweise.map(h => `<li style="margin-bottom:var(--space-1)">${_esc(h)}</li>`).join('')}
</ul>`
: '';
const zumTierarztHtml = result.zum_tierarzt_wenn
? `<div style="margin-top:var(--space-3);padding:var(--space-3);
background:var(--c-surface-alt,var(--c-surface));
border-radius:var(--radius-md);font-size:var(--text-sm)">
<strong>Zum Tierarzt wenn:</strong> ${_esc(result.zum_tierarzt_wenn)}
</div>`
: '';
resultEl.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
<span class="badge ${d.badgeClass}" style="font-size:var(--text-sm);padding:var(--space-1) var(--space-3)">
${d.label}
</span>
</div>
${result.einschaetzung
? `<p style="font-size:var(--text-sm);line-height:1.6;margin:0">${_esc(result.einschaetzung)}</p>`
: ''}
${hinweiseHtml}
${zumTierarztHtml}
`;
resultEl.style.display = '';
});
});
}
// ----------------------------------------------------------
// KI-ZUSAMMENFASSUNG
// ----------------------------------------------------------
@ -1235,7 +1346,7 @@ window.Page_health = (() => {
try {
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id);
UI.modal.open({
title: '✨ KI-Gesundheitsbericht',
title: `${UI.icon('star')} KI-Gesundheitsbericht`,
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
});
} catch (err) {

View file

@ -0,0 +1,413 @@
/* ============================================================
BAN YARO Hunde-Knigge
Seiten-Modul: Begegnungen, Community-Voting, KI-Rat, Haftpflicht.
============================================================ */
window.Page_knigge = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
// Voting-State: { szenario_id: { counts: {}, user_answer: null } }
const _voteState = {};
// ----------------------------------------------------------
// HARDCODED INHALTE
// ----------------------------------------------------------
const BEGEGNUNGEN = [
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>',
titel: 'Fremder Hund',
tipps: 'Kurze Leine, ruhig bleiben, Hunde schnüffeln lassen wenn beide entspannt. Eskalation: weglenken, Richtung wechseln.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#users"></use></svg>',
titel: 'Kinder',
tipps: 'Hund nie unbeaufsichtigt mit Kindern. Kind fragen ob es streicheln darf. Hund dahinter positionieren, nicht zwischen Kind und Weg.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#person-simple-run"></use></svg>',
titel: 'Radfahrer',
tipps: 'Hund an die Seite nehmen. Fahrrad = potentielle Bedrohung für manche Hunde. Frühzeitig weglenken.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#person-simple-run"></use></svg>',
titel: 'Jogger',
tipps: 'Kurze Leine, Abstand halten, Hund nicht anspringen lassen.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>',
titel: 'ÖPNV',
tipps: 'Maulkorbpflicht gilt im ÖPNV (Deutschland-weit). Kleine Hunde in Transportbox kostenlos, große Hunde brauchen Fahrschein. Regeln je Stadt unterschiedlich.',
},
{
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>',
titel: 'Supermarkt / Geschäfte',
tipps: 'Grundsätzlich Hausrecht des Betreibers. "Hunde willkommen"-Schild = explizite Einladung. Im Zweifel fragen. Außen anbinden nur kurz und beaufsichtigt.',
},
];
const SZENARIEN = [
{
id: 'begegnung_leine',
frage: 'Dein Hund ist gut sozialisiert und läuft frei. Ein angeleinten Hund kommt entgegen. Was tust du?',
antworten: [
{ key: 'a', text: 'Hund weiterlaufen lassen — er ist ja friedlich' },
{ key: 'b', text: 'Hund anleinen und Abstand halten' },
{ key: 'c', text: 'Besitzer fragen ob ein Treffen ok ist' },
],
richtig: 'b',
erklaerung: 'Freilaufende Hunde auf angeleine Hunde zulaufen zu lassen ist unhöflich und kann den angeleinten Hund in Stress versetzen ("Leinenfrust"). Immer erst anleinen und Abstand halten.',
},
{
id: 'gassi_kot',
frage: 'Dein Hund macht sein Geschäft im Park abseits des Weges im Gebüsch. Was machst du?',
antworten: [
{ key: 'a', text: 'Liegenlassen — im Gebüsch stört es niemanden' },
{ key: 'b', text: 'Aufsammeln, auch wenn es versteckt liegt' },
{ key: 'c', text: 'Nur aufsammeln wenn jemand zuschaut' },
],
richtig: 'b',
erklaerung: 'Kot grundsätzlich immer aufsammeln — auch im Gebüsch. Kinder spielen überall, und Parasiten (z.B. Spulwurm) können für Menschen gefährlich sein.',
},
{
id: 'restaurant_hund',
frage: 'Im Restaurant-Außenbereich sitzt du mit deinem Hund. Ein anderer Gast bittet dich deinen Hund wegzunehmen weil er Angst hat. Was tust du?',
antworten: [
{ key: 'a', text: 'Ablehnen — Außenbereich ist hundefreundlich' },
{ key: 'b', text: 'Hund wegsetzen oder selbst weiter hinten platzieren' },
{ key: 'c', text: 'Personal entscheiden lassen' },
],
richtig: 'c',
erklaerung: 'Das Personal / der Betreiber entscheidet über das Hausrecht. Gut wäre es, selbst Kompromissbereitschaft zu zeigen und den Hund etwas wegzurücken — das deeskaliert und signalisiert Rücksicht.',
},
{
id: 'anleine_pflicht',
frage: 'Im Park gibt es keine Schilder. Muss dein Hund an die Leine?',
antworten: [
{ key: 'a', text: 'Nein — kein Schild bedeutet keine Pflicht' },
{ key: 'b', text: 'Kommt auf die Gemeindeordnung an' },
{ key: 'c', text: 'Ja — immer Leinenpflicht in öffentlichen Parks' },
],
richtig: 'b',
erklaerung: 'Leinenpflicht ist Ländersache und variiert stark. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften oder Parks. Im Zweifel Hund anleinen oder Gemeindewebsite prüfen.',
},
];
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadAllVotes();
}
function refresh() {
// statische Seite — kein Reload nötig
}
// ----------------------------------------------------------
// HAUPT-RENDER
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div id="knigge-wrap">
${_renderBegegnungen()}
${_renderVoting()}
${_renderKiRat()}
${_renderHaftpflicht()}
</div>
`;
_bindAccordion();
_bindVoting();
_bindKiRat();
}
// ----------------------------------------------------------
// SECTION 1: BEGEGNUNGEN — Accordion-Karten
// ----------------------------------------------------------
function _renderBegegnungen() {
const cards = BEGEGNUNGEN.map((b, i) => `
<div class="knigge-accordion" id="acc-${i}">
<button class="knigge-accordion-head" data-acc="${i}" aria-expanded="false">
<span>${b.icon} <strong>${_esc(b.titel)}</strong></span>
<span class="knigge-accordion-arrow">${UI.icon('caret-down')}</span>
</button>
<div class="knigge-accordion-body" id="acc-body-${i}" hidden>
<p style="color:var(--c-text);line-height:1.6">${_esc(b.tipps)}</p>
</div>
</div>
`).join('');
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('paw-print')} Begegnungen
</h2>
<div class="card" style="padding:0;overflow:hidden">
${cards}
</div>
`;
}
function _bindAccordion() {
_container.querySelectorAll('.knigge-accordion-head').forEach(btn => {
btn.addEventListener('click', () => {
const i = btn.dataset.acc;
const body = document.getElementById(`acc-body-${i}`);
const arrow = btn.querySelector('.knigge-accordion-arrow');
const open = !body.hidden;
body.hidden = open;
btn.setAttribute('aria-expanded', String(!open));
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
});
});
}
// ----------------------------------------------------------
// SECTION 2: COMMUNITY VOTING
// ----------------------------------------------------------
function _renderVoting() {
const cards = SZENARIEN.map(s => `
<div class="card" style="margin-bottom:var(--space-4)" id="sz-${s.id}">
<p style="font-weight:var(--weight-semibold);margin-bottom:var(--space-3);line-height:1.5">
${_esc(s.frage)}
</p>
<div class="knigge-vote-options" id="opts-${s.id}">
${s.antworten.map(a => `
<button class="knigge-vote-btn btn btn-secondary"
data-sz="${s.id}" data-key="${a.key}"
style="width:100%;margin-bottom:var(--space-2);justify-content:flex-start;text-align:left">
${_esc(a.text)}
</button>
`).join('')}
</div>
<div class="knigge-vote-result hidden" id="res-${s.id}"></div>
</div>
`).join('');
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('star')} Was wäre richtig?
</h2>
${cards}
`;
}
function _bindVoting() {
_container.querySelectorAll('.knigge-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const szId = btn.dataset.sz;
const key = btn.dataset.key;
if (!_appState.user) {
UI.toast.warning('Bitte melde dich an um abzustimmen.');
return;
}
try {
const result = await API.knigge.vote(szId, key);
_voteState[szId] = { counts: result.counts, user_answer: result.user_answer };
_renderVoteResult(szId);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
}
async function _loadAllVotes() {
for (const s of SZENARIEN) {
try {
const result = await API.knigge.votes(s.id);
_voteState[s.id] = { counts: result.counts, user_answer: result.user_answer };
if (result.user_answer) {
_renderVoteResult(s.id);
}
} catch {
// ignorieren — Votes werden on-demand geladen
}
}
}
function _renderVoteResult(szId) {
const szenario = SZENARIEN.find(s => s.id === szId);
if (!szenario) return;
const state = _voteState[szId];
if (!state) return;
const optsEl = document.getElementById(`opts-${szId}`);
const resEl = document.getElementById(`res-${szId}`);
if (!optsEl || !resEl) return;
// Optionen ausblenden
optsEl.classList.add('hidden');
resEl.classList.remove('hidden');
const counts = state.counts || {};
const userAnswer = state.user_answer;
const total = Object.values(counts).reduce((s, c) => s + c, 0) || 1;
const isCorrect = userAnswer === szenario.richtig;
const bars = szenario.antworten.map(a => {
const cnt = counts[a.key] || 0;
const pct = Math.round((cnt / total) * 100);
const isU = a.key === userAnswer;
const isR = a.key === szenario.richtig;
const color = isR
? 'var(--c-success, #22c55e)'
: (isU && !isR ? 'var(--c-danger, #ef4444)' : 'var(--c-border)');
return `
<div style="margin-bottom:var(--space-3)">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;font-size:var(--text-sm)">
<span style="color:${isU ? 'var(--c-text)' : 'var(--c-text-secondary)'};font-weight:${isU ? 'var(--weight-semibold)' : 'normal'}">
${isU ? UI.icon('arrow-right') + ' ' : ''}${_esc(a.text)}${isR ? ' ' + UI.icon('check') : ''}
</span>
<span style="color:var(--c-text-secondary)">${pct}% (${cnt})</span>
</div>
<div style="background:var(--c-surface-2);border-radius:4px;height:8px;overflow:hidden">
<div style="width:${pct}%;background:${color};height:8px;border-radius:4px;transition:width 0.4s"></div>
</div>
</div>
`;
}).join('');
const badge = isCorrect
? `<span style="color:var(--c-success,#22c55e);font-weight:var(--weight-semibold)">${UI.icon('check')} Richtig!</span>`
: `<span style="color:var(--c-danger,#ef4444);font-weight:var(--weight-semibold)">${UI.icon('x')} Nicht ganz — </span>`;
resEl.innerHTML = `
<div style="margin-bottom:var(--space-4)">${bars}</div>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);line-height:1.5">
${badge}
<span style="color:var(--c-text-secondary)">${_esc(szenario.erklaerung)}</span>
</div>
`;
}
// ----------------------------------------------------------
// SECTION 3: KI-SITUATIONSBERATER
// ----------------------------------------------------------
function _renderKiRat() {
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('robot')} KI-Situationsberater
</h2>
<div class="card">
<textarea id="ki-situation-input" class="form-control"
rows="3"
placeholder="Beschreibe deine Situation…"
style="margin-bottom:var(--space-3)"></textarea>
<button class="btn btn-primary" id="ki-rat-btn" style="width:100%">
Rat holen ${UI.icon('robot')}
</button>
<div id="ki-rat-result" style="margin-top:var(--space-4);display:none"></div>
</div>
`;
}
function _bindKiRat() {
const btn = _container.querySelector('#ki-rat-btn');
const input = _container.querySelector('#ki-situation-input');
const result = _container.querySelector('#ki-rat-result');
btn?.addEventListener('click', async () => {
const situation = input?.value?.trim();
if (!situation) {
UI.toast.warning('Bitte beschreibe zuerst deine Situation.');
return;
}
if (!_appState.user) {
UI.toast.warning('Bitte melde dich an um den KI-Rat zu nutzen.');
return;
}
UI.setLoading(btn, true);
result.style.display = 'none';
try {
const data = await API.knigge.kiRat(situation);
result.innerHTML = `
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-4);line-height:1.6;color:var(--c-text)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('robot')} KI-Rat</div>
${_esc(data.rat)}
</div>
`;
result.style.display = 'block';
} catch (err) {
const is402 = err.status === 402 || err.status === 503;
result.innerHTML = `
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-4);color:var(--c-text-secondary);font-size:var(--text-sm)">
${is402
? 'Für KI-Rat wird Ban Yaro Plus oder ein laufender KI-Server benötigt.'
: _esc(err.message || 'Fehler beim KI-Abruf.')}
</div>
`;
result.style.display = 'block';
} finally {
UI.setLoading(btn, false);
}
});
}
// ----------------------------------------------------------
// SECTION 4: HAFTPFLICHT-HINWEISE
// ----------------------------------------------------------
function _renderHaftpflicht() {
return `
<h2 style="font-size:var(--text-lg);font-weight:700;margin:var(--space-6) 0 var(--space-3)">
${UI.icon('shield')} Haftpflicht-Hinweise
</h2>
<div class="card" style="margin-bottom:var(--space-8)">
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-3)">
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('scales')}</span>
<span style="color:var(--c-text);line-height:1.5">
Hundehalter haften unbegrenzt für Schäden die ihr Hund verursacht
(§ 833 BGB) auch ohne Verschulden.
</span>
</li>
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('map-trifold')}</span>
<span style="color:var(--c-text);line-height:1.5">
Eine Hundehaftpflichtversicherung ist in einigen Bundesländern
(Bayern, Hamburg, Berlin u.a.) Pflicht.
</span>
</li>
<li style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:1.2rem;flex-shrink:0">${UI.icon('info')}</span>
<span style="color:var(--c-text);line-height:1.5">
Empfehlung: Absicherung ab ~50 /Jahr.
</span>
</li>
</ul>
<p style="margin-top:var(--space-4);font-size:var(--text-xs);color:var(--c-text-muted)">
Dies ist keine Rechtsberatung.
</p>
</div>
`;
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -0,0 +1,688 @@
/* ============================================================
BAN YARO Verlorener Hund (Sprint 11)
Seiten-Modul: Leaflet-Karte + Meldungsliste + Melden-Formular.
============================================================ */
window.Page_lost = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _map = null;
let _markers = [];
let _userMarker = null;
let _reports = [];
let _userPos = null;
let _leafletLoaded = false;
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH — Navigation zur bereits geladenen Seite
// ----------------------------------------------------------
async function refresh() {
if (_userPos) await _loadReports();
}
// ----------------------------------------------------------
// OPEN NEW — vom + Button
// ----------------------------------------------------------
function openNew() {
_showReportForm();
}
// ----------------------------------------------------------
// RENDER — Grundstruktur aufbauen
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
<button class="btn btn-secondary" id="lost-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
<button class="btn btn-primary" id="lost-btn-report">${UI.icon('magnifying-glass')} Hund vermisst melden</button>
</div>
<div id="lost-map"
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2)">
</div>
<div style="font-size:10px;color:var(--c-text-secondary);
text-align:right;margin-bottom:var(--space-4);
padding:2px var(--space-2) 0">
© OpenStreetMap-Mitwirkende
</div>
<p id="lost-info"
style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-3)">
Standort wird ermittelt
</p>
<div id="lost-held"></div>
<div id="lost-list"></div>
`;
document.getElementById('lost-btn-locate')
?.addEventListener('click', _locateUser);
document.getElementById('lost-btn-report')
?.addEventListener('click', _showReportForm);
await _loadLeaflet();
_initMap();
setTimeout(() => _map?.invalidateSize(), 100);
await _locateAndLoad();
}
// ----------------------------------------------------------
// LEAFLET DYNAMISCH LADEN
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
await new Promise(resolve => {
if (document.querySelector('link[href*="leaflet"]')) { resolve(); return; }
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
link.onload = resolve;
link.onerror = resolve;
document.head.appendChild(link);
});
await new Promise((resolve, reject) => {
if (document.querySelector('script[src*="leaflet"]')) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// KARTE INITIALISIEREN
// ----------------------------------------------------------
function _initMap() {
const mapEl = document.getElementById('lost-map');
if (!mapEl || !window.L || _map) return;
_map = L.map('lost-map', { zoomControl: true, attributionControl: false })
.setView([51.1657, 10.4515], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(_map);
}
// ----------------------------------------------------------
// STANDORT ERMITTELN + LADEN
// ----------------------------------------------------------
async function _locateAndLoad() {
try {
_userPos = await API.getLocation({ timeout: 8000 });
_showUserOnMap();
} catch {
_userPos = null;
}
await _loadReports();
}
async function _locateUser() {
const btn = document.getElementById('lost-btn-locate');
UI.setLoading(btn, true);
try {
_userPos = await API.getLocation({ timeout: 8000 });
_showUserOnMap();
if (_map) _map.setView([_userPos.lat, _userPos.lon], 13);
await _loadReports();
} catch {
UI.toast.warning('Standort konnte nicht ermittelt werden.');
}
UI.setLoading(btn, false);
}
function _showUserOnMap() {
if (!_map || !window.L || !_userPos) return;
if (_userMarker) _map.removeLayer(_userMarker);
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
radius : 9,
fillColor : '#3498db',
color : '#fff',
weight : 2,
fillOpacity : 0.9,
}).addTo(_map).bindPopup('<b>Du bist hier</b>');
_map.setView([_userPos.lat, _userPos.lon], 13);
}
// ----------------------------------------------------------
// MELDUNGEN LADEN
// ----------------------------------------------------------
async function _loadReports() {
const infoEl = document.getElementById('lost-info');
if (!_userPos) {
_reports = [];
_renderHeld();
_renderList();
if (infoEl) infoEl.textContent =
'Standort unbekannt — bitte Standort freigeben (📍 Mein Standort).';
return;
}
try {
_reports = await API.lost.list(_userPos.lat, _userPos.lon, 25);
_renderMarkers();
_renderHeld();
_renderList();
_updateBadge(_reports.length);
if (infoEl) {
infoEl.textContent = _reports.length > 0
? `${_reports.length} vermisste${_reports.length !== 1 ? 'r Hund' : 'r Hund'} im Umkreis von 25 km`
: 'Keine vermissten Hunde in deiner Nähe (25 km Radius). 🐾';
}
} catch {
UI.toast.error('Meldungen konnten nicht geladen werden.');
}
}
// ----------------------------------------------------------
// KARTEN-MARKER
// ----------------------------------------------------------
function _renderMarkers() {
if (!_map || !window.L) return;
_markers.forEach(m => _map.removeLayer(m));
_markers = [];
_reports.forEach(r => {
const icon = L.divIcon({
className : '',
html : `<div style="
background:#e74c3c;color:#fff;border-radius:50%;
width:34px;height:34px;
display:flex;align-items:center;justify-content:center;
font-size:17px;box-shadow:0 2px 6px rgba(0,0,0,.35);
border:2px solid #fff">🐕</div>`,
iconSize : [34, 34],
iconAnchor : [17, 17],
});
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
const marker = L.marker([r.lat, r.lon], { icon })
.addTo(_map)
.bindPopup(`
<b>🔍 ${_escape(r.name)}</b><br>
${r.rasse ? _escape(r.rasse) + '<br>' : ''}
${distStr ? `<small>📍 ${distStr} entfernt</small><br>` : ''}
<small>📅 ${_fmtDate(r.created_at)}</small>
`);
marker.on('click', () => _openDetail(r));
_markers.push(marker);
});
}
// ----------------------------------------------------------
// HELD DES TAGES
// ----------------------------------------------------------
function _renderHeld() {
const heldEl = document.getElementById('lost-held');
if (!heldEl) return;
// Letzter gefundener Hund (is_active=0, gefunden_at gesetzt) — wir laden
// sie nicht separat, daher nutzen wir die aktiven; für "Held" einen eigenen
// API-Call wäre übertrieben. Stattdessen zeigen wir es nur wenn die Liste
// kommt und wir einen kürzlich-gefundenen kennen. Wir überspringen hier
// den separaten Endpunkt und blenden die Sektion aus wenn leer.
heldEl.innerHTML = '';
}
// ----------------------------------------------------------
// LISTE
// ----------------------------------------------------------
function _renderList() {
const listEl = document.getElementById('lost-list');
if (!listEl) return;
if (_reports.length === 0) {
listEl.innerHTML = UI.emptyState({
icon : '🐾',
title : 'Keine vermissten Hunde',
text : 'In deiner Nähe (25 km) werden aktuell keine Hunde vermisst.',
action: `<button class="btn btn-primary" id="lost-empty-report">🔍 Hund melden</button>`,
});
listEl.querySelector('#lost-empty-report')
?.addEventListener('click', _showReportForm);
return;
}
listEl.innerHTML = _reports.map(r => _reportCard(r)).join('');
listEl.querySelectorAll('[data-lost-id]').forEach(card => {
card.addEventListener('click', () => {
const r = _reports.find(x => x.id === parseInt(card.dataset.lostId));
if (r) _openDetail(r);
});
});
}
function _reportCard(r) {
const isOwn = _appState.user && _appState.user.id === r.user_id;
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
return `
<div class="card" data-lost-id="${r.id}"
style="cursor:pointer;margin-bottom:var(--space-3);
border-left:4px solid #e74c3c">
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
${r.foto_url
? `<img src="${r.foto_url}" alt="Foto"
loading="lazy"
style="width:72px;height:72px;object-fit:cover;
border-radius:var(--radius-md);flex-shrink:0">`
: `<div style="width:72px;height:72px;background:var(--c-surface-2);
border-radius:var(--radius-md);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-size:2rem">🐕</div>`}
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-1);flex-wrap:wrap">
<span style="font-weight:var(--weight-semibold);font-size:var(--text-base)">
${_escape(r.name)}
</span>
${r.rasse
? `<span class="badge">${_escape(r.rasse)}</span>`
: ''}
${isOwn
? '<span class="badge badge-warning">Meine Meldung</span>'
: ''}
${distStr
? `<span style="margin-left:auto;color:var(--c-text-secondary);
font-size:var(--text-sm);white-space:nowrap">
📍 ${distStr}
</span>`
: ''}
</div>
<p style="margin:0 0 var(--space-1);font-size:var(--text-sm);
color:var(--c-text)">
${_escape(r.beschreibung.slice(0, 120))}${r.beschreibung.length > 120 ? '…' : ''}
</p>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary)">
Gemeldet ${_fmtDate(r.created_at)}
${r.melder_name ? '· ' + _escape(r.melder_name.split(' ')[0]) : ''}
</div>
</div>
</div>
</div>
`;
}
// ----------------------------------------------------------
// DETAIL-MODAL
// ----------------------------------------------------------
function _openDetail(r) {
const isOwn = _appState.user && _appState.user.id === r.user_id;
const isAdmin = _appState.user?.rolle === 'admin';
const distStr = r.distanz_m !== undefined
? (r.distanz_m < 1000 ? `${r.distanz_m} m` : `${(r.distanz_m / 1000).toFixed(1)} km`)
: '';
const body = `
${r.foto_url
? `<img src="${r.foto_url}" alt="Foto"
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: ''}
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-danger">🐕 ${_escape(r.name)}</span>
${r.rasse ? `<span class="badge">${_escape(r.rasse)}</span>` : ''}
</div>
<p style="white-space:pre-wrap;margin-bottom:var(--space-3)">
${_escape(r.beschreibung)}
</p>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4);line-height:1.8">
<div>📍 ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}${distStr ? ' (' + distStr + ' entfernt)' : ''}</div>
<div>📅 Gemeldet: ${_fmtDate(r.created_at)}</div>
${r.melder_name ? `<div>👤 Gemeldet von: ${_escape(r.melder_name.split(' ')[0])}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap">
<button class="btn btn-secondary flex-1" id="detail-lost-map">🗺 Auf Karte</button>
${isOwn || isAdmin
? `<button class="btn btn-nature flex-1" id="detail-lost-found">🎉 Gefunden!</button>`
: ''}
${isOwn || isAdmin
? `<button class="btn btn-danger flex-1" id="detail-lost-delete">🗑 Löschen</button>`
: ''}
</div>
`;
UI.modal.open({ title: `🔍 ${_escape(r.name)} wird vermisst`, body });
document.getElementById('detail-lost-map')?.addEventListener('click', () => {
UI.modal.close();
if (_map) {
_map.setView([r.lat, r.lon], 16);
document.getElementById('lost-map')
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
const marker = _markers[_reports.findIndex(x => x.id === r.id)];
marker?.openPopup();
}
});
document.getElementById('detail-lost-found')?.addEventListener('click', () => {
_showFoundDialog(r);
});
document.getElementById('detail-lost-delete')?.addEventListener('click', async () => {
if (!confirm(`Meldung für ${r.name} wirklich löschen?`)) return;
try {
await API.lost.delete(r.id);
_reports = _reports.filter(x => x.id !== r.id);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.modal.close();
UI.toast.success('Meldung gelöscht.');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Löschen.');
}
});
}
// ----------------------------------------------------------
// GEFUNDEN-DIALOG
// ----------------------------------------------------------
function _showFoundDialog(r) {
UI.modal.open({
title: `🎉 ${_escape(r.name)} gefunden?`,
body: `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
Wurde ${_escape(r.name)} wiedergefunden? Die Meldung wird als
abgeschlossen markiert und aus der Liste entfernt.
</p>
`,
footer: `
<button class="btn btn-secondary" id="found-cancel">Abbrechen</button>
<button class="btn btn-nature" id="found-confirm">🎉 Ja, gefunden!</button>
`,
});
document.getElementById('found-cancel')
?.addEventListener('click', UI.modal.close);
document.getElementById('found-confirm')?.addEventListener('click', async () => {
const btn = document.getElementById('found-confirm');
await UI.asyncButton(btn, async () => {
await API.lost.markFound(r.id);
_reports = _reports.filter(x => x.id !== r.id);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.modal.close();
UI.toast.success(`${r.name} ist wieder da! 🎉`);
});
});
}
// ----------------------------------------------------------
// MELDE-FORMULAR
// ----------------------------------------------------------
function _showReportForm() {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden, um eine Meldung abzuschicken.');
App.navigate('settings');
return;
}
// Eigene registrierte Hunde für Dropdown
const dogs = _appState.dogs || [];
const dogOpts = dogs.length > 0
? `<option value="">— kein registrierter Hund —</option>` +
dogs.map(d => `<option value="${d.id}">${_escape(d.name)}${d.rasse ? ' (' + _escape(d.rasse) + ')' : ''}</option>`).join('')
: '';
const body = `
<form id="lost-form" autocomplete="off">
${dogs.length > 0 ? `
<div class="form-group">
<label class="form-label">
Registrierter Hund
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<select class="form-control" name="dog_id" id="lf-dog-select">
${dogOpts}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Name des Hundes *</label>
<input class="form-control" type="text" name="name" id="lf-name"
placeholder="z. B. Bello" required>
</div>
<div class="form-group">
<label class="form-label">
Rasse
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="text" name="rasse"
placeholder="z. B. Labrador">
</div>
<div class="form-group">
<label class="form-label">Beschreibung *</label>
<textarea class="form-control" name="beschreibung" rows="3"
placeholder="Farbe, Merkmale, wo zuletzt gesehen, Halsband, …"
required></textarea>
</div>
<div class="form-group">
<label class="form-label">Standort (letzter bekannter Ort)</label>
<div style="display:flex;gap:var(--space-2);align-items:center">
<input class="form-control" type="text" id="lf-lat-disp"
placeholder="Breite" readonly style="flex:1">
<input class="form-control" type="text" id="lf-lon-disp"
placeholder="Länge" readonly style="flex:1">
<button type="button" class="btn btn-secondary" id="lf-gps-btn"
title="GPS-Standort ermitteln">📍</button>
</div>
<input type="hidden" name="lat" id="lf-lat">
<input type="hidden" name="lon" id="lf-lon">
<small id="lf-gps-hint" style="color:var(--c-text-secondary)">
${_userPos
? '✅ Aktueller Standort vorausgefüllt'
: 'GPS-Button drücken um Standort zu ermitteln'}
</small>
</div>
<div class="form-group">
<label class="form-label">
Foto
<span style="color:var(--c-text-secondary)">(optional)</span>
</label>
<input class="form-control" type="file" name="photo"
accept="image/*" capture="environment">
<img id="lf-photo-preview"
style="display:none;width:100%;max-height:200px;object-fit:cover;
border-radius:var(--radius-md);margin-top:var(--space-2)">
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="lf-cancel">Abbrechen</button>
<button type="submit" form="lost-form" class="btn btn-primary flex-1">
🔍 Meldung abschicken
</button>
`;
UI.modal.open({ title: '🔍 Hund vermisst melden', body, footer });
// Standort vorausfüllen
if (_userPos) {
document.getElementById('lf-lat').value = _userPos.lat;
document.getElementById('lf-lon').value = _userPos.lon;
document.getElementById('lf-lat-disp').value = _userPos.lat.toFixed(6);
document.getElementById('lf-lon-disp').value = _userPos.lon.toFixed(6);
}
// Wenn registrierter Hund gewählt → Name+Rasse vorausfüllen
document.getElementById('lf-dog-select')?.addEventListener('change', e => {
const dogId = parseInt(e.target.value);
const dog = dogs.find(d => d.id === dogId);
if (dog) {
document.getElementById('lf-name').value = dog.name;
const rasseInput = document.querySelector('#lost-form [name="rasse"]');
if (rasseInput && dog.rasse) rasseInput.value = dog.rasse;
}
});
// GPS-Button
document.getElementById('lf-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('lf-gps-btn');
UI.setLoading(btn, true);
try {
const pos = await API.getLocation({ timeout: 10000, enableHighAccuracy: true });
document.getElementById('lf-lat').value = pos.lat;
document.getElementById('lf-lon').value = pos.lon;
document.getElementById('lf-lat-disp').value = pos.lat.toFixed(6);
document.getElementById('lf-lon-disp').value = pos.lon.toFixed(6);
document.getElementById('lf-gps-hint').textContent = '✅ Standort aktualisiert';
_userPos = pos;
} catch {
UI.toast.error('GPS-Standort konnte nicht ermittelt werden.');
}
UI.setLoading(btn, false);
});
// Foto-Vorschau
const photoInput = document.querySelector('#lost-form [name="photo"]');
const photoPreview = document.getElementById('lf-photo-preview');
if (photoInput && photoPreview) {
UI.setupPhotoPreview(photoInput, photoPreview);
photoInput.addEventListener('change', () => {
photoPreview.style.display = photoInput.files[0] ? 'block' : 'none';
});
}
document.getElementById('lf-cancel')
?.addEventListener('click', UI.modal.close);
// Formular absenden
document.getElementById('lost-form')?.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="lost-form"][type="submit"]') ||
e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
if (!fd.lat || !fd.lon) {
UI.toast.warning('Bitte zuerst den GPS-Standort ermitteln (📍).');
return;
}
if (!fd.name?.trim()) {
UI.toast.warning('Bitte den Namen des Hundes eingeben.');
return;
}
await UI.asyncButton(submitBtn, async () => {
const payload = {
name : fd.name.trim(),
rasse : fd.rasse?.trim() || null,
beschreibung : fd.beschreibung?.trim() || '',
lat : parseFloat(fd.lat),
lon : parseFloat(fd.lon),
dog_id : fd.dog_id ? parseInt(fd.dog_id) : null,
};
const created = await API.lost.report(payload);
// Foto hochladen
if (photoInput?.files[0]) {
try {
const formData = new FormData();
formData.append('file', photoInput.files[0]);
const media = await API.lost.uploadFoto(created.id, formData);
created.foto_url = media.foto_url;
} catch {
UI.toast.warning('Meldung erstellt — Foto konnte nicht hochgeladen werden.');
}
}
// Distanz client-seitig berechnen
created.distanz_m = _userPos
? Math.round(_haversine(_userPos.lat, _userPos.lon, created.lat, created.lon))
: 0;
_reports.unshift(created);
_renderMarkers();
_renderList();
_updateBadge(_reports.length);
UI.toast.success('Hund als vermisst gemeldet. Wir drücken die Daumen!');
UI.modal.close();
});
});
}
// ----------------------------------------------------------
// BADGE
// ----------------------------------------------------------
function _updateBadge(count) {
const b = document.getElementById('lost-badge');
if (b) { b.textContent = count; b.style.display = count > 0 ? '' : 'none'; }
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _haversine(lat1, lon1, lat2, lon2) {
const R = 6_371_000;
const p1 = lat1 * Math.PI / 180;
const p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function _fmtDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr.replace(' ', 'T'));
return d.toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
}
function _escape(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh, openNew };
})();

View file

@ -28,6 +28,8 @@ window.Page_map = (() => {
let _recStartTime = null;
let _recTimerInt = null;
let _recPolyline = null;
let _pocketOverlay = null;
let _pocketHideTimer = null;
let _recMarker = null;
let _recWatchId = null;
@ -71,22 +73,22 @@ window.Page_map = (() => {
// z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = {
restaurant: { icon: '🍽️', label: 'Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '🐕', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '🛒', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '🧻', label: 'Kotbeutel', color: '#6B7280', z: 5 },
tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '⚠️', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '🗑️', label: 'Mülleimer', color: '#78716C', z: -20 },
dog_park: { icon: '🌿', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '💧', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '🪑', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '☠️', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '⚠️', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '🅿️', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '🤝', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '📌', label: 'Sonstiges', color: '#F59E0B', z: 30 },
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel', color: '#6B7280', z: 5 },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444', z: 40 },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6', z: 30 },
poison: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 100 },
muell: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#78716C', z: -20 },
dog_park: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D', z: 5 },
wasser: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9', z: 35 },
bank: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#chair"></use></svg>', label: 'Bank', color: '#92400E', z: -30 },
giftkoeder: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626', z: 80 },
gefahr: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>', label: 'Gefahr', color: '#F59E0B', z: 60 },
parkplatz: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB', z: 5 },
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
};
// Frontend-Layer → Backend-Typ Mapping
@ -150,7 +152,7 @@ window.Page_map = (() => {
<div class="map-legend" id="map-legend">
<button class="map-legend-btn map-legend-all" id="map-legend-all" title="Alle ein-/ausblenden">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#list"></use></svg>
</button>
${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => `
<button class="map-legend-btn${_visible[k] !== false ? ' active' : ''}" data-layer="${k}" style="--layer-color:${t.color}">
@ -163,22 +165,22 @@ window.Page_map = (() => {
<!-- Fadenkreuz-Overlay (nur im Placement-Modus sichtbar) -->
<div class="map-crosshair" id="map-crosshair">
<div class="map-crosshair-pin">📍</div>
<div class="map-crosshair-pin"><svg class="ph-icon" aria-hidden="true" style="width:28px;height:28px"><use href="/icons/phosphor.svg#map-pin"></use></svg></div>
<div class="map-crosshair-shadow"></div>
</div>
<div class="map-place-bar" id="map-place-bar">
<span class="map-place-hint">Karte verschieben · Pin landet genau hier</span>
<div class="map-place-btns">
<button class="btn btn-secondary" id="map-place-cancel">Abbrechen</button>
<button class="btn btn-primary" id="map-place-confirm">📌 Hier platzieren</button>
<button class="btn btn-primary" id="map-place-confirm"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Hier platzieren</button>
</div>
</div>
<div class="map-fabs">
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen">🔴</button>
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern">💾</button>
<button class="map-fab map-fab--pin" id="map-pin-btn" title="Marker setzen">📌</button>
<button class="map-fab" id="map-locate-btn" title="Meinen Standort">📍</button>
<button class="map-fab map-fab--rec" id="map-rec-btn" title="Route aufzeichnen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg></button>
<button class="map-fab map-fab--offline" id="map-offline-btn" title="Karte offline speichern"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></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-locate-btn" title="Meinen Standort"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<div class="map-statusbar" id="map-statusbar">
@ -203,11 +205,11 @@ window.Page_map = (() => {
</div>
</div>
<div class="map-rec-actions">
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause"> Pause</button>
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop"> Speichern</button>
<button class="btn btn-secondary map-rec-action-btn" id="rec-panel-pause">Pause</button>
<button class="btn btn-danger map-rec-action-btn" id="rec-panel-stop"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
</div>
<div class="map-rec-hint" id="map-rec-hint">
📵 Bildschirm bleibt aktiv GPS läuft
Bildschirm bleibt aktiv GPS läuft
</div>
</div>
@ -512,7 +514,7 @@ window.Page_map = (() => {
html: `<div class="poison-marker">
<div class="poison-ring"></div>
<div class="poison-ring"></div>
<div class="poison-dot"></div>
<div class="poison-dot"><svg class="ph-icon" aria-hidden="true" style="width:20px;height:20px"><use href="/icons/phosphor.svg#skull"></use></svg></div>
</div>`,
iconSize: [48, 48],
iconAnchor: [24, 24],
@ -562,11 +564,11 @@ window.Page_map = (() => {
: `<button class="btn btn-secondary btn-sm" id="mp-action">Als ungültig melden</button>`;
const openHours = poi.opening_hours
? `<div style="font-size:11px;color:#555;margin-bottom:4px">🕐 ${poi.opening_hours}</div>` : '';
? `<div style="font-size:11px;color:#555;margin-bottom:4px"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#clock"></use></svg> ${poi.opening_hours}</div>` : '';
const phone = poi.phone
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">📞 ${poi.phone}</a></div>` : '';
? `<div style="font-size:11px;margin-bottom:4px"><a href="tel:${poi.phone}" style="color:var(--c-primary);text-decoration:none">${poi.phone}</a></div>` : '';
const website = poi.website
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none">🌐 Website</a></div>` : '';
? `<div style="font-size:11px;margin-bottom:6px"><a href="${poi.website}" target="_blank" rel="noopener" style="color:var(--c-primary);text-decoration:none"><svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#arrow-square-out"></use></svg> Website</a></div>` : '';
marker.bindPopup(`
<div style="min-width:170px;max-width:240px">
@ -575,8 +577,8 @@ window.Page_map = (() => {
${openHours}${phone}${website}
<div style="font-size:11px;color:#999;margin-bottom:10px">
${isUser
? `📌 Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '🗺️ OpenStreetMap'}
? `<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#push-pin"></use></svg> Community-Pin${poi.username ? ' · <b>' + poi.username + '</b>' : ''}`
: '<svg class="ph-icon" aria-hidden="true" style="width:11px;height:11px"><use href="/icons/phosphor.svg#map-trifold"></use></svg> OpenStreetMap'}
</div>
${actionBtn}
</div>
@ -618,7 +620,7 @@ window.Page_map = (() => {
_placingMarker = false;
const btn = document.getElementById('map-pin-btn');
btn?.classList.remove('active');
btn && (btn.textContent = '\uD83D\uDCCC');
btn && (btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>');
document.getElementById('map-crosshair')?.classList.remove('active', 'dragging');
document.getElementById('map-place-bar')?.classList.remove('active');
_tempMarker?.remove();
@ -626,14 +628,14 @@ window.Page_map = (() => {
}
const PIN_TYPES = [
{ type: 'giftkoeder', icon: '☠️', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
{ type: 'waste_basket', icon: '🗑️', label: 'Mülleimer', color: '#78716C' },
{ type: 'kotbeutel', icon: '🧻', label: 'Kotbeutel', color: '#6B7280' },
{ type: 'drinking_water', icon: '💧', label: 'Wasserstelle', color: '#0EA5E9' },
{ type: 'dog_park', icon: '🌿', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '🅿️', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '🤝', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '📌', label: 'Sonstiges', color: '#F59E0B' },
{ type: 'giftkoeder', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#skull"></use></svg>', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben
{ type: 'waste_basket', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Mülleimer', color: '#78716C' },
{ type: 'kotbeutel', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel', color: '#6B7280' },
{ type: 'drinking_water', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg>', label: 'Wasserstelle', color: '#0EA5E9' },
{ type: 'dog_park', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>', label: 'Hundewiese', color: '#15803D' },
{ type: 'parkplatz', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#car"></use></svg>', label: 'Parkplatz', color: '#2563EB' },
{ type: 'treffpunkt', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED' },
{ type: 'sonstiges', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B' },
];
function _confirmPlacement(latlng) {
@ -645,7 +647,7 @@ window.Page_map = (() => {
let _selectedType = 'giftkoeder';
UI.modal.open({
title: '📌 Marker setzen',
title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg> Marker setzen',
body: `
<form id="poi-form" class="flex flex-col gap-3">
<div>
@ -930,7 +932,7 @@ window.Page_map = (() => {
navigator.serviceWorker.removeEventListener('message', onMessage);
if (btn) btn.classList.remove('loading');
_setOsmStatus('');
UI.toast.success(`\u2705 ${total} Kacheln offline gespeichert!`);
UI.toast.success(`${total} Kacheln offline gespeichert!`);
} else {
_setOsmStatus(`Offline: ${done} / ${total} Kacheln…`);
}
@ -940,6 +942,92 @@ window.Page_map = (() => {
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
}
// ----------------------------------------------------------
// Pocket-Modus Overlay
// ----------------------------------------------------------
function _showPocketOverlay() {
if (_pocketOverlay) return;
const el = document.createElement('div');
el.id = 'pocket-overlay';
el.innerHTML = `
<div class="po-status" id="po-status">GPS läuft</div>
<div class="po-time" id="po-time">0:00</div>
<div class="po-dist" id="po-dist">0.00 km</div>
<div class="po-hint">Tippen für Steuerung</div>
<div class="po-controls" id="po-controls">
<button class="po-btn" id="po-pause"> Pause</button>
<button class="po-btn po-btn--stop" id="po-stop"> Stopp</button>
</div>
`;
el.style.cssText = `
position:fixed;inset:0;z-index:9998;background:#000;
display:flex;flex-direction:column;align-items:center;justify-content:center;
color:#fff;font-family:inherit;user-select:none;
`;
el.querySelector('.po-status').style.cssText =
'font-size:0.85rem;color:#888;margin-bottom:1rem;letter-spacing:0.05em;text-transform:uppercase';
el.querySelector('.po-time').style.cssText =
'font-size:4.5rem;font-weight:700;letter-spacing:-0.02em;line-height:1';
el.querySelector('.po-dist').style.cssText =
'font-size:1.5rem;color:#aaa;margin-top:0.5rem';
el.querySelector('.po-hint').style.cssText =
'font-size:0.75rem;color:#444;margin-top:2.5rem';
const ctrl = el.querySelector('.po-controls');
ctrl.style.cssText =
'display:none;gap:1rem;margin-top:2rem;flex-direction:row';
el.querySelectorAll('.po-btn').forEach(b => {
b.style.cssText =
'padding:0.75rem 1.5rem;border:1px solid #555;border-radius:0.75rem;' +
'background:#111;color:#fff;font-size:1rem;cursor:pointer';
});
el.querySelector('.po-btn--stop').style.cssText +=
'border-color:#c0392b;color:#e74c3c';
// Tippen → Controls 4s einblenden, dann ausblenden
el.addEventListener('click', e => {
if (e.target.closest('#po-controls')) return;
ctrl.style.display = 'flex';
el.querySelector('.po-hint').style.color = '#666';
clearTimeout(_pocketHideTimer);
_pocketHideTimer = setTimeout(() => {
ctrl.style.display = 'none';
el.querySelector('.po-hint').style.color = '#444';
}, 4000);
});
el.querySelector('#po-pause').addEventListener('click', () => {
_togglePause();
el.querySelector('#po-pause').textContent =
_recPaused ? '▶ Weiter' : '⏸ Pause';
el.querySelector('#po-status').textContent =
_recPaused ? 'Pausiert' : 'GPS läuft';
});
el.querySelector('#po-stop').addEventListener('click', () => {
_hidePocketOverlay();
_stopRecording();
});
document.body.appendChild(el);
_pocketOverlay = el;
}
function _hidePocketOverlay() {
clearTimeout(_pocketHideTimer);
_pocketOverlay?.remove();
_pocketOverlay = null;
}
function _updatePocketOverlay() {
if (!_pocketOverlay) return;
const elapsed = Math.floor((Date.now() - _recStartTime) / 1000);
const mm = String(Math.floor(elapsed / 60)).padStart(1, '0');
const ss = String(elapsed % 60).padStart(2, '0');
const timeEl = _pocketOverlay.querySelector('#po-time');
const distEl = _pocketOverlay.querySelector('#po-dist');
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (distEl) distEl.textContent = `${_recDistKm.toFixed(2)} km`;
}
// ----------------------------------------------------------
// GPS-Aufzeichnung
// ----------------------------------------------------------
@ -966,7 +1054,7 @@ window.Page_map = (() => {
// FAB umschalten
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.textContent = '⏹'; btn.classList.add('recording'); }
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>'; btn.classList.add('recording'); }
// Aufzeichnungs-Panel einblenden
const panel = document.getElementById('map-rec-panel');
@ -978,8 +1066,8 @@ window.Page_map = (() => {
await _acquireWakeLock();
const hint = document.getElementById('map-rec-hint');
if (hint) hint.textContent = _wakeLock
? '📵 Bildschirm bleibt aktiv — GPS läuft'
: '⚠️ Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
? 'Bildschirm bleibt aktiv — GPS läuft'
: 'Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen';
// Sichtbarkeit: Wake Lock bei Tab-Wechsel neu anfordern
document.addEventListener('visibilitychange', _onVisibilityChange);
@ -1003,7 +1091,12 @@ window.Page_map = (() => {
() => {},
{ enableHighAccuracy: true, maximumAge: 0, timeout: 10000 }
);
UI.toast.success('Aufzeichnung gestartet — los geht\'s! 🐕');
UI.toast.success('Aufzeichnung gestartet — los geht\'s!');
// Pocket-Modus aktivieren wenn in Einstellungen eingeschaltet
if (localStorage.getItem('by_pocket_mode') === 'true') {
setTimeout(_showPocketOverlay, 800); // kurz warten damit Toast sichtbar war
}
}
async function _onVisibilityChange() {
@ -1074,6 +1167,7 @@ window.Page_map = (() => {
if (distEl) distEl.textContent = _recDistKm.toFixed(2);
if (timeEl) timeEl.textContent = `${mm}:${ss}`;
if (paceEl) paceEl.textContent = pace;
_updatePocketOverlay();
}
function _stopRecording() {
@ -1081,10 +1175,11 @@ window.Page_map = (() => {
if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; }
_recActive = false;
_releaseWakeLock();
_hidePocketOverlay();
document.removeEventListener('visibilitychange', _onVisibilityChange);
const btn = document.getElementById('map-rec-btn');
if (btn) { btn.textContent = '🔴'; btn.classList.remove('recording'); }
if (btn) { btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg>'; btn.classList.remove('recording'); }
const panel = document.getElementById('map-rec-panel');
if (panel) panel.classList.remove('active', 'paused');
@ -1100,16 +1195,34 @@ window.Page_map = (() => {
_showRecSaveModal(_recTrack, _recDistKm, dauMin);
}
async function _prefillRouteName(track, distKm) {
const nameInput = document.querySelector('#rec-save-form [name="name"]');
if (!nameInput || nameInput.value) return;
const pt = track[0];
const date = new Date().toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
const km = distKm.toFixed(1);
let ort = '';
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${pt.lat}&lon=${pt.lon}&format=json&zoom=13&addressdetails=1&accept-language=de`, { cache: 'no-store' });
const data = await r.json();
const a = data.address || {};
ort = a.village || a.town || a.suburb || a.city_district || a.city || a.municipality || '';
} catch {}
if (!nameInput.value) nameInput.value = ort
? `Gassirunde ${ort} · ${date} · ${km} km`
: `Gassirunde · ${date} · ${km} km`;
}
function _showRecSaveModal(track, distKm, dauMin) {
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
🎉 ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p>
<form id="rec-save-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name"
placeholder="z.B. Waldspaziergang am See" required>
placeholder="Wird automatisch ermittelt…" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
@ -1124,34 +1237,34 @@ window.Page_map = (() => {
<label class="form-label">Untergrund</label>
<select class="form-control" name="untergrund">
<option value=""> unbekannt </option>
<option value="wald">🌲 Wald</option>
<option value="asphalt">🛣 Asphalt</option>
<option value="wiese">🌿 Wiese</option>
<option value="mix">🔀 Mix</option>
<option value="wald">Wald</option>
<option value="asphalt">Asphalt</option>
<option value="wiese">Wiese</option>
<option value="mix">Mix</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Hundetauglichkeit</label>
<div class="rk-paw-select" id="rec-paw-select">
<button type="button" class="rk-paw-btn" data-val="eingeschränkt">🐾 Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut">🐾🐾 Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut">🐾🐾🐾 Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium">🐾🐾🐾🐾 Premium</button>
<button type="button" class="rk-paw-btn" data-val="eingeschränkt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Eingeschränkt</button>
<button type="button" class="rk-paw-btn" data-val="gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Gut</button>
<button type="button" class="rk-paw-btn selected" data-val="sehr_gut"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Sehr gut</button>
<button type="button" class="rk-paw-btn" data-val="premium"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paw-print"></use></svg> Premium</button>
</div>
<input type="hidden" name="hunde_tauglichkeit" id="rec-paw-val" value="sehr_gut">
</div>
<div class="form-group" style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="schatten"> 🌳 Viel Schatten
<input type="checkbox" name="schatten"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tree"></use></svg> Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_empfohlen"> 🔗 Leine empfohlen
<input type="checkbox" name="leine_empfohlen"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leine empfohlen
</label>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="is_public" checked> 🌍 Öffentlich (von allen sichtbar)
<input type="checkbox" name="is_public" checked> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
</label>
</div>
<div class="form-group">
@ -1164,10 +1277,12 @@ window.Page_map = (() => {
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rms-discard">Verwerfen</button>
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1">💾 Speichern</button>
<button type="submit" form="rec-save-form" class="btn btn-primary flex-1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg> Speichern</button>
`;
UI.modal.open({ title: '🥾 Route benennen', body, footer });
UI.modal.open({ title: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#path"></use></svg> Route benennen', body, footer });
_prefillRouteName(track, distKm); // async, füllt Name-Feld sobald Nominatim antwortet
document.getElementById('rec-paw-select')?.addEventListener('click', e => {
const btn = e.target.closest('.rk-paw-btn');
@ -1204,7 +1319,7 @@ window.Page_map = (() => {
UI.modal.close();
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
UI.toast.success(`Route „${saved.name}" gespeichert! 🎉`);
UI.toast.success(`Route „${saved.name}" gespeichert!`);
});
});
}

View file

@ -0,0 +1,409 @@
/* ============================================================
BAN YARO Hunde-Filme
Seiten-Modul: Film-Datenbank, Promi-Hunde, Hund des Monats.
============================================================ */
window.Page_movies = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _filme = [];
let _activeTab = 'filme';
let _filter = 'alle';
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
_filme = [];
await _render();
}
// ----------------------------------------------------------
// RENDER — Haupt-Layout mit Tabs
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div class="movies-tabs">
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
</div>
<div id="movies-tab-content"></div>
`;
_container.querySelectorAll('.movies-tab').forEach(btn => {
btn.addEventListener('click', () => {
_activeTab = btn.dataset.tab;
_container.querySelectorAll('.movies-tab').forEach(b => b.classList.remove('movies-tab--active'));
btn.classList.add('movies-tab--active');
_renderTab();
});
});
await _renderTab();
}
async function _renderTab() {
const content = _container.querySelector('#movies-tab-content');
if (!content) return;
content.innerHTML = UI.skeleton(3);
if (_activeTab === 'filme') await _renderFilme(content);
if (_activeTab === 'promis') _renderPromis(content);
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
}
// ----------------------------------------------------------
// TAB 1: FILME
// ----------------------------------------------------------
async function _renderFilme(content) {
try {
_filme = await API.get('/movies/filme');
} catch {
content.innerHTML = UI.emptyState({ icon: '🎬', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
return;
}
content.innerHTML = `
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt">😭 Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt">🐾 Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top">+</button>
</div>
<div class="movie-grid" id="movie-grid"></div>
`;
content.querySelectorAll('.movies-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
_filter = btn.dataset.filter;
content.querySelectorAll('.movies-filter-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
btn.classList.add('movies-filter-btn--active');
_renderMovieGrid(content.querySelector('#movie-grid'));
});
});
_renderMovieGrid(content.querySelector('#movie-grid'));
}
function _renderMovieGrid(grid) {
if (!grid) return;
let list = [..._filme];
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0);
if (list.length === 0) {
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
return;
}
grid.innerHTML = list.map(f => _movieCard(f)).join('');
grid.querySelectorAll('.movie-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.movie-star-rating')) return;
const id = card.dataset.filmId;
const film = _filme.find(f => f.id === id);
if (film) _openMovieModal(film);
});
});
_bindStarRatings(grid);
}
function _movieCard(film) {
const stirbt = film.stirbt_der_hund;
const tag = stirbt
? `<div class="movie-tag-stirbt">⚠️ ACHTUNG: Der Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt">✅ Der Hund überlebt</div>`;
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
return `
<div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body">
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre">${_esc(film.genre)}</div>
<div class="movie-card-rasse">🐾 ${_esc(film.hund_rasse)}</div>
${tag}
<div class="movie-card-stars">${stars}</div>
</div>
</div>
`;
}
function _openMovieModal(film) {
const stirbt = film.stirbt_der_hund;
const bannerClass = stirbt ? 'movie-tag-stirbt' : 'movie-tag-ueberlebt';
const bannerText = stirbt ? '⚠️ ACHTUNG: Der Hund stirbt!' : '✅ Der Hund überlebt';
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, true);
const loginHint = !_appState.user
? `<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-2)">Anmelden um zu bewerten</p>`
: '';
const body = `
<div class="movie-modal-emoji">${film.bild_emoji}</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<span class="badge badge-primary">${_esc(film.genre)}</span>
<span class="badge">🐾 ${_esc(film.hund_rasse)}</span>
<span class="badge">${film.jahr}</span>
</div>
<div class="${bannerClass}" style="margin-bottom:var(--space-4);font-size:var(--text-base)">${bannerText}</div>
<p style="line-height:1.6;color:var(--c-text);margin-bottom:var(--space-5)">${_esc(film.beschreibung)}</p>
<div style="margin-bottom:var(--space-2)">
<strong>Community-Bewertung:</strong>
</div>
<div id="modal-stars-${_esc(film.id)}">${stars}</div>
<div id="modal-avg-${_esc(film.id)}" style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-top:var(--space-1)">
Ø ${film.bewertung_avg} von ${film.bewertung_cnt || 0} Bewertungen
</div>
${loginHint}
`;
UI.modal.open({ title: film.titel, body });
const starsEl = document.getElementById(`modal-stars-${film.id}`);
if (starsEl && _appState.user) {
_bindStarRatingsEl(starsEl, film.id, true);
}
}
function _starsHtml(avg, filmId, userRating, interactive) {
const filled = Math.round(avg);
const stars = [1,2,3,4,5].map(i => {
const active = i <= (userRating || filled) ? ' movie-star--active' : '';
return `<span class="movie-star${active}" data-film-id="${_esc(filmId)}" data-val="${i}">★</span>`;
}).join('');
return `<div class="movie-star-rating" data-film-id="${_esc(filmId)}">${stars} <span class="movie-star-avg">${avg}</span></div>`;
}
function _bindStarRatings(container) {
container.querySelectorAll('.movie-star-rating').forEach(el => {
_bindStarRatingsEl(el, el.dataset.filmId, false);
});
}
function _bindStarRatingsEl(el, filmId, inModal) {
if (!_appState.user) return;
const stars = el.querySelectorAll('.movie-star');
stars.forEach(star => {
star.addEventListener('mouseenter', () => {
const val = parseInt(star.dataset.val);
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
});
star.addEventListener('mouseleave', () => {
const film = _filme.find(f => f.id === filmId);
const cur = film?.user_rating || 0;
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < cur));
});
star.addEventListener('click', async () => {
const val = parseInt(star.dataset.val);
try {
const res = await API.post(`/movies/filme/${filmId}/vote`, { bewertung: val });
// Update in _filme array
const idx = _filme.findIndex(f => f.id === filmId);
if (idx !== -1) {
_filme[idx].user_rating = val;
_filme[idx].bewertung_avg = res.bewertung_avg;
_filme[idx].bewertung_cnt = res.bewertung_cnt;
}
// Update star display
stars.forEach((s, i) => s.classList.toggle('movie-star--active', i < val));
const avgEl = el.querySelector('.movie-star-avg') ||
(inModal ? document.getElementById(`modal-avg-${filmId}`) : null);
if (el.querySelector('.movie-star-avg')) {
el.querySelector('.movie-star-avg').textContent = res.bewertung_avg;
}
if (inModal) {
const avgInfo = document.getElementById(`modal-avg-${filmId}`);
if (avgInfo) avgInfo.textContent = `Ø ${res.bewertung_avg} von ${res.bewertung_cnt} Bewertungen`;
}
UI.toast.success('Bewertung gespeichert!');
} catch {
UI.toast.error('Bewertung konnte nicht gespeichert werden.');
}
});
});
}
// ----------------------------------------------------------
// TAB 2: BERÜHMTHEITEN (hardcoded, kein Backend)
// ----------------------------------------------------------
const PROMIS = [
{ name: "Hachikō", rasse: "Akita Inu", bekannt_fuer: "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", emoji: "🗿" },
{ name: "Rin Tin Tin", rasse: "Deutscher Schäferhund", bekannt_fuer: "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", emoji: "🎬" },
{ name: "Laika", rasse: "Mischling", bekannt_fuer: "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", emoji: "🚀" },
{ name: "Endal", rasse: "Labrador", bekannt_fuer: "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", emoji: "💳" },
{ name: "Barry", rasse: "Bernhardiner", bekannt_fuer: "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", emoji: "🏔️" },
{ name: "Greyfriars Bobby", rasse: "Skye Terrier", bekannt_fuer: "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", emoji: "⛪" },
];
function _renderPromis(content) {
content.innerHTML = `
<div style="padding:var(--space-2) 0">
${PROMIS.map(p => `
<div class="movie-promi-card">
<div class="movie-promi-emoji">${p.emoji}</div>
<div class="movie-promi-body">
<div class="movie-promi-name">${_esc(p.name)}</div>
<div class="movie-promi-rasse">${_esc(p.rasse)}</div>
<div class="movie-promi-text">${_esc(p.bekannt_fuer)}</div>
</div>
</div>
`).join('')}
</div>
`;
}
// ----------------------------------------------------------
// TAB 3: HUND DES MONATS
// ----------------------------------------------------------
async function _renderHundDesMonats(content) {
let data;
try {
data = await API.get('/movies/hund-des-monats');
} catch {
content.innerHTML = UI.emptyState({ icon: '🏆', title: 'Fehler beim Laden', text: 'Bitte versuche es erneut.' });
return;
}
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
let voteSection = '';
if (_appState.user && _appState.dogs?.length > 0) {
const voteCards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
<button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}">
${isVoted ? '✅ Gewählt' : 'Abstimmen'}
</button>
</div>
`;
}).join('');
voteSection = `
<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen deiner Hunde möchtest du abstimmen?</h3>
<div class="hdm-vote-grid" id="hdm-vote-grid">${voteCards}</div>
</div>
`;
} else if (!_appState.user) {
voteSection = `
<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um für deinen Hund abzustimmen.
</p>
</div>
`;
}
const topList = data.top.length > 0
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i] || `${i+1}.`;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} </div>
</div>
`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen diesen Monat. Sei der Erste!</p>`;
content.innerHTML = `
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteSection}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
</div>
`;
// Login-Link
content.querySelector('#hdm-login-link')?.addEventListener('click', e => {
e.preventDefault();
App.navigate('settings');
});
// Vote-Buttons
content.querySelectorAll('.hdm-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
UI.toast.success('Stimme abgegeben!');
// Refresh the tab
await _renderHundDesMonats(content);
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
}
// ----------------------------------------------------------
// HELPER
// ----------------------------------------------------------
function _esc(str) {
if (!str && str !== 0) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -18,12 +18,12 @@ window.Page_places = (() => {
// Typen-Konfiguration
// ----------------------------------------------------------
const TYPEN = {
restaurant: { icon: '🍽️', label: 'Restaurant & Café', color: '#F97316' },
freilauf: { icon: '🐕', label: 'Freilauffläche', color: '#22C55E' },
shop: { icon: '🛒', label: 'Shop', color: '#3B82F6' },
kotbeutel: { icon: '🧻', label: 'Kotbeutel-Station', color: '#6B7280' },
tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444' },
hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6' },
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant & Café', color: '#F97316' },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauffläche', color: '#22C55E' },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>', label: 'Kotbeutel-Station', color: '#6B7280' },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
};
function _esc(s) {
@ -54,13 +54,13 @@ window.Page_places = (() => {
<!-- Toolbar -->
<div class="places-toolbar">
<div class="places-filter" id="places-filter">
<button class="places-filter-btn active" data-typ="">🗺 Alle</button>
<button class="places-filter-btn active" data-typ=""><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg> Alle</button>
${Object.entries(TYPEN).map(([k, t]) =>
`<button class="places-filter-btn" data-typ="${k}">${t.icon} ${t.label}</button>`
).join('')}
</div>
<button class="btn btn-primary btn-sm" id="places-add-btn" style="white-space:nowrap">
+ Ort hinzufügen
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Ort hinzufügen
</button>
</div>
@ -139,7 +139,7 @@ window.Page_places = (() => {
L.Control.Locate = L.Control.extend({
onAdd() {
const btn = L.DomUtil.create('button', 'places-locate-btn');
btn.innerHTML = '📍';
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>';
btn.title = 'Meinen Standort';
btn.onclick = async () => {
try {
@ -191,7 +191,7 @@ window.Page_places = (() => {
_markers = [];
_filtered().forEach(place => {
const t = TYPEN[place.typ] || { icon: '📍', color: '#6B7280' };
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', color: '#6B7280' };
const icon = L.divIcon({
className: '',
html: `<div style="
@ -242,11 +242,11 @@ window.Page_places = (() => {
}
function _cardHTML(p) {
const t = TYPEN[p.typ] || { icon: '📍', label: p.typ, color: '#6B7280' };
const t = TYPEN[p.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: p.typ, color: '#6B7280' };
const flags = [
p.hund_rein === true ? '🐕 Hund rein' : null,
p.leine_pflicht === true ? '🔗 Leinenpflicht' : null,
p.wasser_fuer_hunde === true ? '💧 Wasser' : null,
p.hund_rein === true ? `${UI.icon('dog')} Hund rein` : null,
p.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : null,
p.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser` : null,
].filter(Boolean);
return `
@ -260,7 +260,7 @@ window.Page_places = (() => {
</div>
${flags.length ? `<div class="places-card-flags">${flags.map(f => `<span class="places-flag">${f}</span>`).join('')}</div>` : ''}
</div>
<div class="places-card-arrow"></div>
<div class="places-card-arrow">${UI.icon('arrow-right')}</div>
</div>`;
}
@ -268,13 +268,13 @@ window.Page_places = (() => {
// Detail-Modal
// ----------------------------------------------------------
function _openDetail(place) {
const t = TYPEN[place.typ] || { icon: '📍', label: place.typ, color: '#6B7280' };
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: place.typ, color: '#6B7280' };
const isOwn = _appState.user?.id === place.user_id;
const flags = [
place.hund_rein === true ? '🐕 Hund erlaubt' : (place.hund_rein === false ? '🚫 Kein Hund' : null),
place.leine_pflicht === true ? '🔗 Leinenpflicht' : (place.leine_pflicht === false ? '✅ Leine optional' : null),
place.wasser_fuer_hunde === true ? '💧 Wasser vorhanden': null,
place.hund_rein === true ? `${UI.icon('dog')} Hund erlaubt` : (place.hund_rein === false ? `${UI.icon('x')} Kein Hund` : null),
place.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : (place.leine_pflicht === false ? `${UI.icon('check')} Leine optional` : null),
place.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser vorhanden`: null,
].filter(Boolean);
const body = `
@ -285,8 +285,8 @@ window.Page_places = (() => {
<div style="color:${t.color};font-size:0.9rem">${t.label}</div>
</div>
</div>
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${_esc(place.adresse)}</p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">🌐 ${_esc(place.website)}</a></p>` : ''}
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${_esc(place.adresse)}</p>` : ''}
${place.website ? `<p style="margin-bottom:var(--space-2)"><a href="${_esc(place.website)}" target="_blank" style="color:var(--c-primary)">${UI.icon('arrow-square-out')} ${_esc(place.website)}</a></p>` : ''}
${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''}
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Eingetragen von ${_esc(place.user_name || 'Unbekannt')}
@ -301,7 +301,7 @@ window.Page_places = (() => {
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
`;
UI.modal.open({ title: `${t.icon} ${place.name}`, body, footer });
UI.modal.open({ title: `${t.icon} ${_esc(place.name)}`, body, footer });
document.getElementById('place-detail-close')?.addEventListener('click', UI.modal.close);
@ -341,7 +341,7 @@ window.Page_places = (() => {
const isEdit = !!place;
const typOpts = Object.entries(TYPEN)
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.icon} ${t.label}</option>`)
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.label}</option>`)
.join('');
const body = `
@ -367,12 +367,12 @@ window.Page_places = (() => {
<input class="form-control" type="text" id="pf-lon-disp"
placeholder="Länge" readonly style="flex:1"
value="${place ? place.lon.toFixed(6) : ''}">
<button type="button" class="btn btn-secondary" id="pf-gps-btn" title="GPS">📍</button>
<button type="button" class="btn btn-secondary" id="pf-gps-btn" title="GPS"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg></button>
</div>
<input type="hidden" name="lat" id="pf-lat" value="${place?.lat || ''}">
<input type="hidden" name="lon" id="pf-lon" value="${place?.lon || ''}">
<small id="pf-gps-hint" style="color:var(--c-text-secondary)">
${place ? 'Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'}
${place ? 'Position gespeichert' : 'GPS-Button drücken oder Standort ermitteln'}
</small>
</div>
@ -392,15 +392,15 @@ window.Page_places = (() => {
<label class="form-label">Hundefreundlichkeit</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
🐕 Hund darf rein
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg> Hund darf rein
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_pflicht" ${place?.leine_pflicht ? 'checked' : ''}>
🔗 Leinenpflicht beachten
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leinenpflicht beachten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="wasser_fuer_hunde" ${place?.wasser_fuer_hunde ? 'checked' : ''}>
💧 Wasser für Hunde vorhanden
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg> Wasser für Hunde vorhanden
</label>
</div>
@ -414,7 +414,7 @@ window.Page_places = (() => {
</button>
`;
UI.modal.open({ title: isEdit ? `${place.name} bearbeiten` : '📍 Neuer Ort', body, footer });
UI.modal.open({ title: isEdit ? `${_esc(place.name)} bearbeiten` : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Neuer Ort', body, footer });
document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close);
@ -429,7 +429,7 @@ window.Page_places = (() => {
document.getElementById('pf-lon').value = pos.lon;
document.getElementById('pf-lat-disp').value = pos.lat.toFixed(6);
document.getElementById('pf-lon-disp').value = pos.lon.toFixed(6);
document.getElementById('pf-gps-hint').textContent = 'Standort ermittelt';
document.getElementById('pf-gps-hint').textContent = 'Standort ermittelt';
} catch {
UI.toast.error('GPS nicht verfügbar.');
}

View file

@ -54,8 +54,8 @@ window.Page_poison = (() => {
async function _render() {
_container.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-3);flex-wrap:wrap">
<button class="btn btn-secondary" id="poison-btn-locate">📍 Mein Standort</button>
<button class="btn btn-danger" id="poison-btn-report"> Giftköder melden</button>
<button class="btn btn-secondary" id="poison-btn-locate">${UI.icon('map-pin')} Mein Standort</button>
<button class="btn btn-danger" id="poison-btn-report">${UI.icon('warning-octagon')} Giftköder melden</button>
</div>
<div id="poison-map"

View file

@ -17,6 +17,11 @@ window.Page_routes = (() => {
let _sortBy = 'newest';
let _onlyMine = false;
// Ansichts-Modus: 'list' | 'map'
let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte
let _searchLines = new Map(); // routeId → { line, route }
// Mini-Karten auf den Route-Cards
let _miniMaps = new Map(); // routeId → L.map
let _leafletReady = false;
@ -74,11 +79,15 @@ window.Page_routes = (() => {
<div class="rk-search-row">
<input class="rk-search" id="rk-search" type="search"
placeholder="🔍 Route suchen…" autocomplete="off">
<div class="rk-view-toggle">
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
</div>
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
📥 Import
${UI.icon('download-simple')} Import
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
</label>
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">🔴 Aufzeichnen</button>
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">${UI.icon('path')} Aufzeichnen</button>
</div>
<div class="rk-filters" id="rk-filters">
<div class="rk-filter-group">
@ -114,6 +123,8 @@ window.Page_routes = (() => {
document.getElementById('rk-search').addEventListener('input', e => {
_search = e.target.value.toLowerCase(); _applyFilter();
});
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
document.getElementById('rk-rec-btn').addEventListener('click', () => {
App.navigate('map');
setTimeout(() => window.Page_map?.startRecording?.(), 600);
@ -138,6 +149,177 @@ window.Page_routes = (() => {
});
}
// ----------------------------------------------------------
// View-Toggle
// ----------------------------------------------------------
function _switchView(mode) {
_viewMode = mode;
document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list');
document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map');
const layout = document.querySelector('.rk-layout');
const grid = document.getElementById('rk-grid');
if (mode === 'map') {
if (grid) grid.style.display = 'none';
// Alten Map-Container entfernen falls vorhanden
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
// Als fixed Overlay direkt in <body> — kein Konflikt mit .rk-layout overflow:hidden
const mapH = window.innerHeight - 160;
const sec = document.createElement('div');
sec.id = 'rk-map-section';
sec.className = 'rk-map-section';
sec.innerHTML = `
<div class="rk-map-bar">
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
placeholder="🔍 Ort suchen…" autocomplete="off">
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">📍</button>
</div>
<div id="rk-search-map" style="height:${mapH}px;width:100%"></div>
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
`;
document.body.appendChild(sec);
// Wie _initMiniMaps: pollen bis window.L bereit ist
_pollAndInitSearchMap();
} else {
document.getElementById('rk-map-section')?.remove();
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
if (grid) grid.style.display = '';
}
}
// ----------------------------------------------------------
// Suchkarte
// ----------------------------------------------------------
function _pollAndInitSearchMap() {
if (window.L) { _initSearchMap(); return; }
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 40) {
clearInterval(poll);
if (window.L) _initSearchMap();
}
}, 100);
}
function _initSearchMap() {
if (!document.getElementById('rk-search-map')) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
const zoom = _userPos ? 13 : 6;
_searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap);
setTimeout(() => _searchMap?.invalidateSize(), 100);
setTimeout(() => _searchMap?.invalidateSize(), 600);
_renderRoutesOnMap();
// Standort-Button
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
try {
const pos = await API.getLocation();
_userPos = pos;
_searchMap.setView([pos.lat, pos.lon], 14);
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
});
// Geocoding-Suche
const locInput = document.getElementById('rk-map-loc');
let _geoDebounce;
locInput?.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
clearTimeout(_geoDebounce);
_geocodeAndFly(locInput.value.trim());
});
locInput?.addEventListener('input', () => {
clearTimeout(_geoDebounce);
const q = locInput.value.trim();
if (q.length < 3) return;
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
});
}
async function _geocodeAndFly(query) {
if (!query || !_searchMap) return;
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
{ cache: 'no-store' }
);
const data = await r.json();
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
const { lat, lon, boundingbox } = data[0];
if (boundingbox) {
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
{ maxZoom: 14 });
} else {
_searchMap.setView([+lat, +lon], 13);
}
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
}
function _renderRoutesOnMap() {
if (!_searchMap || !window.L) return;
// Alte Linien entfernen
_searchLines.forEach(({ line }) => line.remove());
_searchLines.clear();
const hint = document.getElementById('rk-map-hint');
_data.forEach(route => {
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
if (pts.length < 2) return;
const line = L.polyline(pts, {
color: '#C4843A', weight: 4, opacity: 0.75,
}).addTo(_searchMap);
// Start-/End-Marker
const startM = L.circleMarker(pts[0], {
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
const endM = L.circleMarker(pts[pts.length - 1], {
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
// Tooltip mit Namen und Distanz
const tip = `<b>${_esc(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
// Hover-Highlight
line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 }));
line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 }));
// Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten)
const onClick = () => {
if (hint) hint.textContent = `Lädt „${route.name}"…`;
_openDetail(route.id).finally(() => {
if (hint) hint.textContent = 'Route antippen um Details zu sehen';
});
};
line.on('click', onClick);
startM.on('click', onClick);
_searchLines.set(route.id, { line, startM, endM });
});
// Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal)
if (_data.length && _searchLines.size && !_userPos) {
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
if (allPts.length) {
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
catch {}
}
}
}
// ----------------------------------------------------------
// Daten
// ----------------------------------------------------------
@ -178,6 +360,7 @@ window.Page_routes = (() => {
_filtered = list;
_renderGrid();
if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap();
}
// ----------------------------------------------------------
@ -511,12 +694,27 @@ window.Page_routes = (() => {
// ----------------------------------------------------------
// Nearby POIs
// ----------------------------------------------------------
// Gibt true zurück wenn poi.lat/lon innerhalb maxMeters eines Track-Punkts liegt
function _isNearTrack(poi, track, maxMeters) {
const R = 6371000;
const plat = poi.lat * Math.PI / 180;
const plon = poi.lon * Math.PI / 180;
for (const pt of track) {
const dlat = plat - pt.lat * Math.PI / 180;
const dlon = plon - pt.lon * Math.PI / 180;
const a = dlat*dlat + Math.cos(plat) * Math.cos(pt.lat * Math.PI/180) * dlon*dlon;
if (R * Math.sqrt(a) <= maxMeters) return true;
}
return false;
}
async function _loadNearbyPois(track) {
const lats = track.map(p => p.lat), lons = track.map(p => p.lon);
const south = Math.min(...lats), north = Math.max(...lats);
const west = Math.min(...lons), east = Math.max(...lons);
// Etwas aufweiten (ca. 300m)
const pad = 0.003;
// Bbox-Padding zum Abrufen (ca. 150m) — echte Distanzfilterung danach
const pad = 0.0015;
const bbox = { south: south-pad, north: north+pad, west: west-pad, east: east+pad };
const results = [];
@ -524,7 +722,9 @@ window.Page_routes = (() => {
try {
const params = new URLSearchParams({ type, fast: 'true', ...bbox });
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
pois.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
pois
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
} catch {}
}));
return results;

View file

@ -54,7 +54,7 @@ window.Page_settings = (() => {
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${_esc(u.email)}</div>
${u.is_premium
? `<span class="badge badge-primary" style="margin-top:var(--space-1)">
Ban Yaro Plus
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#star"></use></svg> Ban Yaro Plus
</span>`
: `<span class="badge" style="margin-top:var(--space-1);
color:var(--c-text-secondary)">
@ -68,25 +68,52 @@ window.Page_settings = (() => {
<div class="card-body" style="padding:0">
<div class="sidebar-item" data-page="dog-profile"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<span>🐕</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
<span>Hunde-Profile</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-push-btn"
style="padding:var(--space-4);border-radius:0;border-bottom:1px solid var(--c-border)">
<span>🔔</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bell"></use></svg>
<span>Push-Benachrichtigungen</span>
<span style="margin-left:auto;color:var(--c-text-secondary)"></span>
</div>
<div class="sidebar-item" id="settings-logout-btn"
style="padding:var(--space-4);border-radius:0;cursor:pointer;
color:var(--c-danger)">
<span>🚪</span>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
<span>Abmelden</span>
</div>
</div>
</div>
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-3) var(--space-4);
font-size:var(--text-xs);font-weight:600;
color:var(--c-text-secondary);text-transform:uppercase;
letter-spacing:0.05em;border-bottom:1px solid var(--c-border)">
App-Einstellungen
</div>
<div class="card-body" style="padding:0">
<div style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#eye-slash"></use></svg>
<div style="flex:1">
<div style="font-weight:500">Pocket-Modus beim Aufzeichnen</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Schwarzes Overlay hält den Bildschirm aktiv (GPS läuft) ideal für die Hosentasche.
Helligkeit auf Minimum reduzieren für optimalen Akku-Schutz.
</div>
</div>
<label class="toggle" style="flex-shrink:0">
<input type="checkbox" id="toggle-pocket-mode"
${localStorage.getItem('by_pocket_mode') === 'true' ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div style="text-align:center;color:var(--c-text-secondary);
font-size:var(--text-xs)">
Ban Yaro · banyaro.app<br>
@ -121,6 +148,13 @@ window.Page_settings = (() => {
UI.toast.warning('Push-Benachrichtigungen konnten nicht aktiviert werden.');
}
});
document.getElementById('toggle-pocket-mode')?.addEventListener('change', e => {
localStorage.setItem('by_pocket_mode', String(e.target.checked));
UI.toast.info(e.target.checked
? 'Pocket-Modus aktiviert — Bildschirm bleibt bei Aufzeichnung an.'
: 'Pocket-Modus deaktiviert.');
});
}
// ----------------------------------------------------------

View file

@ -9,10 +9,10 @@ window.Page_sitting = (() => {
// Konstanten
// ----------------------------------------------------------
const SERVICES = [
{ id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '☀️' },
{ id: 'uebernachtung', label: 'Übernachtung', icon: '🌙' },
{ id: 'gassi', label: 'Gassi gehen', icon: '🦮' },
{ id: 'hausbesuch', label: 'Hausbesuch', icon: '🏠' },
{ id: 'tagesbetreuung', label: 'Tagesbetreuung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sun"></use></svg>' },
{ id: 'uebernachtung', label: 'Übernachtung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#moon"></use></svg>' },
{ id: 'gassi', label: 'Gassi gehen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>' },
{ id: 'hausbesuch', label: 'Hausbesuch', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#house-line"></use></svg>' },
];
// ----------------------------------------------------------
@ -44,10 +44,10 @@ window.Page_sitting = (() => {
function _render() {
_container.innerHTML = `
<div class="sitting-tabs" id="sit-tabs">
<button class="sitting-tab active" data-sit-tab="suchen">🔍 Sitter finden</button>
<button class="sitting-tab active" data-sit-tab="suchen">${UI.icon('magnifying-glass')} Sitter finden</button>
${_state.user ? `
<button class="sitting-tab" data-sit-tab="profil">👤 Mein Profil</button>
<button class="sitting-tab" data-sit-tab="anfragen">📬 Anfragen</button>
<button class="sitting-tab" data-sit-tab="profil">${UI.icon('user')} Mein Profil</button>
<button class="sitting-tab" data-sit-tab="anfragen">${UI.icon('bell')} Anfragen</button>
` : ''}
</div>
<div id="sit-content" class="sitting-content"></div>
@ -95,7 +95,7 @@ window.Page_sitting = (() => {
// ---- Tab: Sitter suchen ----
function _renderSuchen(el) {
if (!_sitters.length) {
el.innerHTML = UI.emptyState({ icon: '🐕', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' });
el.innerHTML = UI.emptyState({ icon: 'dog', title: 'Keine Sitter', text: 'Noch keine Sitter in deiner Nähe registriert.' });
return;
}
el.innerHTML = `
@ -115,10 +115,10 @@ window.Page_sitting = (() => {
: '';
return `
<div class="sitting-card" data-sit-id="${s.id}">
<div class="sitting-card-avatar">🐾</div>
<div class="sitting-card-avatar">${UI.icon('paw-print')}</div>
<div class="sitting-card-body">
<div class="sitting-card-name">${UI.escHtml(s.sitter_name)}</div>
${dist ? `<div class="sitting-card-dist">📍 ${dist} entfernt</div>` : ''}
${dist ? `<div class="sitting-card-dist">${UI.icon('map-pin')} ${dist} entfernt</div>` : ''}
${s.beschreibung ? `<div class="sitting-card-desc">${UI.escHtml(s.beschreibung)}</div>` : ''}
<div class="sitting-services">${svcs}</div>
</div>
@ -135,7 +135,7 @@ window.Page_sitting = (() => {
if (!_mySitter) {
el.innerHTML = `
<div class="sitting-empty-profil">
<div style="font-size:3rem">🐾</div>
<div style="font-size:3rem">${UI.icon('paw-print')}</div>
<h3>Werde Hundesitter</h3>
<p>Biete anderen Hundebesitzern deine Dienste an und verdiene etwas dazu.</p>
<button class="btn btn-primary" id="sit-create-profil-btn">Profil erstellen</button>
@ -149,9 +149,9 @@ window.Page_sitting = (() => {
<div class="sitting-my-profil">
<div class="sitting-profil-header">
<div class="sitting-profil-status ${s.aktiv ? 'active' : 'inactive'}">
${s.aktiv ? '✅ Aktiv' : '⏸️ Pausiert'}
${s.aktiv ? `${UI.icon('check')} Aktiv` : 'Pausiert'}
</div>
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn"> Bearbeiten</button>
<button class="btn btn-secondary btn-sm" id="sit-edit-profil-btn">${UI.icon('pencil-simple')} Bearbeiten</button>
</div>
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
<div class="sitting-profil-facts">
@ -172,17 +172,17 @@ window.Page_sitting = (() => {
let html = '';
if (inbox.length) {
html += `<div class="sitting-section-label">📬 Eingehende Anfragen (als Sitter)</div>`;
html += `<div class="sitting-section-label">${UI.icon('bell')} Eingehende Anfragen (als Sitter)</div>`;
html += inbox.map(r => _requestCardHTML(r, 'inbox')).join('');
}
if (myReqs.length) {
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">📤 Meine Anfragen</div>`;
html += `<div class="sitting-section-label" style="margin-top:var(--space-4)">${UI.icon('upload')} Meine Anfragen</div>`;
html += myReqs.map(r => _requestCardHTML(r, 'sent')).join('');
}
if (!inbox.length && !myReqs.length) {
html = UI.emptyState({ icon: '📬', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' });
html = UI.emptyState({ icon: 'bell', title: 'Keine Anfragen', text: 'Noch keine Sitting-Anfragen vorhanden.' });
}
el.innerHTML = html;
@ -198,7 +198,7 @@ window.Page_sitting = (() => {
<span class="sitting-req-name">${UI.escHtml(name || '?')}</span>
<span class="sitting-req-status" style="color:${color}">${r.status}</span>
</div>
<div class="sitting-req-dates">📅 ${r.von} ${r.bis}</div>
<div class="sitting-req-dates">${UI.icon('calendar-dots')} ${r.von} ${r.bis}</div>
${r.nachricht ? `<div class="sitting-req-msg">${UI.escHtml(r.nachricht)}</div>` : ''}
${r.status === 'offen' ? _requestActions(r.id, mode) : ''}
</div>
@ -209,14 +209,14 @@ window.Page_sitting = (() => {
if (mode === 'inbox') {
return `
<div class="sitting-req-actions">
<button class="btn btn-primary btn-sm" data-sit-accept="${id}"> Annehmen</button>
<button class="btn btn-danger btn-sm" data-sit-decline="${id}"> Ablehnen</button>
<button class="btn btn-primary btn-sm" data-sit-accept="${id}">${UI.icon('check')} Annehmen</button>
<button class="btn btn-danger btn-sm" data-sit-decline="${id}">${UI.icon('x')} Ablehnen</button>
</div>
`;
}
return `
<div class="sitting-req-actions">
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">🚫 Abbrechen</button>
<button class="btn btn-secondary btn-sm" data-sit-cancel="${id}">${UI.icon('x')} Abbrechen</button>
</div>
`;
}
@ -236,9 +236,9 @@ window.Page_sitting = (() => {
: null;
const body = `
<div class="sitting-detail-avatar">🐾</div>
<div class="sitting-detail-avatar">${UI.icon('paw-print')}</div>
<h3 style="margin:var(--space-2) 0">${UI.escHtml(s.sitter_name)}</h3>
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">📍 ${dist} entfernt</div>` : ''}
${dist ? `<div style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${dist} entfernt</div>` : ''}
${s.beschreibung ? `<p>${UI.escHtml(s.beschreibung)}</p>` : ''}
<div class="sitting-services" style="margin:var(--space-3) 0">${svcs}</div>
<div class="sitting-profil-facts">
@ -249,7 +249,7 @@ window.Page_sitting = (() => {
`;
const footer = _state.user && _mySitter?.user_id !== s.user_id ? `
<button class="btn btn-primary" id="sit-anfrage-btn">📬 Anfrage senden</button>
<button class="btn btn-primary" id="sit-anfrage-btn">${UI.icon('bell')} Anfrage senden</button>
` : (!_state.user ? `<span style="color:var(--c-text-secondary)">Zum Anfragen bitte einloggen.</span>` : '');
UI.modal.open({ title: 'Sitter-Profil', body, footer });
@ -368,7 +368,7 @@ window.Page_sitting = (() => {
<input class="form-control" type="number" step="any" name="lon" id="sit-lon" value="${s?.lon || ''}">
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">📍 Meine Position</button>
<button type="button" class="btn btn-secondary btn-sm" id="sit-gps-btn">${UI.icon('map-pin')} Meine Position</button>
<div class="form-group" style="margin-top:var(--space-3)">
<label class="form-label">Umkreis (km)</label>
<input class="form-control" type="number" min="1" max="100" name="radius_km" value="${s?.radius_km ?? 20}">

View file

@ -65,10 +65,10 @@ window.Page_walks = (() => {
<!-- Toolbar -->
<div class="walks-toolbar">
<div class="walks-view-toggle" id="walks-view-toggle">
<button class="walks-view-btn active" data-view="liste">📋 Liste</button>
<button class="walks-view-btn" data-view="karte">🗺 Karte</button>
<button class="walks-view-btn active" data-view="liste">${UI.icon('list')} Liste</button>
<button class="walks-view-btn" data-view="karte">${UI.icon('map-trifold')} Karte</button>
</div>
<button class="btn btn-primary btn-sm" id="walks-create-btn">+ Treffen planen</button>
<button class="btn btn-primary btn-sm" id="walks-create-btn">${UI.icon('plus')} Treffen planen</button>
</div>
<!-- Liste -->
@ -143,7 +143,7 @@ window.Page_walks = (() => {
if (!_data.length) {
el.innerHTML = `
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">🐕</div>
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('dog')}</div>
<p style="color:var(--c-text-secondary)">Noch keine Treffen in deiner Nähe.</p>
<button class="btn btn-primary" style="margin-top:var(--space-4)" id="walks-first-btn">
Erstes Treffen planen
@ -160,12 +160,12 @@ window.Page_walks = (() => {
let html = '';
if (heute.length) {
html += `<div class="walks-section-label">🌟 Heute</div>`;
html += `<div class="walks-section-label">${UI.icon('star')} Heute</div>`;
html += heute.map(w => _walkCardHTML(w)).join('');
}
if (upcoming.length) {
html += `<div class="walks-section-label">📅 Demnächst</div>`;
html += `<div class="walks-section-label">${UI.icon('calendar-dots')} Demnächst</div>`;
html += upcoming.map(w => _walkCardHTML(w)).join('');
}
@ -190,12 +190,12 @@ window.Page_walks = (() => {
</div>
<div class="walks-card-body">
<div class="walks-card-title">${_esc(w.titel)}</div>
${w.ort_name ? `<div class="walks-card-ort">📍 ${_esc(w.ort_name)}</div>` : ''}
${w.ort_name ? `<div class="walks-card-ort">${UI.icon('map-pin')} ${_esc(w.ort_name)}</div>` : ''}
<div class="walks-card-meta">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
</span>
<span class="walks-badge">🐾 ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
<span class="walks-badge">${UI.icon('paw-print')} ${w.teilnehmer_count}/${w.max_teilnehmer}</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
</div>
</div>
@ -241,7 +241,7 @@ window.Page_walks = (() => {
html: `<div style="background:${color};color:#fff;font-size:14px;font-weight:700;
width:32px;height:32px;border-radius:50%;display:flex;align-items:center;
justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);
border:2px solid rgba(255,255,255,0.8)">🐕</div>`,
border:2px solid rgba(255,255,255,0.8)">${UI.icon('dog')}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
const m = L.marker([w.lat, w.lon], { icon })
@ -273,8 +273,8 @@ window.Page_walks = (() => {
const teilnehmerHTML = walk.teilnehmer?.length
? walk.teilnehmer.map(t => `
<div class="walks-participant">
<span class="walks-participant-name">🧑 ${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">🐕 ${_esc(t.hunde)}</span>` : ''}
<span class="walks-participant-name">${UI.icon('user')} ${_esc(t.user_name)}</span>
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${_esc(t.hunde)}</span>` : ''}
</div>`).join('')
: `<p style="color:var(--c-text-muted)">Noch keine Teilnehmer.</p>`;
@ -284,12 +284,12 @@ window.Page_walks = (() => {
${_fmtDate(walk.datum)}<br>
<strong>um ${walk.uhrzeit} Uhr</strong>
</div>
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">📍 ${_esc(walk.ort_name)}</div>` : ''}
${walk.ort_name ? `<div style="margin-top:var(--space-2);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${_esc(walk.ort_name)}</div>` : ''}
<div style="margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap">
<span class="walks-badge ${isFull ? 'walks-badge--full' : 'walks-badge--open'}">
${isFull ? '🔴 Voll' : `🟢 ${spots} Platz${spots !== 1 ? 'e' : ''} frei`}
</span>
<span class="walks-badge">🐾 ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
<span class="walks-badge">${UI.icon('paw-print')} ${walk.teilnehmer_count}/${walk.max_teilnehmer} Teilnehmer</span>
${isOwn ? '<span class="walks-badge walks-badge--own">Dein Treffen</span>' : ''}
</div>
</div>
@ -330,11 +330,11 @@ window.Page_walks = (() => {
} else {
footer = `
<button type="button" class="btn btn-secondary flex-1" id="wd-close">Schließen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-join">🐕 Mitmachen</button>
<button type="button" class="btn btn-primary flex-1" id="wd-join">${UI.icon('dog')} Mitmachen</button>
`;
}
UI.modal.open({ title: `🐕 ${walk.titel}`, body, footer });
UI.modal.open({ title: `${UI.icon('dog')} ${walk.titel}`, body, footer });
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
@ -398,14 +398,14 @@ window.Page_walks = (() => {
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;
padding:var(--space-2) 0">
<input type="checkbox" name="dog" value="${d.id}" checked>
🐕 ${_esc(d.name)}
${UI.icon('dog')} ${_esc(d.name)}
</label>`).join('')
: `<p style="color:var(--c-text-muted)">Keine Hunde im Profil — du kannst trotzdem mitmachen.</p>`;
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
${_fmtDate(walk.datum)} um ${walk.uhrzeit} Uhr<br>
${walk.ort_name ? `📍 ${_esc(walk.ort_name)}` : ''}
${walk.ort_name ? `${UI.icon('map-pin')} ${_esc(walk.ort_name)}` : ''}
</p>
<div class="form-group">
<label class="form-label">Mit welchen Hunden?</label>
@ -415,7 +415,7 @@ window.Page_walks = (() => {
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="join-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="join-confirm">🐕 Mitmachen</button>
<button type="button" class="btn btn-primary flex-1" id="join-confirm">${UI.icon('dog')} Mitmachen</button>
`;
UI.modal.open({ title: `Treffen beitreten`, body, footer });
@ -485,12 +485,12 @@ window.Page_walks = (() => {
value="${_esc(v.ort_name || '')}"
placeholder="z. B. Parkeingang Nordseite, U-Bahn Volkspark"
style="flex:1">
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">📍</button>
<button type="button" class="btn btn-secondary" id="walk-gps-btn" title="GPS">${UI.icon('map-pin')}</button>
</div>
<input type="hidden" name="lat" id="walk-lat" value="${v.lat || ''}">
<input type="hidden" name="lon" id="walk-lon" value="${v.lon || ''}">
<small id="walk-gps-hint" style="color:var(--c-text-secondary)">
${v.lat ? '✅ Position gespeichert' : 'GPS-Button für aktuellen Standort'}
${v.lat ? `${UI.icon('check')} Position gespeichert` : 'GPS-Button für aktuellen Standort'}
</small>
</div>
@ -512,11 +512,11 @@ window.Page_walks = (() => {
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button>
<button type="submit" form="walk-form" class="btn btn-primary flex-1">
${isEdit ? 'Speichern' : '📅 Treffen planen'}
${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`}
</button>
`;
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : '🐕 Treffen planen', body, footer });
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : `${UI.icon('dog')} Treffen planen`, body, footer });
document.getElementById('wf-cancel')?.addEventListener('click', UI.modal.close);
@ -528,7 +528,7 @@ window.Page_walks = (() => {
_userPos = pos;
document.getElementById('walk-lat').value = pos.lat;
document.getElementById('walk-lon').value = pos.lon;
document.getElementById('walk-gps-hint').textContent = '✅ Standort ermittelt';
document.getElementById('walk-gps-hint').innerHTML = `${UI.icon('check')} Standort ermittelt`;
} catch { UI.toast.error('GPS nicht verfügbar.'); }
UI.setLoading(btn, false);
});
@ -539,7 +539,7 @@ window.Page_walks = (() => {
const fd = UI.formData(e.target);
if (!fd.lat || !fd.lon) {
UI.toast.warning('Bitte GPS-Position ermitteln (📍).');
UI.toast.warning('Bitte GPS-Position ermitteln.');
return;
}

View file

@ -0,0 +1,687 @@
/* ============================================================
BAN YARO Hunde-Wiki
Rassen-Datenbank, Gesundheit, Recht, Quiz
============================================================ */
window.Page_wiki = (() => {
// ----------------------------------------------------------
// MODUL-STATE
// ----------------------------------------------------------
let _container = null;
let _appState = null;
let _tab = 'rassen';
let _rassen = [];
let _gruppen = [];
let _totalBreeds = 0;
let _currentOffset = 0;
const PAGE_SIZE = 30;
let _currentSearch = '';
let _currentGruppe = '';
let _quizAnswers = {};
let _quizStep = 0;
// ----------------------------------------------------------
// HARDCODED: Gesundheits-Inhalte
// ----------------------------------------------------------
const GESUNDHEIT = [
{
titel: 'Zecken & FSME',
icon: 'skull',
text: 'Zecken sind von März bis November aktiv (Spitze AprilJuni, SeptemberOktober). Täglich nach Gassi auf Zecken untersuchen — besonders Ohren, Achseln, Leiste.\n\nZecke entfernen: Zeckenzange ansetzen, nicht drehen, gerade herausziehen. KEINE Öle/Vaseline.\n\nFSME: Impfung für Menschen empfohlen in Risikogebieten (RKI-Karte: rki.de/fsme). Hunde können Borreliose bekommen — Impfung empfohlen.',
},
{
titel: 'Vergiftungen — Sofortmaßnahmen',
icon: 'skull',
text: 'Verdacht auf Vergiftung: Sofort Tierarzt oder Tiergiftzentrale (Berlin: 030 19240, München: 089 19240).\n\nNICHT versuchen den Hund zum Erbrechen zu bringen ohne tierärztlichen Rat.\n\nHäufige Giftquellen: Schokolade, Trauben/Rosinen, Zwiebeln, Xylitol (Süßungsmittel), Ibuprofen.',
},
{
titel: 'Hitzschlag',
icon: 'warning',
text: 'Symptome: Starkes Hecheln, Speichelfluss, taumeln, Kollaps.\n\nSofortmaßnahme: In den Schatten, mit lauwarmem (nicht kaltem!) Wasser abkühlen, sofort zum Tierarzt.\n\nHunde NIEMALS im Auto lassen.',
},
{
titel: 'Erste Hilfe Grundlagen',
icon: 'first-aid',
text: 'Bewusstloser Hund: Atemwege frei? Atemkontrolle.\n\nHerzdruckmassage: 100120/min, 1/3 Brusttiefe.\n\nBeatmung: Maul zu, in Nase blasen.\n\nBlutung: Druckverband.\n\nKnochenbruch: Immobilisieren, tragen.',
},
];
// ----------------------------------------------------------
// HARDCODED: Recht & Regeln
// ----------------------------------------------------------
const RECHT = [
{ land: 'Bayern', leine: 'Anleinpflicht im Wald und in Ortschaften', rasse: 'Keine allgemeine Rasseliste (Gefährlichkeitsfeststellung individuell)', steuer: '~100€/Jahr (variiert nach Gemeinde)' },
{ land: 'Baden-Württemberg', leine: 'Leinenpflicht in Ortschaften und Parks', rasse: 'American Pitbull Terrier, American Staffordshire Terrier u.a.', steuer: '~100150€/Jahr' },
{ land: 'Berlin', leine: 'Allgemeine Leinenpflicht in öffentlichen Anlagen', rasse: 'Pitbull, Staffordshire, Rottweiler (bedingt)', steuer: '~120€/Jahr (ab 2. Hund: 180€)' },
{ land: 'Brandenburg', leine: 'Leinenpflicht in Ortschaften und Wäldern AprilJuli', rasse: 'Pitbull, American Staffordshire u.a.', steuer: '~60100€/Jahr' },
{ land: 'Hamburg', leine: 'Allgemeine Leinenpflicht', rasse: 'Pitbull, Rottweiler, Staffordshire u.a.', steuer: '~90€/Jahr (Kampfhund: 900€)' },
{ land: 'Hessen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a. (Liste)', steuer: '~75120€/Jahr' },
{ land: 'NRW', leine: 'Leinenpflicht in bebauten Gebieten', rasse: 'Pitbull, American Staffordshire, Staffordshire Bull Terrier u.a.', steuer: '~100160€/Jahr' },
{ land: 'Niedersachsen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60100€/Jahr' },
{ land: 'Sachsen', leine: 'Leinenpflicht in Ortschaften und öffentl. Anlagen', rasse: 'Keine staatliche Liste (kommunal)', steuer: '~50100€/Jahr' },
{ land: 'Thüringen', leine: 'Anleinpflicht in Wäldern und Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60100€/Jahr' },
];
// ----------------------------------------------------------
// QUIZ: Fragen
// ----------------------------------------------------------
const QUIZ_FRAGEN = [
{ key: 'groesse', frage: 'Welche Größe passt zu dir?', optionen: [{val:'klein', label:'Klein (unter 10 kg)'}, {val:'mittel', label:'Mittel (1030 kg)'}, {val:'gross', label:'Groß (über 30 kg)'}] },
{ key: 'aktivitaet', frage: 'Wie aktiv bist du?', optionen: [{val:'niedrig', label:'Eher gemütlich'}, {val:'mittel', label:'Regelmäßige Spaziergänge'}, {val:'hoch', label:'Sehr sportlich'}] },
{ key: 'erfahrung', frage: 'Wie viel Hundeerfahrung hast du?', optionen: [{val:'anfaenger', label:'Ersthundehalter'}, {val:'fortgeschritten', label:'Erfahren'}, {val:'experte', label:'Profi'}] },
{ key: 'kinder', frage: 'Lebst du mit Kindern zusammen?', optionen: [{val:'true', label:'Ja'}, {val:'false', label:'Nein'}] },
{ key: 'wohnung', frage: 'Wo wohnst du?', optionen: [{val:'true', label:'Wohnung (ohne Garten)'}, {val:'false', label:'Haus mit Garten'}] },
];
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
// ----------------------------------------------------------
// REFRESH
// ----------------------------------------------------------
async function refresh() {
// Wiki ist nicht hunde-spezifisch, kein Reload nötig
}
// ----------------------------------------------------------
// RENDER
// ----------------------------------------------------------
async function _render() {
_container.innerHTML = `
<div class="wiki-tab-bar" id="wiki-tab-bar">
<button class="wiki-tab-btn${_tab === 'rassen' ? ' active' : ''}" data-tab="rassen">${UI.icon('dog')} Rassen</button>
<button class="wiki-tab-btn${_tab === 'gesundheit'? ' active' : ''}" data-tab="gesundheit">${UI.icon('syringe')} Gesundheit</button>
<button class="wiki-tab-btn${_tab === 'recht' ? ' active' : ''}" data-tab="recht">${UI.icon('handshake')} Recht</button>
<button class="wiki-tab-btn${_tab === 'quiz' ? ' active' : ''}" data-tab="quiz">${UI.icon('star')} Quiz</button>
</div>
<div id="wiki-content"></div>
`;
_container.querySelector('#wiki-tab-bar').addEventListener('click', e => {
const btn = e.target.closest('[data-tab]');
if (!btn) return;
_tab = btn.dataset.tab;
_container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === _tab));
_renderTab();
});
await _renderTab();
}
async function _renderTab() {
const content = _container.querySelector('#wiki-content');
if (!content) return;
if (_tab === 'rassen') await _renderRassen(content);
else if (_tab === 'gesundheit') _renderGesundheit(content);
else if (_tab === 'recht') _renderRecht(content);
else if (_tab === 'quiz') _renderQuiz(content);
}
// ----------------------------------------------------------
// TAB: Rassen
// ----------------------------------------------------------
async function _renderRassen(el) {
// Check seeding state first
let stats;
try {
stats = await _apiFetch('/api/wiki/stats');
} catch {
el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Wiki konnte nicht geladen werden.' });
return;
}
if (!stats.seeded) {
el.innerHTML = `
<div class="wiki-loading-state">
<div class="wiki-loading-spinner"></div>
<p class="wiki-loading-text">Rassen-Datenbank wird geladen ${UI.icon('dog')}</p>
<p class="wiki-loading-hint">Beim ersten Start werden ~170 Rassen von TheDogAPI abgerufen.</p>
</div>
`;
return;
}
// Reset state when re-rendering the tab fresh
_rassen = [];
_currentOffset = 0;
_currentSearch = '';
_currentGruppe = '';
el.innerHTML = `
<div class="wiki-filter-bar">
<input class="form-control wiki-search-input" type="search" id="wiki-rassen-search" placeholder="Rasse suchen…">
<select class="form-control wiki-gruppe-select" id="wiki-gruppe-select">
<option value="">Alle Gruppen</option>
</select>
</div>
<div class="wiki-breed-grid" id="wiki-breed-grid"></div>
<div id="wiki-mehr-wrap" style="text-align:center;padding:var(--space-4) 0;display:none">
<button class="btn btn-secondary" id="wiki-mehr-btn">Mehr laden</button>
</div>
`;
// Load initial batch (also populates gruppen)
await _loadBreeds(el, true);
// Search handler with debounce
let _searchTimer;
el.querySelector('#wiki-rassen-search').addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_currentSearch = e.target.value;
_rassen = [];
_currentOffset = 0;
_loadBreeds(el, true);
}, 300);
});
// Gruppe filter handler
el.querySelector('#wiki-gruppe-select').addEventListener('change', e => {
_currentGruppe = e.target.value;
_rassen = [];
_currentOffset = 0;
_loadBreeds(el, true);
});
// "Mehr laden" button
el.querySelector('#wiki-mehr-btn').addEventListener('click', () => {
_loadBreeds(el, false);
});
}
async function _loadBreeds(el, reset) {
const grid = el.querySelector('#wiki-breed-grid');
const mehrWrap = el.querySelector('#wiki-mehr-wrap');
const mehrBtn = el.querySelector('#wiki-mehr-btn');
if (!grid) return;
if (reset) {
grid.innerHTML = `<div class="wiki-breeds-loading">Lade Rassen…</div>`;
}
const params = new URLSearchParams({
search: _currentSearch,
gruppe: _currentGruppe,
limit: PAGE_SIZE,
offset: _currentOffset,
});
let data;
try {
data = await _apiFetch(`/api/wiki/rassen?${params}`);
} catch {
grid.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Rassen konnten nicht geladen werden.</p>`;
return;
}
// Populate Gruppen dropdown (only on first load)
if (reset && data.gruppen && data.gruppen.length) {
_gruppen = data.gruppen;
const sel = el.querySelector('#wiki-gruppe-select');
if (sel) {
// Preserve current selection
const cur = _currentGruppe;
sel.innerHTML = `<option value="">Alle Gruppen</option>` +
_gruppen.map(g => `<option value="${_esc(g)}"${g === cur ? ' selected' : ''}>${_esc(g)}</option>`).join('');
}
}
if (reset) {
_rassen = data.breeds;
_totalBreeds = data.total;
grid.innerHTML = '';
} else {
_rassen = _rassen.concat(data.breeds);
_currentOffset += data.breeds.length;
}
if (reset) {
_currentOffset = data.breeds.length;
}
if (reset && _rassen.length === 0) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Keine Rassen gefunden.</p>`;
if (mehrWrap) mehrWrap.style.display = 'none';
return;
}
// Render cards
const newCards = data.breeds.map(r => _breedCardHtml(r)).join('');
if (reset) {
grid.innerHTML = newCards;
} else {
grid.insertAdjacentHTML('beforeend', newCards);
}
// Attach click handlers to newly added cards
grid.querySelectorAll('.wiki-breed-card:not([data-bound])').forEach(card => {
card.dataset.bound = '1';
card.addEventListener('click', () => _openBreedDetail(card.dataset.slug));
});
// Show/hide "Mehr laden"
if (mehrWrap) {
const shown = _rassen.length;
mehrWrap.style.display = shown < _totalBreeds ? 'block' : 'none';
if (mehrBtn) mehrBtn.textContent = `Mehr laden (${_totalBreeds - shown} weitere)`;
}
}
function _breedCardHtml(r) {
const photoHtml = r.foto_url
? `<img class="wiki-breed-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: '';
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${r.foto_url ? 'display:none' : ''}">${UI.icon('dog')}</div>`;
return `
<div class="wiki-breed-card" data-slug="${_esc(r.slug)}">
<div class="wiki-breed-photo-wrap">
${photoHtml}
${fallbackHtml}
</div>
<div class="wiki-breed-card-body">
<div class="wiki-breed-card-name">${_esc(r.name)}</div>
<div class="wiki-breed-card-gruppe">${_esc(r.gruppe || '—')}</div>
<div class="wiki-breed-badges">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(r.erfahrung)}">${_erfahrungLabel(r.erfahrung)}</span>
</div>
</div>
</div>
`;
}
async function _openBreedDetail(slug) {
let rasse;
try {
rasse = await _apiFetch(`/api/wiki/rassen/${slug}`);
} catch {
UI.toast.error('Rasse konnte nicht geladen werden.');
return;
}
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
// Temperament chips
const chips = rasse.temperament
? rasse.temperament.split(',').map(t => `<span class="wiki-trait-chip">${_esc(t.trim())}</span>`).join('')
: '';
// Stats row
const gewicht = (rasse.gewicht_min_kg && rasse.gewicht_max_kg)
? `${rasse.gewicht_min_kg}${rasse.gewicht_max_kg} kg`
: (rasse.gewicht_max_kg ? `bis ${rasse.gewicht_max_kg} kg` : '—');
const photoHtml = rasse.foto_url
? `<img class="wiki-detail-photo" src="${_esc(rasse.foto_url)}" alt="${_esc(rasse.name)}" onerror="this.style.display='none'">`
: '';
const body = `
${photoHtml}
<div class="wiki-detail-badges">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(rasse.groesse)}">${_groesseLabel(rasse.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(rasse.aktivitaet)}">${_aktivLabel(rasse.aktivitaet)}</span>
<span class="wiki-badge-erfahrung wiki-badge-erfahrung--${_esc(rasse.erfahrung)}">${_erfahrungLabel(rasse.erfahrung)}</span>
${rasse.gruppe ? `<span class="badge">${_esc(rasse.gruppe)}</span>` : ''}
</div>
${rasse.herkunft || rasse.bred_for ? `
<div class="wiki-detail-section">
${rasse.herkunft ? `<div class="wiki-detail-label">Herkunft</div><p>${_esc(rasse.herkunft)}</p>` : ''}
${rasse.bred_for ? `<div class="wiki-detail-label">Ursprüngliche Aufgabe</div><p>${_esc(rasse.bred_for)}</p>` : ''}
</div>` : ''}
${chips ? `
<div class="wiki-detail-section">
<div class="wiki-detail-label">Charakter</div>
<div class="wiki-trait-chips">${chips}</div>
</div>` : ''}
<div class="wiki-stat-row">
<div class="wiki-stat-item">
<span class="wiki-stat-label">Gewicht</span>
<span class="wiki-stat-value">${gewicht}</span>
</div>
<div class="wiki-stat-item">
<span class="wiki-stat-label">Lebenserwartung</span>
<span class="wiki-stat-value">${_esc(rasse.lebensdauer || '—')}</span>
</div>
</div>
<div class="wiki-fit-row">
<span>${UI.icon('house-line')} Wohnung: ${rasse.wohnung_geeignet ? UI.icon('check') : UI.icon('x')}</span>
<span>${UI.icon('users')} Kinder: ${rasse.kinder_geeignet ? UI.icon('check') : UI.icon('x')}</span>
</div>
<div class="wiki-detail-section" id="wiki-berichte-section">
<div class="wiki-detail-label">Community-Berichte</div>
${berichteHtml}
</div>
${_appState.user
? `<button class="btn btn-secondary w-full" id="wiki-bericht-add-btn" style="margin-top:var(--space-3)">+ Eigenen Bericht hinzufügen</button>`
: `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:var(--space-3)">
<a href="#settings" style="color:var(--c-primary)">Anmelden</a>, um einen Bericht zu schreiben.
</p>`
}
`;
UI.modal.open({ title: _esc(rasse.name), body });
document.getElementById('wiki-bericht-add-btn')?.addEventListener('click', () => {
UI.modal.close();
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
});
}
function _renderBerichteHtml(berichte, slug) {
if (!berichte || berichte.length === 0) {
return `<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">Noch keine Community-Berichte für diese Rasse.</p>`;
}
return berichte.map(b => `
<div class="wiki-bericht-item" data-id="${b.id}">
<div class="wiki-bericht-header">
<span class="wiki-bericht-autor">${_esc(b.autor)}</span>
<span class="wiki-bericht-date">${_formatDate(b.created_at)}</span>
${_appState.user && _appState.user.name === b.autor
? `<button class="btn btn-danger btn-xs wiki-bericht-del" data-id="${b.id}" data-slug="${_esc(slug)}" style="margin-left:auto;padding:2px 8px;font-size:0.7rem">Löschen</button>`
: ''}
</div>
<div class="wiki-bericht-titel">${_esc(b.titel)}</div>
<p class="wiki-bericht-text">${_esc(b.text)}</p>
</div>
`).join('');
}
function _showBerichtForm(slug, rasseName) {
const body = `
<form id="wiki-bericht-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Rasse</label>
<input class="form-control" type="text" value="${_esc(rasseName)}" disabled>
</div>
<div class="form-group">
<label class="form-label">Titel</label>
<input class="form-control" type="text" name="titel" maxlength="120" placeholder="z.B. Mein Erfahrungsbericht" required>
</div>
<div class="form-group">
<label class="form-label">Bericht</label>
<textarea class="form-control" name="text" rows="6" placeholder="Deine Erfahrungen mit dieser Rasse…" required></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wiki-bericht-cancel">Abbrechen</button>
<button type="submit" form="wiki-bericht-form" class="btn btn-primary flex-1">Veröffentlichen</button>
`;
UI.modal.open({ title: 'Bericht schreiben', body, footer });
document.getElementById('wiki-bericht-cancel')?.addEventListener('click', UI.modal.close);
const form = document.getElementById('wiki-bericht-form');
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
form.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.querySelector('[form="wiki-bericht-form"][type="submit"]');
const fd = UI.formData(form);
await UI.asyncButton(submitBtn, async () => {
try {
await _apiPost('/api/wiki/berichte', { rasse: slug, titel: fd.titel, text: fd.text });
UI.modal.close();
UI.toast.success('Bericht veröffentlicht!');
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Veröffentlichen.');
}
});
});
}
// ----------------------------------------------------------
// TAB: Gesundheit
// ----------------------------------------------------------
function _renderGesundheit(el) {
const items = GESUNDHEIT.map((s, i) => `
<div class="wiki-section" data-idx="${i}">
<div class="wiki-section-header">
<span class="wiki-section-icon">${UI.icon(s.icon)}</span>
<span class="wiki-section-titel">${_esc(s.titel)}</span>
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
</div>
<div class="wiki-section-body" style="display:none">
<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_esc(s.text)}</p>
</div>
</div>
`).join('');
el.innerHTML = `<div class="wiki-accordion">${items}</div>`;
el.querySelectorAll('.wiki-section').forEach(sec => {
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
const body = sec.querySelector('.wiki-section-body');
const arrow = sec.querySelector('.wiki-section-arrow');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
sec.classList.toggle('open', !open);
});
});
}
// ----------------------------------------------------------
// TAB: Recht & Regeln
// ----------------------------------------------------------
function _renderRecht(el) {
const items = RECHT.map((r, i) => `
<div class="wiki-section" data-idx="${i}">
<div class="wiki-section-header">
<span class="wiki-section-icon">${UI.icon('map-pin')}</span>
<span class="wiki-section-titel">${_esc(r.land)}</span>
<span class="wiki-section-arrow">${UI.icon('caret-down')}</span>
</div>
<div class="wiki-section-body" style="display:none">
<div class="wiki-recht-row"><span class="wiki-recht-label">Leinenpflicht</span><span>${_esc(r.leine)}</span></div>
<div class="wiki-recht-row"><span class="wiki-recht-label">Rasseliste</span><span>${_esc(r.rasse)}</span></div>
<div class="wiki-recht-row"><span class="wiki-recht-label">Hundesteuer</span><span>${_esc(r.steuer)}</span></div>
</div>
</div>
`).join('');
el.innerHTML = `
<div class="wiki-accordion">${items}</div>
<p class="wiki-disclaimer">Angaben ohne Gewähr Regelungen ändern sich. Bitte beim zuständigen Ordnungsamt prüfen.</p>
`;
el.querySelectorAll('.wiki-section').forEach(sec => {
sec.querySelector('.wiki-section-header').addEventListener('click', () => {
const body = sec.querySelector('.wiki-section-body');
const arrow = sec.querySelector('.wiki-section-arrow');
const open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
arrow.innerHTML = open ? UI.icon('caret-down') : UI.icon('caret-up');
sec.classList.toggle('open', !open);
});
});
}
// ----------------------------------------------------------
// TAB: Quiz
// ----------------------------------------------------------
function _renderQuiz(el) {
_quizAnswers = {};
_quizStep = 0;
_renderQuizStep(el);
}
function _renderQuizStep(el) {
if (_quizStep >= QUIZ_FRAGEN.length) {
_loadQuizResult(el);
return;
}
const frage = QUIZ_FRAGEN[_quizStep];
const progress = Math.round((_quizStep / QUIZ_FRAGEN.length) * 100);
const optionsHtml = frage.optionen.map(o => `
<button class="wiki-quiz-option${_quizAnswers[frage.key] === o.val ? ' selected' : ''}"
data-key="${_esc(frage.key)}" data-val="${_esc(o.val)}">
${_esc(o.label)}
</button>
`).join('');
el.innerHTML = `
<div class="wiki-quiz-wrap">
<div class="wiki-quiz-progress-bar">
<div class="wiki-quiz-progress" style="width:${progress}%"></div>
</div>
<p class="wiki-quiz-step-info">Frage ${_quizStep + 1} von ${QUIZ_FRAGEN.length}</p>
<p class="wiki-quiz-frage">${_esc(frage.frage)}</p>
<div class="wiki-quiz-options">${optionsHtml}</div>
<div class="wiki-quiz-nav">
${_quizStep > 0
? `<button class="btn btn-secondary" id="quiz-back">Zurück</button>`
: '<span></span>'}
</div>
</div>
`;
el.querySelectorAll('.wiki-quiz-option').forEach(btn => {
btn.addEventListener('click', () => {
_quizAnswers[btn.dataset.key] = btn.dataset.val;
_quizStep++;
_renderQuizStep(el);
});
});
el.querySelector('#quiz-back')?.addEventListener('click', () => {
_quizStep--;
const prevKey = QUIZ_FRAGEN[_quizStep].key;
delete _quizAnswers[prevKey];
_renderQuizStep(el);
});
}
async function _loadQuizResult(el) {
el.innerHTML = `<div style="text-align:center;padding:var(--space-8)">Berechne Ergebnis…</div>`;
const params = new URLSearchParams(_quizAnswers).toString();
let data;
try {
data = await _apiFetch(`/api/wiki/quiz/result?${params}`);
} catch {
el.innerHTML = UI.emptyState({ icon: 'warning', title: 'Fehler', text: 'Ergebnis konnte nicht geladen werden.' });
return;
}
const cardsHtml = data.results.map(r => {
const photoHtml = r.foto_url
? `<img class="wiki-quiz-result-photo" src="${_esc(r.foto_url)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none'">`
: `<div class="wiki-quiz-result-photo-fallback">${UI.icon('dog')}</div>`;
return `
<div class="wiki-quiz-result-card">
<div class="wiki-quiz-result-photo-wrap">${photoHtml}</div>
<div class="wiki-quiz-result-card-body">
<div class="wiki-quiz-result-name">${_esc(r.name)}</div>
<div class="wiki-quiz-result-gruppe">${_esc(r.gruppe || '')}</div>
<div class="wiki-breed-badges" style="margin:var(--space-2) 0">
<span class="wiki-badge-groesse wiki-badge-groesse--${_esc(r.groesse)}">${_groesseLabel(r.groesse)}</span>
<span class="wiki-badge-aktivitaet wiki-badge-aktivitaet--${_esc(r.aktivitaet)}">${_aktivLabel(r.aktivitaet)}</span>
</div>
${r.temperament ? `<p class="wiki-quiz-result-char">${_esc(r.temperament.split(',').slice(0,4).join(', '))}</p>` : ''}
<div class="wiki-fit-row" style="font-size:var(--text-xs);margin-top:var(--space-1)">
<span>${UI.icon('house-line')} ${r.wohnung_geeignet ? 'Wohnung' : 'Haus'}</span>
<span>${UI.icon('users')} ${r.kinder_geeignet ? 'Kinderfreundlich' : 'Erfahrung nötig'}</span>
</div>
<button class="btn btn-secondary btn-sm wiki-quiz-mehr" data-slug="${_esc(r.slug)}" style="margin-top:var(--space-2)">Mehr erfahren</button>
</div>
</div>
`;
}).join('');
el.innerHTML = `
<div class="wiki-quiz-wrap">
<div class="wiki-quiz-progress-bar">
<div class="wiki-quiz-progress" style="width:100%"></div>
</div>
<h3 style="margin:var(--space-4) 0 var(--space-2);text-align:center">Deine Top 3 Rassen</h3>
<div class="wiki-quiz-results">${cardsHtml}</div>
<button class="btn btn-secondary w-full" id="quiz-restart" style="margin-top:var(--space-4)">Quiz neu starten</button>
</div>
`;
el.querySelectorAll('.wiki-quiz-mehr').forEach(btn => {
btn.addEventListener('click', () => {
_tab = 'rassen';
_container.querySelectorAll('.wiki-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === 'rassen'));
_openBreedDetail(btn.dataset.slug);
});
});
el.querySelector('#quiz-restart')?.addEventListener('click', () => {
_renderQuiz(el);
});
}
// ----------------------------------------------------------
// HELPER: API-Fetch
// ----------------------------------------------------------
async function _apiFetch(url) {
const resp = await fetch(url, { credentials: 'include' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
async function _apiPost(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
return resp.json();
}
// ----------------------------------------------------------
// HELPER: Labels
// ----------------------------------------------------------
function _groesseLabel(g) {
return { klein: 'Klein', mittel: 'Mittel', gross: 'Groß', sehr_gross: 'Sehr groß' }[g] || g;
}
function _aktivLabel(a) {
return { niedrig: 'Ruhig', mittel: 'Aktiv', hoch: 'Sportlich', sehr_hoch: 'Sehr aktiv' }[a] || a;
}
function _erfahrungLabel(e) {
return { anfaenger: 'Anfänger', fortgeschritten: 'Erfahren', experte: 'Experte' }[e] || e;
}
function _formatDate(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return iso; }
}
function _esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
return { init, refresh };
})();

View file

@ -6,6 +6,13 @@
const UI = (() => {
// ----------------------------------------------------------
// PHOSPHOR ICON HELPER — erzeugt SVG-String für Templates
// ----------------------------------------------------------
function _svgIcon(name, extraClass = '') {
return `<svg class="ph-icon${extraClass ? ' ' + extraClass : ''}" aria-hidden="true"><use href="/icons/phosphor.svg#${name}"></use></svg>`;
}
// ----------------------------------------------------------
// TOAST
// ----------------------------------------------------------
@ -16,9 +23,9 @@ const UI = (() => {
const el = document.createElement('div');
el.className = `toast${type !== 'default' ? ` toast-${type}` : ''}`;
const icon = { success: '✓', danger: '✕', warning: '⚠', info: '' }[type] || '';
el.innerHTML = icon
? `<span style="font-size:1.1em">${icon}</span><span>${message}</span>`
const iconName = { success: 'check', danger: 'x', warning: 'warning', info: 'info' }[type];
el.innerHTML = iconName
? `${_svgIcon(iconName)}<span>${message}</span>`
: `<span>${message}</span>`;
container().appendChild(el);
@ -59,7 +66,7 @@ const UI = (() => {
${title ? `
<div class="modal-header">
<span class="modal-title">${title}</span>
<button class="btn btn-ghost btn-icon modal-close-btn" aria-label="Schließen"></button>
<button class="btn btn-ghost btn-icon modal-close-btn" aria-label="Schließen"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg></button>
</div>
` : ''}
<div class="modal-body">${body || ''}</div>
@ -259,6 +266,7 @@ const UI = (() => {
formData, setFormError, clearFormErrors,
emptyState, time,
setupPhotoPreview, scrollTop, skeleton,
icon: _svgIcon,
};
})();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v62';
const CACHE_VERSION = 'by-v89';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
@ -12,6 +12,7 @@ const STATIC_ASSETS = [
'/css/design-system.css',
'/css/layout.css',
'/css/components.css',
'/icons/phosphor.svg',
'/js/api.js',
'/js/ui.js',
'/js/app.js',
@ -192,6 +193,22 @@ self.addEventListener('push', event => {
requireInteraction: data.requireInteraction || false,
};
// Chat-Nachricht
if (data.type === 'chat_message') {
options.tag = data.tag || `chat-${data.data?.conversation_id}`;
options.renotify = true;
options.actions = [
{ action: 'reply', title: 'Antworten' },
{ action: 'dismiss', title: 'Schließen' },
];
}
// Freundschaftsanfrage
if (data.type === 'friend_request') {
options.tag = 'friend-request';
options.actions = [{ action: 'view', title: 'Anzeigen' }];
}
// Giftköder-Alarm: besondere Darstellung
if (data.type === 'poison_alert') {
options.tag = 'poison-alert';
@ -219,9 +236,10 @@ self.addEventListener('notificationclick', event => {
let url = '/';
if (action === 'view' || data?.page) {
if (action === 'view' || action === 'reply' || data?.page) {
url = `/#${data.page || 'poison'}`;
if (data.id) url += `?id=${data.id}`;
if (data.conversation_id) url += `?conversation_id=${data.conversation_id}`;
else if (data.id) url += `?id=${data.id}`;
}
event.waitUntil(

71
backend/weather.py Normal file
View file

@ -0,0 +1,71 @@
"""
BAN YARO Wetter-Zusammenfassung via Open-Meteo
Prüft mehrere deutsche Städte und liefert max. Temperatur und Gewitterwarnung.
"""
import logging
import httpx
logger = logging.getLogger(__name__)
# Wichtige deutsche Städte als Stichprobe
CITIES = [
{"name": "Berlin", "lat": 52.52, "lon": 13.41},
{"name": "München", "lat": 48.14, "lon": 11.58},
{"name": "Hamburg", "lat": 53.55, "lon": 10.00},
{"name": "Frankfurt", "lat": 50.11, "lon": 8.68},
{"name": "Köln", "lat": 50.94, "lon": 6.96},
]
# WMO-Wettercodes 9599 = Gewitter
THUNDERSTORM_CODES = {95, 96, 99}
async def get_weather_summary() -> dict:
"""
Holt Tagesprognose für mehrere deutsche Städte von Open-Meteo.
Gibt zurück: {"max_temp_c": float, "thunderstorm": bool}
"""
max_temp = -999.0
thunderstorm = False
async with httpx.AsyncClient(timeout=10.0) as client:
for city in CITIES:
url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={city['lat']}&longitude={city['lon']}"
"&daily=temperature_2m_max,precipitation_probability_max,weathercode"
"&timezone=Europe%2FBerlin&forecast_days=1"
)
try:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
daily = data.get("daily", {})
temps = daily.get("temperature_2m_max", [None])
precip_probs = daily.get("precipitation_probability_max", [None])
weathercodes = daily.get("weathercode", [None])
temp = temps[0] if temps else None
precip_prob = precip_probs[0] if precip_probs else None
weathercode = weathercodes[0] if weathercodes else None
if temp is not None and temp > max_temp:
max_temp = temp
if (
precip_prob is not None and precip_prob > 50
and weathercode is not None and int(weathercode) in THUNDERSTORM_CODES
):
thunderstorm = True
logger.info(f"Gewitter erkannt: {city['name']} (Code {weathercode}, {precip_prob}% Niederschlag)")
except Exception as e:
logger.warning(f"Wetter-Abruf für {city['name']} fehlgeschlagen: {e}")
if max_temp == -999.0:
max_temp = 0.0
logger.info(f"Wetter-Zusammenfassung: max_temp={max_temp}°C, thunderstorm={thunderstorm}")
return {"max_temp_c": max_temp, "thunderstorm": thunderstorm}