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:
rene 2026-04-15 22:01:58 +02:00
parent 097295c628
commit 32d630d5a1
6 changed files with 598 additions and 3 deletions

View file

@ -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 (

View file

@ -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}

View file

@ -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}")

View 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

View file

@ -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 };
})(); })();

View file

@ -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