Sprint 11b: Wiki-Foto-Einreichungen + Wikipedia-Foto-Scraper
- User können Fotos für Rassen vorschlagen (Upload-Modal in Rassen-Detail) - Mod/Admin-Review-Tab im Wiki mit Freischalten/Ablehnen + Push-Notification - wikipedia_photos.py: holt Fotos über Wikidata-QID → Wikipedia-API - Foto-Status: 578 lokal, 186 extern, 238 ohne Foto - DB: wiki_foto_submissions Tabelle - SW by-v90
This commit is contained in:
parent
097295c628
commit
32d630d5a1
6 changed files with 598 additions and 3 deletions
|
|
@ -525,6 +525,22 @@ def _migrate(conn_factory):
|
||||||
ON events(external_id) WHERE external_id IS NOT NULL;
|
ON events(external_id) WHERE external_id IS NOT NULL;
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Wiki: User-Foto-Einreichungen
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_foto_submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
rasse_id INTEGER NOT NULL REFERENCES wiki_rassen(id) ON DELETE CASCADE,
|
||||||
|
foto_url TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
reviewed_by INTEGER REFERENCES users(id),
|
||||||
|
reviewed_at TEXT,
|
||||||
|
reject_reason TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wfs_status ON wiki_foto_submissions(status, created_at DESC);
|
||||||
|
""")
|
||||||
|
|
||||||
# Freundschaften + Direktnachrichten
|
# Freundschaften + Direktnachrichten
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS friendships (
|
CREATE TABLE IF NOT EXISTS friendships (
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
"""BAN YARO — Hunde-Wiki Routes"""
|
"""BAN YARO — Hunde-Wiki Routes"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds")
|
||||||
|
SUBMIT_DIR = os.path.join(BREEDS_DIR, "submissions")
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -256,3 +265,175 @@ async def quiz_result(
|
||||||
]
|
]
|
||||||
|
|
||||||
return {"results": top3}
|
return {"results": top3}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/wiki/rassen/{slug}/foto — User reicht Foto ein
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/rassen/{slug}/foto", status_code=201)
|
||||||
|
async def submit_foto(
|
||||||
|
slug: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
with db() as conn:
|
||||||
|
rasse = conn.execute(
|
||||||
|
"SELECT id, name, external_id FROM wiki_rassen WHERE slug=?", (slug,)
|
||||||
|
).fetchone()
|
||||||
|
if not rasse:
|
||||||
|
raise HTTPException(404, "Rasse nicht gefunden.")
|
||||||
|
|
||||||
|
# Dateiformat prüfen
|
||||||
|
ct = file.content_type or ""
|
||||||
|
if not ct.startswith("image/"):
|
||||||
|
raise HTTPException(400, "Nur Bilddateien erlaubt.")
|
||||||
|
|
||||||
|
os.makedirs(SUBMIT_DIR, exist_ok=True)
|
||||||
|
ts = int(time.time())
|
||||||
|
filename = f"{slug}_{user['id']}_{ts}.jpg"
|
||||||
|
path = os.path.join(SUBMIT_DIR, filename)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > 8 * 1024 * 1024:
|
||||||
|
raise HTTPException(400, "Datei zu groß (max. 8 MB).")
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
local_url = f"/media/breeds/submissions/{filename}"
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
# Bestehende pending-Einreichung des Users für diese Rasse ersetzen
|
||||||
|
old = conn.execute(
|
||||||
|
"SELECT foto_url FROM wiki_foto_submissions WHERE rasse_id=? AND user_id=? AND status='pending'",
|
||||||
|
(rasse["id"], user["id"])
|
||||||
|
).fetchone()
|
||||||
|
if old:
|
||||||
|
try:
|
||||||
|
old_path = old["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
|
||||||
|
if os.path.exists(old_path):
|
||||||
|
os.remove(old_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM wiki_foto_submissions WHERE rasse_id=? AND user_id=? AND status='pending'",
|
||||||
|
(rasse["id"], user["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO wiki_foto_submissions (user_id, rasse_id, foto_url)
|
||||||
|
VALUES (?,?,?)
|
||||||
|
""", (user["id"], rasse["id"], local_url))
|
||||||
|
|
||||||
|
logger.info(f"Foto-Einreichung: {rasse['name']} von User {user['id']}")
|
||||||
|
return {"ok": True, "foto_url": local_url}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# GET /api/wiki/foto-submissions — offene Einreichungen (Mod/Admin)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.get("/foto-submissions")
|
||||||
|
async def list_submissions(user=Depends(get_current_user)):
|
||||||
|
if not (user.get("is_moderator") or user.get("rolle") == "admin"):
|
||||||
|
raise HTTPException(403, "Nur Moderatoren.")
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT s.id, s.foto_url, s.status, s.created_at,
|
||||||
|
u.name AS user_name,
|
||||||
|
r.name AS rasse_name, r.slug AS rasse_slug,
|
||||||
|
r.foto_url AS aktuell_foto
|
||||||
|
FROM wiki_foto_submissions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
JOIN wiki_rassen r ON r.id = s.rasse_id
|
||||||
|
WHERE s.status = 'pending'
|
||||||
|
ORDER BY s.created_at ASC
|
||||||
|
""").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PATCH /api/wiki/foto-submissions/{id} — genehmigen oder ablehnen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
class ReviewModel(BaseModel):
|
||||||
|
action: str # "approve" | "reject"
|
||||||
|
reject_reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/foto-submissions/{sub_id}")
|
||||||
|
async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_current_user)):
|
||||||
|
if not (user.get("is_moderator") or user.get("rolle") == "admin"):
|
||||||
|
raise HTTPException(403, "Nur Moderatoren.")
|
||||||
|
if data.action not in ("approve", "reject"):
|
||||||
|
raise HTTPException(400, "action muss 'approve' oder 'reject' sein.")
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
sub = conn.execute(
|
||||||
|
"SELECT * FROM wiki_foto_submissions WHERE id=? AND status='pending'",
|
||||||
|
(sub_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(404, "Einreichung nicht gefunden.")
|
||||||
|
|
||||||
|
rasse = conn.execute(
|
||||||
|
"SELECT id, external_id, slug FROM wiki_rassen WHERE id=?",
|
||||||
|
(sub["rasse_id"],)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if data.action == "approve":
|
||||||
|
# Ziel-Dateiname aus external_id ableiten
|
||||||
|
ext_id = rasse["external_id"] if rasse else None
|
||||||
|
if ext_id and str(ext_id).startswith("wd_"):
|
||||||
|
qid = str(ext_id).replace("wd_", "")
|
||||||
|
dest_name = f"{qid}.jpg"
|
||||||
|
elif ext_id:
|
||||||
|
dest_name = f"{ext_id}.jpg"
|
||||||
|
else:
|
||||||
|
dest_name = f"{rasse['slug']}.jpg"
|
||||||
|
|
||||||
|
os.makedirs(BREEDS_DIR, exist_ok=True)
|
||||||
|
src = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
|
||||||
|
dest = os.path.join(BREEDS_DIR, dest_name)
|
||||||
|
try:
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Datei konnte nicht kopiert werden: {e}")
|
||||||
|
|
||||||
|
new_url = f"/media/breeds/{dest_name}"
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE wiki_rassen SET foto_url=? WHERE id=?",
|
||||||
|
(new_url, rasse["id"])
|
||||||
|
)
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE wiki_foto_submissions
|
||||||
|
SET status='approved', reviewed_by=?, reviewed_at=datetime('now')
|
||||||
|
WHERE id=?
|
||||||
|
""", (user["id"], sub_id))
|
||||||
|
|
||||||
|
# Push-Notification an Einreicher
|
||||||
|
try:
|
||||||
|
from routes.push import send_push_to_user
|
||||||
|
send_push_to_user(sub["user_id"], {
|
||||||
|
"title": "Foto freigeschalten!",
|
||||||
|
"body": f"Dein Foto wurde im Wiki veröffentlicht.",
|
||||||
|
"type": "wiki_foto_approved",
|
||||||
|
"data": {"page": "wiki"},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
else: # reject
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE wiki_foto_submissions
|
||||||
|
SET status='rejected', reviewed_by=?, reviewed_at=datetime('now'),
|
||||||
|
reject_reason=?
|
||||||
|
WHERE id=?
|
||||||
|
""", (user["id"], data.reject_reason or "Nicht geeignet.", sub_id))
|
||||||
|
# Temp-Datei löschen
|
||||||
|
try:
|
||||||
|
path = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,10 @@ async def _job_seed_wikidata_breeds():
|
||||||
logger.info(f"Wikidata breed seed done: {count} neue Rassen")
|
logger.info(f"Wikidata breed seed done: {count} neue Rassen")
|
||||||
mirrored = await mirror_wikidata_photos()
|
mirrored = await mirror_wikidata_photos()
|
||||||
logger.info(f"Wikidata photo mirror done: {mirrored} Fotos")
|
logger.info(f"Wikidata photo mirror done: {mirrored} Fotos")
|
||||||
|
# Wikipedia-Fotos für Rassen die noch kein Bild haben
|
||||||
|
from scraper.wikipedia_photos import fetch_wikipedia_photos
|
||||||
|
wp_count = await fetch_wikipedia_photos()
|
||||||
|
logger.info(f"Wikipedia photo fetch done: {wp_count} Fotos")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Wikidata-Seed: Fehler: {e}")
|
logger.error(f"Wikidata-Seed: Fehler: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
198
backend/scraper/wikipedia_photos.py
Normal file
198
backend/scraper/wikipedia_photos.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
"""
|
||||||
|
Holt Fotos für Wikidata-Rassen ohne Bild über die Wikipedia-API.
|
||||||
|
|
||||||
|
Strategie:
|
||||||
|
1. Wikidata-API: QID → Wikipedia-Artikel-Titel (DE bevorzugt, Fallback EN)
|
||||||
|
2. Wikipedia pageimages-API: Artikel-Titel → Bild-URL
|
||||||
|
3. Wikimedia Commons: Bild herunterladen und lokal speichern
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
BREEDS_DIR = os.path.join(MEDIA_DIR, "breeds")
|
||||||
|
|
||||||
|
WIKIDATA_API = "https://www.wikidata.org/w/api.php"
|
||||||
|
WP_DE_API = "https://de.wikipedia.org/w/api.php"
|
||||||
|
WP_EN_API = "https://en.wikipedia.org/w/api.php"
|
||||||
|
HEADERS = {"User-Agent": "BanYaro/1.0 (https://banyaro.app; contact@banyaro.app)"}
|
||||||
|
BATCH_SIZE = 50 # Wikidata API erlaubt max 50 IDs pro Request
|
||||||
|
SLEEP_MS = 0.35 # 350ms zwischen Downloads
|
||||||
|
|
||||||
|
|
||||||
|
def _qid_from_ext(ext_id: str) -> str | None:
|
||||||
|
"""Extrahiert QID aus external_id wie 'wd_Q12345' → 'Q12345'."""
|
||||||
|
m = re.match(r"wd_(Q\d+)$", ext_id)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_sitelinks(qids: list[str], client: httpx.AsyncClient) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Gibt {qid: {'de': 'Titel_DE', 'en': 'Titel_EN'}} zurück
|
||||||
|
für alle QIDs, die mindestens einen Wikipedia-Sitelink haben.
|
||||||
|
"""
|
||||||
|
if not qids:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
r = await client.get(WIKIDATA_API, params={
|
||||||
|
"action": "wbgetentities",
|
||||||
|
"ids": "|".join(qids),
|
||||||
|
"props": "sitelinks",
|
||||||
|
"sitefilter": "dewiki|enwiki",
|
||||||
|
"format": "json",
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Wikidata sitelinks Fehler: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for qid, entity in data.get("entities", {}).items():
|
||||||
|
sitelinks = entity.get("sitelinks", {})
|
||||||
|
titles = {}
|
||||||
|
if "dewiki" in sitelinks:
|
||||||
|
titles["de"] = sitelinks["dewiki"]["title"]
|
||||||
|
if "enwiki" in sitelinks:
|
||||||
|
titles["en"] = sitelinks["enwiki"]["title"]
|
||||||
|
if titles:
|
||||||
|
result[qid] = titles
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_wp_image(title: str, lang: str, client: httpx.AsyncClient) -> str | None:
|
||||||
|
"""
|
||||||
|
Gibt die Thumbnail-URL eines Wikipedia-Artikels zurück (600px-Version).
|
||||||
|
"""
|
||||||
|
api = WP_DE_API if lang == "de" else WP_EN_API
|
||||||
|
try:
|
||||||
|
r = await client.get(api, params={
|
||||||
|
"action": "query",
|
||||||
|
"titles": title,
|
||||||
|
"prop": "pageimages",
|
||||||
|
"pithumbsize": 600,
|
||||||
|
"format": "json",
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
pages = r.json().get("query", {}).get("pages", {})
|
||||||
|
for page in pages.values():
|
||||||
|
thumb = page.get("thumbnail", {}).get("source")
|
||||||
|
if thumb:
|
||||||
|
return thumb
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WP pageimage Fehler ({lang}/{title}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_image(url: str, path: str, client: httpx.AsyncClient) -> bool:
|
||||||
|
"""Lädt Bild herunter, speichert unter path. True bei Erfolg."""
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(SLEEP_MS)
|
||||||
|
r = await client.get(url)
|
||||||
|
if r.status_code == 200 and r.headers.get("content-type", "").startswith("image"):
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(r.content)
|
||||||
|
return True
|
||||||
|
if r.status_code == 429:
|
||||||
|
await asyncio.sleep(15 * (attempt + 1))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Download Fehler {url}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_wikipedia_photos() -> int:
|
||||||
|
"""
|
||||||
|
Haupt-Funktion: Holt Wikipedia-Fotos für alle Rassen ohne foto_url.
|
||||||
|
Gibt Anzahl erfolgreich gespeicherter Fotos zurück.
|
||||||
|
"""
|
||||||
|
os.makedirs(BREEDS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT id, external_id, name
|
||||||
|
FROM wiki_rassen
|
||||||
|
WHERE (foto_url IS NULL OR foto_url = '')
|
||||||
|
AND external_id LIKE 'wd_%'
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.info("Wikipedia-Fotos: nichts zu tun")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
logger.info(f"Wikipedia-Fotos: {len(rows)} Rassen ohne Foto")
|
||||||
|
|
||||||
|
# QID → DB-Row mappen
|
||||||
|
qid_map = {} # { 'Q12345': {'id': 1, 'external_id': 'wd_Q12345', 'name': '...'} }
|
||||||
|
for row in rows:
|
||||||
|
qid = _qid_from_ext(row["external_id"])
|
||||||
|
if qid:
|
||||||
|
qid_map[qid] = dict(row)
|
||||||
|
|
||||||
|
qids = list(qid_map.keys())
|
||||||
|
saved = 0
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=30,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers=HEADERS
|
||||||
|
) as client:
|
||||||
|
|
||||||
|
# Sitelinks in Batches holen
|
||||||
|
sitelinks: dict[str, dict] = {}
|
||||||
|
for i in range(0, len(qids), BATCH_SIZE):
|
||||||
|
batch = qids[i:i + BATCH_SIZE]
|
||||||
|
chunk = await _fetch_sitelinks(batch, client)
|
||||||
|
sitelinks.update(chunk)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
logger.info(f"Sitelinks: {i + len(batch)}/{len(qids)} abgefragt, {len(sitelinks)} mit WP-Link")
|
||||||
|
|
||||||
|
logger.info(f"Wikipedia-Links gefunden: {len(sitelinks)}/{len(qids)}")
|
||||||
|
|
||||||
|
# Für jeden mit Sitelink → Bild holen + herunterladen
|
||||||
|
for idx, (qid, titles) in enumerate(sitelinks.items()):
|
||||||
|
row = qid_map[qid]
|
||||||
|
row_id = row["id"]
|
||||||
|
lang = "de" if "de" in titles else "en"
|
||||||
|
title = titles[lang]
|
||||||
|
|
||||||
|
img_url = await _fetch_wp_image(title, lang, client)
|
||||||
|
if not img_url:
|
||||||
|
# Zweiter Versuch mit EN wenn DE kein Bild hat
|
||||||
|
if lang == "de" and "en" in titles:
|
||||||
|
img_url = await _fetch_wp_image(titles["en"], "en", client)
|
||||||
|
|
||||||
|
if not img_url:
|
||||||
|
logger.debug(f"Kein WP-Bild für {row['name']} ({qid})")
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_path = os.path.join(BREEDS_DIR, f"{qid}.jpg")
|
||||||
|
local_url = f"/media/breeds/{qid}.jpg"
|
||||||
|
|
||||||
|
if os.path.exists(local_path):
|
||||||
|
# Datei existiert bereits → nur DB updaten
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?", (local_url, row_id))
|
||||||
|
saved += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ok = await _download_image(img_url, local_path, client)
|
||||||
|
if ok:
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute("UPDATE wiki_rassen SET foto_url=? WHERE id=?", (local_url, row_id))
|
||||||
|
saved += 1
|
||||||
|
else:
|
||||||
|
logger.debug(f"Download fehlgeschlagen: {row['name']}")
|
||||||
|
|
||||||
|
if idx % 50 == 0 and idx > 0:
|
||||||
|
logger.info(f"Wikipedia-Fotos: {saved}/{idx + 1} bisher")
|
||||||
|
|
||||||
|
logger.info(f"Wikipedia-Fotos gespeichert: {saved}/{len(sitelinks)} (mit WP-Link)")
|
||||||
|
return saved
|
||||||
|
|
@ -94,12 +94,15 @@ window.Page_wiki = (() => {
|
||||||
// RENDER
|
// RENDER
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _render() {
|
async function _render() {
|
||||||
|
const isMod = _appState.user && (_appState.user.is_moderator || _appState.user.rolle === 'admin');
|
||||||
|
|
||||||
_container.innerHTML = `
|
_container.innerHTML = `
|
||||||
<div class="wiki-tab-bar" id="wiki-tab-bar">
|
<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 === '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 === '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 === '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>
|
<button class="wiki-tab-btn${_tab === 'quiz' ? ' active' : ''}" data-tab="quiz">${UI.icon('star')} Quiz</button>
|
||||||
|
${isMod ? `<button class="wiki-tab-btn${_tab === 'fotos' ? ' active' : ''}" data-tab="fotos" id="wiki-fotos-tab">${UI.icon('camera')} Fotos <span id="wiki-fotos-badge" style="display:none" class="badge badge-sm">0</span></button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div id="wiki-content"></div>
|
<div id="wiki-content"></div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -122,6 +125,97 @@ window.Page_wiki = (() => {
|
||||||
else if (_tab === 'gesundheit') _renderGesundheit(content);
|
else if (_tab === 'gesundheit') _renderGesundheit(content);
|
||||||
else if (_tab === 'recht') _renderRecht(content);
|
else if (_tab === 'recht') _renderRecht(content);
|
||||||
else if (_tab === 'quiz') _renderQuiz(content);
|
else if (_tab === 'quiz') _renderQuiz(content);
|
||||||
|
else if (_tab === 'fotos') await _renderFotoSubmissions(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// TAB: Foto-Einreichungen (Mod/Admin)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _renderFotoSubmissions(el) {
|
||||||
|
el.innerHTML = `<div style="padding:var(--space-4)">${UI.skeleton(3)}</div>`;
|
||||||
|
let subs;
|
||||||
|
try {
|
||||||
|
subs = await _apiFetch('/api/wiki/foto-submissions');
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="empty-state"><p>${_esc(e.message)}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge updaten
|
||||||
|
const badge = document.getElementById('wiki-fotos-badge');
|
||||||
|
if (badge) { badge.textContent = subs.length; badge.style.display = subs.length ? '' : 'none'; }
|
||||||
|
|
||||||
|
if (!subs.length) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="empty-state" style="padding:var(--space-10)">
|
||||||
|
${UI.icon('check')}
|
||||||
|
<p style="margin-top:var(--space-3);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="padding:var(--space-4)">
|
||||||
|
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);margin-bottom:var(--space-4)">
|
||||||
|
Ausstehende Fotos (${subs.length})
|
||||||
|
</h3>
|
||||||
|
<div id="wiki-subs-list">
|
||||||
|
${subs.map(s => `
|
||||||
|
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-3)" id="wiki-sub-${s.id}">
|
||||||
|
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||||
|
<img src="${_esc(s.foto_url)}" alt=""
|
||||||
|
style="width:100px;height:80px;object-fit:cover;border-radius:var(--radius-md);flex-shrink:0;background:var(--c-surface-2)">
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div style="font-weight:var(--weight-semibold)">${_esc(s.rasse_name)}</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||||
|
von ${_esc(s.user_name)} · ${_formatDate(s.created_at)}
|
||||||
|
</div>
|
||||||
|
${s.aktuell_foto
|
||||||
|
? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:4px">
|
||||||
|
Aktuelles Foto: <img src="${_esc(s.aktuell_foto)}" style="height:20px;vertical-align:middle;border-radius:2px">
|
||||||
|
</div>`
|
||||||
|
: `<div style="font-size:var(--text-xs);color:var(--c-warning,#e8a000);margin-top:4px">Kein Foto vorhanden</div>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
|
||||||
|
<button class="btn btn-primary btn-sm flex-1"
|
||||||
|
onclick="Page_wiki._approveSubmission(${s.id})">
|
||||||
|
${UI.icon('check')} Freischalten
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm flex-1"
|
||||||
|
onclick="Page_wiki._rejectSubmission(${s.id})">
|
||||||
|
${UI.icon('x')} Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _approveSubmission(id) {
|
||||||
|
try {
|
||||||
|
await _apiPatch(`/api/wiki/foto-submissions/${id}`, { action: 'approve' });
|
||||||
|
document.getElementById(`wiki-sub-${id}`)?.remove();
|
||||||
|
UI.toast('Foto freigeschaltet!', 'success');
|
||||||
|
const badge = document.getElementById('wiki-fotos-badge');
|
||||||
|
if (badge) {
|
||||||
|
const n = Math.max(0, parseInt(badge.textContent || '0') - 1);
|
||||||
|
badge.textContent = n; badge.style.display = n ? '' : 'none';
|
||||||
|
}
|
||||||
|
} catch (e) { UI.toast(e.message, 'danger'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _rejectSubmission(id) {
|
||||||
|
const reason = prompt('Ablehnungsgrund (optional):') ?? null;
|
||||||
|
if (reason === null) return; // Abbrechen
|
||||||
|
try {
|
||||||
|
await _apiPatch(`/api/wiki/foto-submissions/${id}`, { action: 'reject', reject_reason: reason });
|
||||||
|
document.getElementById(`wiki-sub-${id}`)?.remove();
|
||||||
|
UI.toast('Einreichung abgelehnt.', 'info');
|
||||||
|
} catch (e) { UI.toast(e.message, 'danger'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -366,6 +460,12 @@ window.Page_wiki = (() => {
|
||||||
<a href="#settings" style="color:var(--c-primary)">Anmelden</a>, um einen Bericht zu schreiben.
|
<a href="#settings" style="color:var(--c-primary)">Anmelden</a>, um einen Bericht zu schreiben.
|
||||||
</p>`
|
</p>`
|
||||||
}
|
}
|
||||||
|
${_appState.user ? `
|
||||||
|
<div style="margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--c-border-light)">
|
||||||
|
<button class="btn btn-ghost w-full" id="wiki-foto-submit-btn" style="font-size:var(--text-sm)">
|
||||||
|
${UI.icon('camera')} ${rasse.foto_url ? 'Besseres Foto vorschlagen' : 'Foto hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
UI.modal.open({ title: _esc(rasse.name), body });
|
UI.modal.open({ title: _esc(rasse.name), body });
|
||||||
|
|
@ -374,6 +474,87 @@ window.Page_wiki = (() => {
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
|
setTimeout(() => _showBerichtForm(slug, rasse.name), 350);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('wiki-foto-submit-btn')?.addEventListener('click', () => {
|
||||||
|
UI.modal.close();
|
||||||
|
setTimeout(() => _showFotoSubmitForm(slug, rasse.name), 350);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Foto vorschlagen
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _showFotoSubmitForm(slug, rasseName) {
|
||||||
|
const body = `
|
||||||
|
<p style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-bottom:var(--space-4)">
|
||||||
|
Dein Foto wird nach einer kurzen Prüfung freigeschaltet und als Hauptbild im Wiki verwendet.
|
||||||
|
</p>
|
||||||
|
<form id="wiki-foto-form" autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Foto von <strong>${_esc(rasseName)}</strong></label>
|
||||||
|
<input class="form-control" type="file" id="wiki-foto-input"
|
||||||
|
accept="image/jpeg,image/png,image/webp" required>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:4px">
|
||||||
|
JPG, PNG oder WebP · max. 8 MB · möglichst hochauflösend
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="wiki-foto-preview" style="margin-top:var(--space-3);display:none">
|
||||||
|
<img id="wiki-foto-preview-img" style="max-width:100%;max-height:200px;border-radius:var(--radius-md);object-fit:contain">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
const footer = `
|
||||||
|
<button type="button" class="btn btn-secondary flex-1" id="wiki-foto-cancel">Abbrechen</button>
|
||||||
|
<button type="submit" form="wiki-foto-form" class="btn btn-primary flex-1" id="wiki-foto-submit">
|
||||||
|
${UI.icon('paper-plane-tilt')} Einreichen
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
UI.modal.open({ title: 'Foto vorschlagen', body, footer });
|
||||||
|
document.getElementById('wiki-foto-cancel')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
document.getElementById('wiki-foto-input')?.addEventListener('change', e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const preview = document.getElementById('wiki-foto-preview');
|
||||||
|
const img = document.getElementById('wiki-foto-preview-img');
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.src = url;
|
||||||
|
preview.style.display = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('wiki-foto-form')?.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('wiki-foto-input');
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('wiki-foto-submit');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wird hochgeladen…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const token = localStorage.getItem('by_token');
|
||||||
|
const resp = await fetch(`/api/wiki/rassen/${encodeURIComponent(slug)}/foto`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Upload fehlgeschlagen');
|
||||||
|
}
|
||||||
|
UI.modal.close();
|
||||||
|
UI.toast('Danke! Dein Foto wird geprüft und dann veröffentlicht.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast(err.message, 'danger');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `${UI.icon('paper-plane-tilt')} Einreichen`;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderBerichteHtml(berichte, slug) {
|
function _renderBerichteHtml(berichte, slug) {
|
||||||
|
|
@ -637,6 +818,21 @@ window.Page_wiki = (() => {
|
||||||
return resp.json();
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _apiPatch(url, body) {
|
||||||
|
const token = localStorage.getItem('by_token');
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) },
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
async function _apiPost(url, body) {
|
async function _apiPost(url, body) {
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -682,6 +878,6 @@ window.Page_wiki = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// PUBLIC
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
return { init, refresh };
|
return { init, refresh, _approveSubmission, _rejectSubmission };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v89';
|
const CACHE_VERSION = 'by-v90';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue