Feature: HdM Community-Vote — alle öffentlichen Hunde wählbar, eigene ausgenommen, SW by-v597

This commit is contained in:
rene 2026-05-02 08:44:59 +02:00
parent 83958cbb0b
commit 031c6028ac
5 changed files with 127 additions and 51 deletions

View file

@ -386,6 +386,34 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote}
@router.get("/hund-des-monats/kandidaten")
async def get_hdm_kandidaten(user=Depends(get_current_user)):
"""Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand."""
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.rasse, d.foto_url,
u.name AS besitzer_name,
COALESCE(v.stimmen, 0) AS stimmen
FROM dogs d
JOIN users u ON u.id = d.user_id
LEFT JOIN (
SELECT dog_id, COUNT(*) AS stimmen
FROM hund_des_monats_votes
WHERE monat = ?
GROUP BY dog_id
) v ON v.dog_id = d.id
WHERE d.is_public = 1
AND d.user_id != ?
ORDER BY
CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END,
stimmen DESC,
d.name ASC
LIMIT 60
""", (monat, user["id"])).fetchall()
return [dict(r) for r in rows]
@router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m")
@ -393,7 +421,9 @@ async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_
dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
if dog["user_id"] != user["id"] and not dog["is_public"]:
if dog["user_id"] == user["id"]:
raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.")
if not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
conn.execute("""
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)

View file

@ -5241,11 +5241,19 @@ html.modal-open {
margin-bottom: var(--space-3);
}
/* Kandidaten-Suche */
.hdm-kandidaten-search {
margin-bottom: var(--space-3);
}
/* Vote-Grid */
.hdm-vote-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
max-height: 340px;
overflow-y: auto;
padding-right: var(--space-1);
}
.hdm-vote-card {

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '596'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '597'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -232,45 +232,12 @@ window.Page_forum = (() => {
}
async function _openHdmModal(data) {
// Immer frische Daten laden
try { data = await API.get('/movies/hund-des-monats'); } catch { /* nutze gecachte */ }
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
let voteSection = '';
if (_appState.user && _appState.dogs?.length > 0) {
const cards = _appState.dogs.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}" data-dog-id="${dog.id}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
<button class="btn${isVoted ? ' btn-primary' : ' btn-secondary'} hdm-vote-btn" data-dog-id="${dog.id}">
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
</button>
</div>`;
}).join('');
voteSection = `
<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen deiner Hunde möchtest du abstimmen?</h3>
<div class="hdm-vote-grid" id="hdm-vote-grid">${cards}</div>
</div>`;
} else if (!_appState.user) {
voteSection = `
<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um für deinen Hund abzustimmen.
</p>
</div>`;
}
const topList = data.top?.length
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
@ -290,7 +257,26 @@ window.Page_forum = (() => {
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
</div>`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen diesen Monat. Sei der Erste!</p>`;
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
const voteHint = !_appState.user
? `<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um abstimmen zu können.
</p>
</div>`
: `<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
<div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off"
style="font-size:var(--text-sm)">
</div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)}
</div>
</div>`;
const body = `
<div class="hdm-header">
@ -298,7 +284,7 @@ window.Page_forum = (() => {
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteSection}
${voteHint}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
@ -311,20 +297,72 @@ window.Page_forum = (() => {
e.preventDefault(); UI.modal.close(); App.navigate('settings');
});
document.querySelectorAll('.hdm-vote-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
UI.toast.success('Stimme abgegeben!');
UI.modal.close();
_loadHdmCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
if (!_appState.user) return;
// Kandidaten laden und rendern
let _kandidaten = [];
const _renderKandidaten = (list) => {
const grid = document.getElementById('hdm-kandidaten-grid');
if (!grid) return;
if (!list.length) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
return;
}
grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
</button>
</div>`;
}).join('');
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
data.user_vote = dogId;
UI.toast.success('Stimme abgegeben!');
UI.modal.close();
_loadHdmCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
};
try {
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
} catch {
document.getElementById('hdm-kandidaten-grid').innerHTML =
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
return;
}
_renderKandidaten(_kandidaten);
document.getElementById('hdm-search')?.addEventListener('input', e => {
const q = e.target.value.trim().toLowerCase();
_renderKandidaten(q
? _kandidaten.filter(d =>
(d.name || '').toLowerCase().includes(q) ||
(d.rasse || '').toLowerCase().includes(q))
: _kandidaten
);
});
}

View file

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