Compare commits

..

No commits in common. "af4b1a4a5548b4867f02bdd4e4e04232f31abb8a" and "b608d5635f411945d4e0cbb7d02f37afb0ea6554" have entirely different histories.

7 changed files with 43 additions and 203 deletions

View file

@ -16,8 +16,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
# Media-Verzeichnis
RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \
/data/media/breeds/gallery /data/media/breeds/submissions
RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison
EXPOSE 8000

View file

@ -80,18 +80,6 @@ CATEGORIES = [
("diamant", 365, "Ein ganzes Jahr"),
],
},
{
"id": "wiki_fotos",
"name": "Wiki-Fotos",
"emoji": "📸",
"metrik": "wiki_fotos",
"einheit": " Foto(s)",
"stufen": [
("bronze", 1, "Erster Klick"),
("silber", 3, "Foto-Fan"),
("gold", 10, "Wiki-Fotograf"),
],
},
]
# Flat-Liste aller Badge-IDs für DB-Kompatibilität
@ -141,21 +129,19 @@ def check_and_award(user_id: int, conn):
COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0),
1) AS total_km,
(SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_valid=1) AS routen,
(SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois,
(SELECT COUNT(*) FROM wiki_foto_submissions WHERE user_id=? AND status='approved') AS wiki_fotos
(SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois
FROM (SELECT 1)
""", (user_id, user_id, user_id, user_id, user_id)).fetchone()
""", (user_id, user_id, user_id, user_id)).fetchone()
streak_row = conn.execute(
"SELECT current_streak FROM users WHERE id=?", (user_id,)
).fetchone()
metrics = {
"total_km": stats["total_km"] if stats else 0,
"routen": stats["routen"] if stats else 0,
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
"wiki_fotos": stats["wiki_fotos"] if stats else 0,
"total_km": stats["total_km"] if stats else 0,
"routen": stats["routen"] if stats else 0,
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
}
earned = {r["badge_id"] for r in
@ -197,7 +183,6 @@ async def my_achievements(user=Depends(get_current_user)):
1) AS total_km,
(SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_valid=1) AS routen,
(SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?) AS pois,
(SELECT COUNT(*) FROM wiki_foto_submissions WHERE user_id=? AND status='approved') AS wiki_fotos,
ROUND(
COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=? AND r.is_valid=1), 0) +
COALESCE((SELECT SUM(w.walked_km) FROM route_walks w WHERE w.user_id=?), 0),
@ -205,7 +190,7 @@ async def my_achievements(user=Depends(get_current_user)):
+ (SELECT COUNT(*) FROM user_map_pois p WHERE p.user_id=?)*5
+ (SELECT COUNT(*) FROM routes r WHERE r.user_id=? AND r.is_valid=1)*10 AS punkte
FROM (SELECT 1)
""", (uid, uid, uid, uid, uid, uid, uid, uid, uid)).fetchone()
""", (uid, uid, uid, uid, uid, uid, uid, uid)).fetchone()
streak_row = conn.execute(
"SELECT current_streak, max_streak FROM users WHERE id=?", (uid,)
@ -230,11 +215,10 @@ async def my_achievements(user=Depends(get_current_user)):
""", (stats["punkte"] if stats else 0,)).fetchone()
metrics = {
"total_km": stats["total_km"] if stats else 0,
"routen": stats["routen"] if stats else 0,
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
"wiki_fotos": stats["wiki_fotos"] if stats else 0,
"total_km": stats["total_km"] if stats else 0,
"routen": stats["routen"] if stats else 0,
"pois": stats["pois"] if stats else 0,
"streak": (streak_row["current_streak"] if streak_row else 0),
}
# Kategorien mit aktuellem Tier + Fortschritt aufbauen

View file

@ -12,10 +12,9 @@ from auth import get_current_user, get_current_user_optional
from ratelimit import check as rl_check, block_ip
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")
GALLERY_DIR = os.path.join(BREEDS_DIR, "gallery")
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()
@ -120,10 +119,7 @@ async def get_rassen(
with db() as conn:
rows = conn.execute(f"""
SELECT id, name, gruppe, groesse, aktivitaet, erfahrung,
foto_url, slug, kinder_geeignet, wohnung_geeignet,
(SELECT s.foto_url FROM wiki_foto_submissions s
WHERE s.rasse_id = wiki_rassen.id AND s.status='approved'
ORDER BY s.reviewed_at DESC LIMIT 1) AS user_foto
foto_url, slug, kinder_geeignet, wohnung_geeignet
FROM wiki_rassen
{where}
ORDER BY name ASC
@ -170,18 +166,8 @@ async def get_rasse(rasse_slug: str, request: Request):
(rasse_slug,),
).fetchall()
user_fotos = conn.execute("""
SELECT s.foto_url, u.name AS user_name, s.created_at
FROM wiki_foto_submissions s
JOIN users u ON u.id = s.user_id
WHERE s.rasse_id = ? AND s.status = 'approved'
ORDER BY s.reviewed_at DESC
LIMIT 10
""", (rasse["id"],)).fetchall()
result = dict(rasse)
result["berichte"] = [dict(r) for r in rows]
result["user_fotos"] = [dict(r) for r in user_fotos]
result["berichte"] = [dict(r) for r in rows]
return result
@ -414,61 +400,47 @@ async def review_submission(sub_id: int, data: ReviewModel, user=Depends(get_cur
).fetchone()
if data.action == "approve":
# Ins gallery-Verzeichnis verschieben
os.makedirs(GALLERY_DIR, exist_ok=True)
src = sub["foto_url"].replace("/media/", MEDIA_DIR + "/", 1)
dest_name = f"{rasse['slug']}_{sub_id}.jpg"
dest = os.path.join(GALLERY_DIR, dest_name)
# 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/gallery/{dest_name}"
# Nur als Hauptbild setzen wenn noch keins vorhanden
if not rasse["foto_url"]:
conn.execute(
"UPDATE wiki_rassen SET foto_url=? WHERE id=?",
(new_url, rasse["id"])
)
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', foto_url=?, reviewed_by=?, reviewed_at=datetime('now')
SET status='approved', reviewed_by=?, reviewed_at=datetime('now')
WHERE id=?
""", (new_url, user["id"], sub_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": "Dein Foto wurde im Wiki veröffentlicht.",
"body": f"Dein Foto wurde im Wiki veröffentlicht.",
"type": "wiki_foto_approved",
"data": {"page": "wiki"},
})
except Exception:
pass
# Badge-Check
try:
from routes.achievements import check_and_award
with db() as conn2:
new_badges = check_and_award(sub["user_id"], conn2)
if new_badges:
try:
send_push_to_user(sub["user_id"], {
"title": "\U0001f3c5 Neues Badge!",
"body": f"Du hast '{new_badges[0]['name']}' verdient!",
"type": "badge_earned",
"data": {"page": "achievements"},
})
except Exception:
pass
except Exception:
pass
else: # reject
conn.execute("""
UPDATE wiki_foto_submissions

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '351'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '347'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -9,44 +9,6 @@ window.Page_settings = (() => {
let _appState = null;
let _mode = 'login'; // 'login' | 'register'
// ----------------------------------------------------------
// HUNDEPASSPHRASE — sicheres Passwort aus Hundewelt
// ----------------------------------------------------------
const _PW_WOERTER = [
// Rassen
'Labrador','Pudel','Beagle','Husky','Dackel','Spitz','Mops','Boxer',
'Collie','Setter','Pointer','Retriever','Shepherd','Terrier','Welpe',
// Körper & Natur
'Pfote','Schwanz','Schnauze','Schnurrbart','Fell','Nase','Ohr',
// Aktivität
'Gassi','Laufen','Bellen','Springen','Graben','Schnüffeln','Spielen',
'Apportieren','Schwimmen','Hecheln','Wackeln','Toben',
// Gegenstände
'Leckerli','Leine','Halsband','Ball','Napf','Knochen','Frisbee',
'Körbchen','Bürste','Leine','Stöckchen','Kauspielzeug',
// Orte & Personen
'Wiese','Wald','Park','Bach','Pfütze','Tierarzt','Züchter',
// Eigenschaften
'Treu','Tapfer','Mutig','Flauschig','Verspielt','Neugierig',
'Wachsam','Flink','Sanft','Lieb',
// Geräusche & Aktionen
'Wuff','Jaulen','Schnuppern','Wedeln','Gähnen','Strecken',
// Futter
'Trockenfutter','Nassfutter','Kausnack','Futternapf',
];
function _genPassphrase() {
const pick = () => _PW_WOERTER[Math.floor(Math.random() * _PW_WOERTER.length)];
const num = Math.floor(Math.random() * 90) + 10; // 2-stellig
// 3 zufällige Wörter + Zahl, mit Bindestrich
const words = [];
while (words.length < 3) {
const w = pick();
if (!words.includes(w)) words.push(w);
}
return words.join('-') + '-' + num;
}
// ----------------------------------------------------------
// INIT / REFRESH
// ----------------------------------------------------------
@ -787,34 +749,6 @@ window.Page_settings = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#eye"></use></svg>
</button>
</div>
<!-- Hundepassphrase-Generator -->
<div id="pw-gen-box" style="margin-top:var(--space-2);padding:var(--space-3);
background:var(--c-surface-2);border-radius:var(--radius-md);
border-left:3px solid var(--c-primary)">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-secondary);
text-transform:uppercase;letter-spacing:.05em">🐾 Passwort-Vorschlag</span>
<button type="button" id="pw-gen-new"
style="margin-left:auto;font-size:var(--text-xs);color:var(--c-primary);
background:none;border:none;cursor:pointer;padding:0;font-weight:600">
neu
</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2)">
<code id="pw-gen-phrase"
style="flex:1;font-size:var(--text-sm);font-weight:700;
color:var(--c-text);letter-spacing:.02em;word-break:break-all"></code>
<button type="button" id="pw-gen-use"
style="flex-shrink:0;font-size:var(--text-xs);font-weight:600;
padding:4px 10px;border-radius:var(--radius-md);
background:var(--c-primary);color:#fff;border:none;cursor:pointer">
Übernehmen
</button>
</div>
<div style="font-size:10px;color:var(--c-text-muted);margin-top:var(--space-1)">
Sichere Passphrase aus der Hundewelt leicht zu merken, schwer zu knacken.
</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Konto erstellen
@ -882,32 +816,6 @@ window.Page_settings = (() => {
function _bindRegisterForm() {
_bindPwToggle('register-pw', 'register-pw-toggle');
// Hundepassphrase-Generator initialisieren
const phraseEl = document.getElementById('pw-gen-phrase');
const pwInput = document.getElementById('register-pw');
if (phraseEl) {
const _refresh = () => { phraseEl.textContent = _genPassphrase(); };
_refresh();
document.getElementById('pw-gen-new')?.addEventListener('click', _refresh);
document.getElementById('pw-gen-use')?.addEventListener('click', () => {
const phrase = phraseEl.textContent;
pwInput.value = phrase;
pwInput.type = 'text'; // sichtbar machen
document.getElementById('register-pw-toggle')
?.querySelector('use')
?.setAttribute('href', '/icons/phosphor.svg#eye-slash');
// kurzes visuelles Feedback
const btn = document.getElementById('pw-gen-use');
btn.textContent = '✓ Übernommen';
btn.style.background = 'var(--c-success)';
setTimeout(() => {
btn.textContent = 'Übernehmen';
btn.style.background = 'var(--c-primary)';
}, 1500);
});
}
document.getElementById('auth-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');

View file

@ -371,11 +371,10 @@ window.Page_wiki = (() => {
const _DOG_SILHOUETTE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="48" height="48" fill="currentColor" aria-hidden="true"><path d="M200,56H180V48a12,12,0,0,0-24,0v8H132a44,44,0,0,0-43.3,36.3L80,140H60a28,28,0,0,0-28,28v16a12,12,0,0,0,12,12H204a12,12,0,0,0,12-12V100A44.05,44.05,0,0,0,200,56Zm20,128H44V168a4,4,0,0,1,4-4H84.9l4.16-41.57A20,20,0,0,1,108.86,104H156a4,4,0,0,1,0,8H128a4,4,0,0,0,0,8h28a12,12,0,0,0,12-12,11.84,11.84,0,0,0-.1-1.52A20,20,0,0,1,196,124Zm0-60a20,20,0,0,1-7.77,15.82A27.84,27.84,0,0,0,180,132V120a12,12,0,0,1,12-12h16a4,4,0,0,0,4-4V100A20,20,0,0,1,220,124ZM168,48a4,4,0,0,1,8,0V72H168Z"/></svg>`;
function _breedCardHtml(r) {
const fotoUrl = r.foto_url || r.user_foto || '';
const photoHtml = fotoUrl
? `<img class="wiki-breed-photo" src="${_esc(fotoUrl)}" loading="lazy" alt="${_esc(r.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
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="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${r.foto_url ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
return `
<div class="wiki-breed-card" data-slug="${_esc(r.slug)}">
@ -728,32 +727,10 @@ window.Page_wiki = (() => {
const berichteHtml = _renderBerichteHtml(rasse.berichte || [], slug);
const userFotosHtml = (rasse.user_fotos || []).length
? `<div style="margin-top:var(--space-3)">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);
font-weight:700;text-transform:uppercase;letter-spacing:.05em;
margin-bottom:var(--space-2)">📸 Community-Fotos</div>
<div style="display:flex;gap:var(--space-2);overflow-x:auto;padding-bottom:4px">
${rasse.user_fotos.map(f => `
<div style="flex-shrink:0">
<img src="${_esc(f.foto_url)}" alt="${_esc(f.user_name)}"
style="height:80px;width:80px;object-fit:cover;
border-radius:var(--radius-md);cursor:pointer"
onclick="document.querySelector('.wiki-detail-photo')?.setAttribute('src','${_esc(f.foto_url)}')">
<div style="font-size:9px;color:var(--c-text-muted);text-align:center;
margin-top:2px;max-width:80px;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap">von ${_esc(f.user_name)}</div>
</div>
`).join('')}
</div>
</div>`
: '';
const body = `
${/* 1. Hero */ ''}
<div class="wiki-detail-hero" style="text-align:center;margin-bottom:var(--space-4)">
${photoHtml}
${userFotosHtml}
<h1 style="font-size:var(--text-xl);font-weight:var(--weight-bold);margin:var(--space-2) 0 var(--space-1)">${_esc(rasse.name)}</h1>
${rasse.herkunft ? `<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">${UI.icon('map-pin')} ${_esc(rasse.herkunft)}</div>` : ''}
${rasse.gruppe ? `<div style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:2px">${_esc(rasse.gruppe)}</div>` : ''}

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v367';
const CACHE_VERSION = 'by-v365';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten