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;
|
||||
""")
|
||||
|
||||
# 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
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
"""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 database import db
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -256,3 +265,175 @@ async def quiz_result(
|
|||
]
|
||||
|
||||
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")
|
||||
mirrored = await mirror_wikidata_photos()
|
||||
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:
|
||||
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
|
||||
// ----------------------------------------------------------
|
||||
async function _render() {
|
||||
const isMod = _appState.user && (_appState.user.is_moderator || _appState.user.rolle === 'admin');
|
||||
|
||||
_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>
|
||||
${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 id="wiki-content"></div>
|
||||
`;
|
||||
|
|
@ -122,6 +125,97 @@ window.Page_wiki = (() => {
|
|||
else if (_tab === 'gesundheit') _renderGesundheit(content);
|
||||
else if (_tab === 'recht') _renderRecht(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.
|
||||
</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 });
|
||||
|
|
@ -374,6 +474,87 @@ window.Page_wiki = (() => {
|
|||
UI.modal.close();
|
||||
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) {
|
||||
|
|
@ -637,6 +818,21 @@ window.Page_wiki = (() => {
|
|||
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) {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
|
@ -682,6 +878,6 @@ window.Page_wiki = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh };
|
||||
return { init, refresh, _approveSubmission, _rejectSubmission };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v89';
|
||||
const CACHE_VERSION = 'by-v90';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue