feat: View-Counter + Like-Button für Beiträge

- DB: views + likes INTEGER Spalten (DEFAULT 0)
- Backend: View-Inkrement auf /beitrag/<id>, Like-API /api/like/<id>
- Frontend: 👁 Aufrufe + 👍 Like-Button auf Übersichtskarten
- Detailseite: Views/Likes Anzeige
- Like-Dedup: Cookie-basiert (1 Like pro Beitrag pro Browser, 365 Tage)
This commit is contained in:
Hermes Agent
2026-06-17 21:56:38 +02:00
parent e2b0bbef1e
commit 600ef6ac8d
+73
View File
@@ -1803,6 +1803,10 @@ def public_frontend():
<span class="story-author">""" + name + """</span> <span class="story-author">""" + name + """</span>
</div> </div>
<p class="story-preview">""" + preview + """</p> <p class="story-preview">""" + preview + """</p>
<div class="card-stats">
<span class="stat-views">👁 """ + str(r['views'] or 0) + """</span>
<button class="stat-like-btn" data-id=""" + str(r['id']) + """ data-likes=""" + str(r['likes'] or 0) + """>👍 """ + str(r['likes'] or 0) + """</button>
</div>
</div> </div>
</article> </article>
</a>""" </a>"""
@@ -1818,6 +1822,10 @@ def public_frontend():
<span class="story-author">""" + name + """</span> <span class="story-author">""" + name + """</span>
</div> </div>
<p class="story-preview">""" + preview + """</p> <p class="story-preview">""" + preview + """</p>
<div class="card-stats">
<span class="stat-views">👁 """ + str(r['views'] or 0) + """</span>
<button class="stat-like-btn" data-id=""" + str(r['id']) + """ data-likes=""" + str(r['likes'] or 0) + """>👍 """ + str(r['likes'] or 0) + """</button>
</div>
</div> </div>
</article>""" </article>"""
@@ -1873,6 +1881,14 @@ def public_frontend():
.text-only .card-content { position:relative; padding:0.55rem 0.85rem 0.65rem; } .text-only .card-content { position:relative; padding:0.55rem 0.85rem 0.65rem; }
.text-only .story-time-overlay { position:absolute; top:auto; bottom:0.45rem; right:0.6rem; left:auto; z-index:5; } .text-only .story-time-overlay { position:absolute; top:auto; bottom:0.45rem; right:0.6rem; left:auto; z-index:5; }
/* Card stats (views + likes) */
.card-stats { display:flex; align-items:center; gap:0.6rem; padding-top:0.35rem; margin-top:0.3rem; border-top:1px solid #f0ede6; }
.stat-views { font-size:0.68rem; color:#999; display:flex; align-items:center; gap:0.2rem; }
.stat-like-btn { background:none; border:1.5px solid #e0dcd3; border-radius:999px; padding:0.2rem 0.55rem; font-size:0.7rem; cursor:pointer; color:#888; font-family:inherit; transition:all 0.15s; display:flex; align-items:center; gap:0.15rem; -webkit-tap-highlight-color:transparent; }
.stat-like-btn:hover { border-color:#20228a; color:#20228a; background:rgba(32,34,138,0.05); }
.stat-like-btn.liked { background:#20228a; color:#fff; border-color:#20228a; font-weight:600; }
.stat-like-btn:active { transform:scale(0.94); }
/* Frontend filter bar */ /* Frontend filter bar */
.frontend-filter { max-width:1100px; margin:1rem auto 0; padding:0 1.5rem; display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; } .frontend-filter { max-width:1100px; margin:1rem auto 0; padding:0 1.5rem; display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; }
.frontend-filter .filter-label { font-size:0.72rem; color:#888; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; } .frontend-filter .filter-label { font-size:0.72rem; color:#888; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; }
@@ -2231,6 +2247,31 @@ def public_frontend():
})(); })();
</script> </script>
<script>
// Like button handler
(function() {
document.querySelectorAll('.stat-like-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var id = this.getAttribute('data-id');
if (this.classList.contains('liked')) return;
this.disabled = true;
fetch('/api/like/' + id, {method:'POST'})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.already) {
this.classList.add('liked');
this.textContent = '👍 ' + data.likes;
this.setAttribute('data-likes', data.likes);
}
this.disabled = false;
}.bind(this))
.catch(function() { this.disabled = false; }.bind(this));
});
});
})();
</script>
<script> <script>
(function() { (function() {
const bubble = document.getElementById('chatBubble'); const bubble = document.getElementById('chatBubble');
@@ -2696,6 +2737,10 @@ def public_detail(sub_id: int):
if not row: if not row:
return "<h1>Nicht gefunden</h1><p><a href='/'>← Zurück</a></p>", 404 return "<h1>Nicht gefunden</h1><p><a href='/'>← Zurück</a></p>", 404
# Track view
db.execute("UPDATE submissions SET views = views + 1 WHERE id = ?", (sub_id,))
db.commit()
# Build media HTML — videos are playable, images full-size # Build media HTML — videos are playable, images full-size
media_html = "" media_html = ""
if row["media_path"]: if row["media_path"]:
@@ -2807,6 +2852,10 @@ def public_detail(sub_id: int):
</div> </div>
<div class="detail-media">{media_html}</div> <div class="detail-media">{media_html}</div>
{"".join([f'<div class="detail-content">{content}</div>' if content else ''])} {"".join([f'<div class="detail-content">{content}</div>' if content else ''])}
<div style="display:flex;align-items:center;gap:1rem;margin-top:1.5rem;padding-top:1rem;border-top:1px solid #f0ede6;">
<span style="font-size:0.8rem;color:#999;">👁 {row['views'] or 0} Aufrufe</span>
<span style="font-size:0.8rem;color:#999;">👍 {row['likes'] or 0}</span>
</div>
</article> </article>
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
@@ -2819,6 +2868,30 @@ def public_detail(sub_id: int):
</html>""" </html>"""
@app.route("/api/like/<int:sub_id>", methods=["POST"])
def api_like(sub_id: int):
db = get_db()
row = db.execute(
"SELECT id, likes FROM submissions WHERE id = ? AND status='published' AND deleted_at IS NULL",
(sub_id,),
).fetchone()
if not row:
return jsonify({"error": "Nicht gefunden"}), 404
liked = request.cookies.get(f"liked_{sub_id}", "")
if liked == "1":
return jsonify({"likes": row["likes"] or 0, "already": True})
db.execute("UPDATE submissions SET likes = likes + 1 WHERE id = ?", (sub_id,))
db.commit()
new_likes = db.execute("SELECT likes FROM submissions WHERE id = ?", (sub_id,)).fetchone()[0]
resp = jsonify({"likes": new_likes or 0, "already": False})
resp.set_cookie(f"liked_{sub_id}", "1", max_age=365*24*60*60, httponly=True, samesite="Lax")
return resp
if __name__ == "__main__": if __name__ == "__main__":
import threading import threading
def keepalive(): def keepalive():