Implement left/right voting on entries
GET /entry/{pid}/interactions/{mode} now reads the vote tally and, for
mode=left/right (auth required), toggles the user's vote: new vote, repeat
same mode removes it, different mode switches. Returns {left, right,
selected}. A UNIQUE(uid, pid) constraint on the vote table prevents
duplicate votes. The JS frontend renders Links/Rechts buttons with live
counts under each entry.
This completes the voting feature that was left commented-out and broken
in the Flask version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,8 @@ CREATE TABLE IF NOT EXISTS vote (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uid INTEGER,
|
uid INTEGER,
|
||||||
pid INTEGER,
|
pid INTEGER,
|
||||||
mode TEXT
|
mode TEXT,
|
||||||
|
UNIQUE(uid, pid)
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
@@ -105,8 +106,64 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleInteractions liest den Abstimmungsstand eines Beitrags und schaltet bei
|
||||||
|
// mode=left/right die Stimme des angemeldeten Nutzers um:
|
||||||
|
// - keine bisherige Stimme -> neue Stimme
|
||||||
|
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
|
||||||
|
// - andere Stimme -> auf den neuen Modus wechseln
|
||||||
|
// mode=none liest nur (z. B. beim Laden des Feeds).
|
||||||
func handleInteractions(w http.ResponseWriter, r *http.Request) {
|
func handleInteractions(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusNotImplemented, "Voting ist noch nicht implementiert")
|
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := chi.URLParam(r, "mode")
|
||||||
|
|
||||||
|
var uid int64
|
||||||
|
if s, ok := getSession(r); ok {
|
||||||
|
uid = s.UID
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == "left" || mode == "right" {
|
||||||
|
if uid == 0 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Melde dich an, um abzustimmen!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var prev string
|
||||||
|
err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
|
||||||
|
switch {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
|
||||||
|
case err == nil && prev == mode:
|
||||||
|
db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
|
||||||
|
case err == nil:
|
||||||
|
db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := "none"
|
||||||
|
if uid != 0 {
|
||||||
|
var cur string
|
||||||
|
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
|
||||||
|
selected = cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var left, right int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left)
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"pid": pid,
|
||||||
|
"left": left,
|
||||||
|
"right": right,
|
||||||
|
"selected": selected,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media.
|
// storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media.
|
||||||
|
|||||||
+18
-3
@@ -137,13 +137,28 @@ Mindestens `content` oder `file` muss vorhanden sein.
|
|||||||
|
|
||||||
### `GET /entry/:pid/interactions/:mode`
|
### `GET /entry/:pid/interactions/:mode`
|
||||||
|
|
||||||
Abstimmung auf einen Beitrag. (*Aktuell nicht implementiert.*)
|
Abstimmungsstand lesen und optional die eigene Stimme umschalten.
|
||||||
|
|
||||||
**Parameter:**
|
**Parameter:**
|
||||||
| Name | Typ | Beschreibung |
|
| Name | Typ | Beschreibung |
|
||||||
|------|-----|--------------|
|
|------|-----|--------------|
|
||||||
| `pid` | int | Post-ID |
|
| `pid` | int | Post-ID |
|
||||||
| `mode` | string | `left` oder `right` |
|
| `mode` | string | `none` (nur lesen), `left` oder `right` (abstimmen) |
|
||||||
|
|
||||||
|
**Voting-Logik** (nur bei `left`/`right`, Auth erforderlich):
|
||||||
|
- keine bisherige Stimme → neue Stimme
|
||||||
|
- gleiche Stimme erneut → Stimme zurückziehen (Toggle)
|
||||||
|
- andere Stimme → auf neuen Modus wechseln
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "pid": 123, "left": 4, "right": 2, "selected": "left" }
|
||||||
|
```
|
||||||
|
`selected` ist `none`, wenn nicht eingeloggt oder keine Stimme abgegeben.
|
||||||
|
Bei `left`/`right` ohne Login → `401`.
|
||||||
|
|
||||||
|
> Hinweis: Mutation per GET ist aus dem ursprünglichen HTMX-Design übernommen.
|
||||||
|
> Sauberer wäre ein separater `POST` fürs Abstimmen — siehe offene Punkte.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -234,7 +249,7 @@ CREATE TABLE vote (
|
|||||||
|
|
||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- `/entry/:pid/interactions/:mode` ist auskommentiert — Voting-Logik muss neu designed werden
|
- Voting läuft per GET (HTMX-Erbe) — perspektivisch auf `POST` umstellen (CSRF/Idempotenz)
|
||||||
- Kein Rate-Limiting außer dem zufälligen `sleep` beim Login
|
- Kein Rate-Limiting außer dem zufälligen `sleep` beim Login
|
||||||
- `session`-Token ist nur `randbelow(999999999) + timestamp` — sollte auf `crypto/rand` umgestellt werden
|
- `session`-Token ist nur `randbelow(999999999) + timestamp` — sollte auf `crypto/rand` umgestellt werden
|
||||||
- Fehler-Responses sind aktuell Plaintext/HTML — in der Go-API einheitlich JSON
|
- Fehler-Responses sind aktuell Plaintext/HTML — in der Go-API einheitlich JSON
|
||||||
|
|||||||
@@ -73,3 +73,23 @@ button:hover { opacity: 0.9; }
|
|||||||
|
|
||||||
.msg { color: #ff6b6b; font-size: 0.9rem; min-height: 1.2em; }
|
.msg { color: #ff6b6b; font-size: 0.9rem; min-height: 1.2em; }
|
||||||
.end { text-align: center; color: var(--muted); padding: 2rem 0; }
|
.end { text-align: center; color: var(--muted); padding: 2rem 0; }
|
||||||
|
|
||||||
|
.interactions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
.interactions .vote {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #2a2e35;
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
}
|
||||||
|
.interactions .vote:hover { border-color: var(--accent); }
|
||||||
|
.interactions .vote.selected {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.interactions .counts { color: var(--muted); font-size: 0.9rem; }
|
||||||
|
|||||||
@@ -83,10 +83,42 @@ function renderEntry(e) {
|
|||||||
<div class="meta"><strong>${escapeHtml(e.username)}</strong> · ${when}</div>
|
<div class="meta"><strong>${escapeHtml(e.username)}</strong> · ${when}</div>
|
||||||
<div class="content">${linkify(e.content)}</div>
|
<div class="content">${linkify(e.content)}</div>
|
||||||
${media}
|
${media}
|
||||||
|
<div class="interactions">
|
||||||
|
<button class="vote" data-mode="left">Links</button>
|
||||||
|
<span class="counts">– / –</span>
|
||||||
|
<button class="vote" data-mode="right">Rechts</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
setupVoting(el, e.pid);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupVoting(el, pid) {
|
||||||
|
const box = el.querySelector(".interactions");
|
||||||
|
const counts = box.querySelector(".counts");
|
||||||
|
const buttons = box.querySelectorAll(".vote");
|
||||||
|
|
||||||
|
function render(state) {
|
||||||
|
if (state.error) {
|
||||||
|
counts.textContent = state.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
counts.textContent = `${state.left} / ${state.right}`;
|
||||||
|
buttons.forEach((b) =>
|
||||||
|
b.classList.toggle("selected", b.dataset.mode === state.selected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.forEach((b) =>
|
||||||
|
b.addEventListener("click", async () => {
|
||||||
|
render(await api.get(`/entry/${pid}/interactions/${b.dataset.mode}`));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anfangszustand laden, ohne zu werten.
|
||||||
|
api.get(`/entry/${pid}/interactions/none`).then(render);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading || done) return;
|
if (loading || done) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user