Feature: HdM Community-Vote — alle öffentlichen Hunde wählbar, eigene ausgenommen, SW by-v597
This commit is contained in:
parent
83958cbb0b
commit
031c6028ac
5 changed files with 127 additions and 51 deletions
|
|
@ -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}
|
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")
|
@router.post("/hund-des-monats/vote")
|
||||||
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
|
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
|
||||||
monat = datetime.now().strftime("%Y-%m")
|
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()
|
dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
|
||||||
if not dog:
|
if not dog:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
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.")
|
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
|
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
|
||||||
|
|
|
||||||
|
|
@ -5241,11 +5241,19 @@ html.modal-open {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kandidaten-Suche */
|
||||||
|
.hdm-kandidaten-search {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Vote-Grid */
|
/* Vote-Grid */
|
||||||
.hdm-vote-grid {
|
.hdm-vote-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
max-height: 340px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hdm-vote-card {
|
.hdm-vote-card {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,45 +232,12 @@ window.Page_forum = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _openHdmModal(data) {
|
async function _openHdmModal(data) {
|
||||||
// Immer frische Daten laden
|
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
|
||||||
try { data = await API.get('/movies/hund-des-monats'); } catch { /* nutze gecachte */ }
|
|
||||||
|
|
||||||
const [year, month] = data.monat.split('-');
|
const [year, month] = data.monat.split('-');
|
||||||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||||
.format(new Date(+year, +month - 1, 1));
|
.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
|
const topList = data.top?.length
|
||||||
? data.top.slice(0, 5).map((dog, i) => {
|
? data.top.slice(0, 5).map((dog, i) => {
|
||||||
const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][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 class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('')
|
}).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 = `
|
const body = `
|
||||||
<div class="hdm-header">
|
<div class="hdm-header">
|
||||||
|
|
@ -298,7 +284,7 @@ window.Page_forum = (() => {
|
||||||
<h2 class="hdm-title">Hund des Monats</h2>
|
<h2 class="hdm-title">Hund des Monats</h2>
|
||||||
<div class="hdm-monat">${_esc(monthName)}</div>
|
<div class="hdm-monat">${_esc(monthName)}</div>
|
||||||
</div>
|
</div>
|
||||||
${voteSection}
|
${voteHint}
|
||||||
<div class="hdm-section">
|
<div class="hdm-section">
|
||||||
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
|
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
|
||||||
<div id="hdm-top-list">${topList}</div>
|
<div id="hdm-top-list">${topList}</div>
|
||||||
|
|
@ -311,20 +297,72 @@ window.Page_forum = (() => {
|
||||||
e.preventDefault(); UI.modal.close(); App.navigate('settings');
|
e.preventDefault(); UI.modal.close(); App.navigate('settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.hdm-vote-btn').forEach(btn => {
|
if (!_appState.user) return;
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const dogId = parseInt(btn.dataset.dogId);
|
// Kandidaten laden und rendern
|
||||||
await UI.asyncButton(btn, async () => {
|
let _kandidaten = [];
|
||||||
try {
|
const _renderKandidaten = (list) => {
|
||||||
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
|
const grid = document.getElementById('hdm-kandidaten-grid');
|
||||||
UI.toast.success('Stimme abgegeben!');
|
if (!grid) return;
|
||||||
UI.modal.close();
|
if (!list.length) {
|
||||||
_loadHdmCard();
|
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
|
||||||
} catch (err) {
|
return;
|
||||||
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
|
}
|
||||||
}
|
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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v596';
|
const CACHE_VERSION = 'by-v597';
|
||||||
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
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue