diff --git a/db.go b/db.go index 6af20ec..8aaae1d 100644 --- a/db.go +++ b/db.go @@ -40,7 +40,8 @@ CREATE TABLE IF NOT EXISTS vote ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid INTEGER, pid INTEGER, - mode TEXT + mode TEXT, + UNIQUE(uid, pid) ); ` diff --git a/entry.go b/entry.go index f2d5efa..390d7bc 100644 --- a/entry.go +++ b/entry.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "fmt" "image" "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) { - 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. diff --git a/notes/api.md b/notes/api.md index 98059a3..3e14524 100644 --- a/notes/api.md +++ b/notes/api.md @@ -137,13 +137,28 @@ Mindestens `content` oder `file` muss vorhanden sein. ### `GET /entry/:pid/interactions/:mode` -Abstimmung auf einen Beitrag. (*Aktuell nicht implementiert.*) +Abstimmungsstand lesen und optional die eigene Stimme umschalten. **Parameter:** | Name | Typ | Beschreibung | |------|-----|--------------| | `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 -- `/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 - `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 diff --git a/web/css/app.css b/web/css/app.css index 4392b3c..208066a 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -73,3 +73,23 @@ button:hover { opacity: 0.9; } .msg { color: #ff6b6b; font-size: 0.9rem; min-height: 1.2em; } .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; } diff --git a/web/js/app.js b/web/js/app.js index 272b5a9..e03736a 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -83,10 +83,42 @@ function renderEntry(e) {
${escapeHtml(e.username)} · ${when}
${linkify(e.content)}
${media} +
+ + – / – + +
`; + setupVoting(el, e.pid); 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() { if (loading || done) return; loading = true;