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:
parent
96bd57f0ad
commit
097295c628
44 changed files with 9980 additions and 300 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
306
backend/main.py
306
backend/main.py
|
|
@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}}
|
||||
|
||||
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
191
backend/routes/chat.py
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
551
backend/routes/forum.py
Normal 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
148
backend/routes/friends.py
Normal 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
113
backend/routes/knigge.py
Normal 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
171
backend/routes/lost.py
Normal 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
185
backend/routes/movies.py
Normal 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 (1800–1812). 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 # 1–5
|
||||
|
||||
|
||||
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
258
backend/routes/wiki.py
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
0
backend/scraper/__init__.py
Normal file
0
backend/scraper/__init__.py
Normal file
138
backend/scraper/breeds.py
Normal file
138
backend/scraper/breeds.py
Normal 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
|
||||
317
backend/scraper/events_vdh.py
Normal file
317
backend/scraper/events_vdh.py
Normal 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
|
||||
196
backend/scraper/wikidata_breeds.py
Normal file
196
backend/scraper/wikidata_breeds.py
Normal 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
|
|
@ -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; }
|
||||
|
|
|
|||
78
backend/static/icons/phosphor.svg
Normal file
78
backend/static/icons/phosphor.svg
Normal 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 |
|
|
@ -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">←</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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
344
backend/static/js/pages/chat.js
Normal file
344
backend/static/js/pages/chat.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
return {
|
||||
init,
|
||||
_showList,
|
||||
_openThread,
|
||||
_send,
|
||||
_deleteMsg,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
@ -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('')}
|
||||
|
|
|
|||
|
|
@ -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>` : ''}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
890
backend/static/js/pages/forum.js
Normal file
890
backend/static/js/pages/forum.js
Normal 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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
})();
|
||||
282
backend/static/js/pages/friends.js
Normal file
282
backend/static/js/pages/friends.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
return {
|
||||
init,
|
||||
_sendRequest, _accept, _decline, _cancel, _removeFriend, _openChat,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
413
backend/static/js/pages/knigge.js
Normal file
413
backend/static/js/pages/knigge.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
688
backend/static/js/pages/lost.js
Normal file
688
backend/static/js/pages/lost.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh, openNew };
|
||||
|
||||
})();
|
||||
|
|
@ -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!`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
409
backend/static/js/pages/movies.js
Normal file
409
backend/static/js/pages/movies.js
Normal 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 (1800–1812). 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
687
backend/static/js/pages/wiki.js
Normal file
687
backend/static/js/pages/wiki.js
Normal 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 April–Juni, September–Oktober). 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: 100–120/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: '~100–150€/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 April–Juli', rasse: 'Pitbull, American Staffordshire u.a.', steuer: '~60–100€/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: '~75–120€/Jahr' },
|
||||
{ land: 'NRW', leine: 'Leinenpflicht in bebauten Gebieten', rasse: 'Pitbull, American Staffordshire, Staffordshire Bull Terrier u.a.', steuer: '~100–160€/Jahr' },
|
||||
{ land: 'Niedersachsen', leine: 'Anleinpflicht in Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60–100€/Jahr' },
|
||||
{ land: 'Sachsen', leine: 'Leinenpflicht in Ortschaften und öffentl. Anlagen', rasse: 'Keine staatliche Liste (kommunal)', steuer: '~50–100€/Jahr' },
|
||||
{ land: 'Thüringen', leine: 'Anleinpflicht in Wäldern und Ortschaften', rasse: 'Pitbull u.a.', steuer: '~60–100€/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 (10–30 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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
|
||||
})();
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
71
backend/weather.py
Normal 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 95–99 = 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue