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:
@@ -1803,6 +1803,10 @@ def public_frontend():
|
||||
<span class="story-author">""" + name + """</span>
|
||||
</div>
|
||||
<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>
|
||||
</article>
|
||||
</a>"""
|
||||
@@ -1818,6 +1822,10 @@ def public_frontend():
|
||||
<span class="story-author">""" + name + """</span>
|
||||
</div>
|
||||
<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>
|
||||
</article>"""
|
||||
|
||||
@@ -1873,6 +1881,14 @@ def public_frontend():
|
||||
.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; }
|
||||
|
||||
/* 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 { 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; }
|
||||
@@ -2231,6 +2247,31 @@ def public_frontend():
|
||||
})();
|
||||
</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>
|
||||
(function() {
|
||||
const bubble = document.getElementById('chatBubble');
|
||||
@@ -2696,6 +2737,10 @@ def public_detail(sub_id: int):
|
||||
if not row:
|
||||
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
|
||||
media_html = ""
|
||||
if row["media_path"]:
|
||||
@@ -2807,6 +2852,10 @@ def public_detail(sub_id: int):
|
||||
</div>
|
||||
<div class="detail-media">{media_html}</div>
|
||||
{"".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>
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
@@ -2819,6 +2868,30 @@ def public_detail(sub_id: int):
|
||||
</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__":
|
||||
import threading
|
||||
def keepalive():
|
||||
|
||||
Reference in New Issue
Block a user