Threading: Antworten (reply_to) + Focus-View
- entry: reply_to/reply_count/last_activity; reply_count bubbelt bis Root
- Hauptfeed nur Roots nach last_activity, Profil-Feed inkl. Antworten
- GET /entry/{pid}/thread (Ahnen+Antworten), GET /e/{pid} Focus-Seite
- Frontend: Antworten-Link im Feed, entry.html/entry.js Focus-Seite
- notes/migrations.md: ALTER TABLE fuer Prod
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,12 +28,15 @@ CREATE TABLE IF NOT EXISTS session (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entry (
|
CREATE TABLE IF NOT EXISTS entry (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
pid INTEGER UNIQUE,
|
pid INTEGER UNIQUE,
|
||||||
uid INTEGER,
|
uid INTEGER,
|
||||||
created_at INTEGER,
|
created_at INTEGER,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
filepath TEXT
|
filepath TEXT,
|
||||||
|
reply_to INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reply_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_activity INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS vote (
|
CREATE TABLE IF NOT EXISTS vote (
|
||||||
|
|||||||
@@ -221,6 +221,100 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createReply erstellt eine Antwort auf parentPID und liefert deren pid.
|
||||||
|
func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
resp := postForm(t, c, srv.URL+"/entry/create", url.Values{
|
||||||
|
"content": {content}, "reply_to": {fmt.Sprint(parentPID)},
|
||||||
|
})
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("create reply: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
PID int64 `json:"pid"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
return out.PID
|
||||||
|
}
|
||||||
|
|
||||||
|
type threadEntry struct {
|
||||||
|
PID int64 `json:"pid"`
|
||||||
|
ReplyCount int64 `json:"reply_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type threadView struct {
|
||||||
|
Entry threadEntry `json:"entry"`
|
||||||
|
Ancestors []threadEntry `json:"ancestors"`
|
||||||
|
Replies []threadEntry `json:"replies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getThread(t *testing.T, srv *httptest.Server, pid int64) threadView {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.Get(fmt.Sprintf("%s/entry/%d/thread", srv.URL, pid))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("thread %d: status %d", pid, resp.StatusCode)
|
||||||
|
}
|
||||||
|
var tv threadView
|
||||||
|
json.NewDecoder(resp.Body).Decode(&tv)
|
||||||
|
return tv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThreading(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
|
|
||||||
|
root := createEntry(t, alice, srv, "wurzel")
|
||||||
|
mid := createReply(t, alice, srv, "antwort 1", root)
|
||||||
|
createReply(t, alice, srv, "antwort 2", mid) // tiefere Antwort
|
||||||
|
|
||||||
|
// reply_count bubblet bis zum Root: Root = 2 (ganzer Teilbaum), mid = 1.
|
||||||
|
if tv := getThread(t, srv, root); tv.Entry.ReplyCount != 2 {
|
||||||
|
t.Fatalf("root reply_count: erwartet 2, bekam %d", tv.Entry.ReplyCount)
|
||||||
|
}
|
||||||
|
rootThread := getThread(t, srv, root)
|
||||||
|
if len(rootThread.Replies) != 1 || rootThread.Replies[0].PID != mid {
|
||||||
|
t.Fatalf("root replies: erwartet [%d], bekam %+v", mid, rootThread.Replies)
|
||||||
|
}
|
||||||
|
if len(rootThread.Ancestors) != 0 {
|
||||||
|
t.Fatalf("root ancestors: erwartet leer, bekam %+v", rootThread.Ancestors)
|
||||||
|
}
|
||||||
|
|
||||||
|
midThread := getThread(t, srv, mid)
|
||||||
|
if midThread.Entry.ReplyCount != 1 {
|
||||||
|
t.Fatalf("mid reply_count: erwartet 1, bekam %d", midThread.Entry.ReplyCount)
|
||||||
|
}
|
||||||
|
if len(midThread.Ancestors) != 1 || midThread.Ancestors[0].PID != root {
|
||||||
|
t.Fatalf("mid ancestors: erwartet [%d], bekam %+v", root, midThread.Ancestors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hauptfeed: nur der Root, keine Antworten.
|
||||||
|
resp, _ := http.Get(srv.URL + "/entry/feed/0")
|
||||||
|
var feed []struct {
|
||||||
|
PID int64 `json:"pid"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&feed)
|
||||||
|
resp.Body.Close()
|
||||||
|
if len(feed) != 1 || feed[0].PID != root {
|
||||||
|
t.Fatalf("hauptfeed: erwartet nur Root %d, bekam %+v", root, feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profil-Feed: alle drei Beiträge (Root + Antworten).
|
||||||
|
resp, _ = http.Get(srv.URL + "/u/alice/feed/0")
|
||||||
|
var profile []struct {
|
||||||
|
PID int64 `json:"pid"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&profile)
|
||||||
|
resp.Body.Close()
|
||||||
|
if len(profile) != 3 {
|
||||||
|
t.Fatalf("profil-feed: erwartet 3 Beiträge, bekam %d", len(profile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenameUser(t *testing.T) {
|
func TestRenameUser(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
|
|||||||
@@ -20,17 +20,50 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
PID int64 `json:"pid"`
|
PID int64 `json:"pid"`
|
||||||
UID int64 `json:"uid"`
|
UID int64 `json:"uid"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Filepath string `json:"filepath"`
|
Filepath string `json:"filepath"`
|
||||||
Username string `json:"username"`
|
ReplyTo int64 `json:"reply_to"`
|
||||||
|
ReplyCount int64 `json:"reply_count"`
|
||||||
|
LastActivity int64 `json:"last_activity"`
|
||||||
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// feedPage liefert eine Feed-Seite (20 Beiträge, neueste zuerst). Ist uid != 0,
|
const entrySelect = `
|
||||||
// werden nur die Beiträge dieses Nutzers geliefert.
|
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
||||||
func feedPage(page int, uid int64) ([]Entry, error) {
|
e.reply_to, e.reply_count, e.last_activity, u.username
|
||||||
|
FROM entry e JOIN user u ON u.uid = e.uid`
|
||||||
|
|
||||||
|
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
||||||
|
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
entries := []Entry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
||||||
|
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Username); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
|
||||||
|
func entryByPID(pid int64) (Entry, error) {
|
||||||
|
var e Entry
|
||||||
|
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
||||||
|
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
||||||
|
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Username)
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedPage liefert eine Feed-Seite (20 Beiträge). Ist uid != 0, nur die Beiträge
|
||||||
|
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
|
||||||
|
// sonst werden alle Beiträge chronologisch geliefert.
|
||||||
|
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||||
if page < 0 {
|
if page < 0 {
|
||||||
page = 0
|
page = 0
|
||||||
}
|
}
|
||||||
@@ -39,37 +72,37 @@ func feedPage(page int, uid int64) ([]Entry, error) {
|
|||||||
}
|
}
|
||||||
const count = 20
|
const count = 20
|
||||||
|
|
||||||
query := `
|
query := entrySelect
|
||||||
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, u.username
|
|
||||||
FROM entry e JOIN user u ON u.uid = e.uid`
|
|
||||||
args := []any{}
|
args := []any{}
|
||||||
|
where := []string{}
|
||||||
if uid != 0 {
|
if uid != 0 {
|
||||||
query += ` WHERE e.uid = ?`
|
where = append(where, "e.uid = ?")
|
||||||
args = append(args, uid)
|
args = append(args, uid)
|
||||||
}
|
}
|
||||||
query += ` ORDER BY e.created_at DESC LIMIT ? OFFSET ?`
|
if rootsOnly {
|
||||||
|
where = append(where, "e.reply_to = 0")
|
||||||
|
}
|
||||||
|
if len(where) > 0 {
|
||||||
|
query += " WHERE " + strings.Join(where, " AND ")
|
||||||
|
}
|
||||||
|
if rootsOnly {
|
||||||
|
query += ` ORDER BY e.last_activity DESC`
|
||||||
|
} else {
|
||||||
|
query += ` ORDER BY e.created_at DESC`
|
||||||
|
}
|
||||||
|
query += ` LIMIT ? OFFSET ?`
|
||||||
args = append(args, count, page*count)
|
args = append(args, count, page*count)
|
||||||
|
|
||||||
rows, err := db.Query(query, args...)
|
rows, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
return scanEntries(rows)
|
||||||
|
|
||||||
entries := []Entry{}
|
|
||||||
for rows.Next() {
|
|
||||||
var e Entry
|
|
||||||
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, &e.Username); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
return entries, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFeed(w http.ResponseWriter, r *http.Request) {
|
func handleFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||||
entries, err := feedPage(page, 0)
|
entries, err := feedPage(page, 0, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||||
return
|
return
|
||||||
@@ -77,7 +110,7 @@ func handleFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, entries)
|
writeJSON(w, http.StatusOK, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich).
|
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
|
||||||
func handleUserFeed(w http.ResponseWriter, r *http.Request) {
|
func handleUserFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
var uid int64
|
var uid int64
|
||||||
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
||||||
@@ -86,7 +119,7 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||||
entries, err := feedPage(page, uid)
|
entries, err := feedPage(page, uid, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||||
return
|
return
|
||||||
@@ -94,6 +127,59 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, entries)
|
writeJSON(w, http.StatusOK, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
|
||||||
|
// dann über /entry/{pid}/thread.
|
||||||
|
func serveEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, "web/entry.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
||||||
|
// direkten Antworten (öffentlich).
|
||||||
|
func handleThread(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := entryByPID(pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ahnenkette von Eltern bis Root hochlaufen, dann umdrehen (Root zuerst).
|
||||||
|
ancestors := []Entry{}
|
||||||
|
for cur := entry.ReplyTo; cur != 0 && len(ancestors) < 50; {
|
||||||
|
a, err := entryByPID(cur)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ancestors = append(ancestors, a)
|
||||||
|
cur = a.ReplyTo
|
||||||
|
}
|
||||||
|
for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
ancestors[i], ancestors[j] = ancestors[j], ancestors[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replies, err := scanEntries(rows)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"entry": entry,
|
||||||
|
"ancestors": ancestors,
|
||||||
|
"replies": replies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
@@ -107,6 +193,14 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
content = content[:1000]
|
content = content[:1000]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64)
|
||||||
|
if replyTo != 0 {
|
||||||
|
if _, err := entryByPID(replyTo); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
filepath := ""
|
filepath := ""
|
||||||
if r.MultipartForm != nil {
|
if r.MultipartForm != nil {
|
||||||
if files := r.MultipartForm.File["file"]; len(files) > 0 {
|
if files := r.MultipartForm.File["file"]; len(files) > 0 {
|
||||||
@@ -123,10 +217,37 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
pid := int64(rand.IntN(999999999999))
|
pid := int64(rand.IntN(999999999999))
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
if _, err := db.Exec(
|
|
||||||
`INSERT INTO entry (pid, uid, created_at, content, filepath) VALUES (?, ?, ?, ?, ?)`,
|
tx, err := db.Begin()
|
||||||
pid, uid, now, content, filepath,
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||||
|
pid, uid, now, content, filepath, replyTo, now,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben.
|
||||||
|
for cur, i := replyTo, 0; cur != 0 && i < 50; i++ {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`UPDATE entry SET reply_count = reply_count + 1,
|
||||||
|
last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`,
|
||||||
|
now, cur,
|
||||||
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tx.QueryRow(`SELECT reply_to FROM entry WHERE pid = ?`, cur).Scan(&cur); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -135,6 +256,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
"pid": pid,
|
"pid": pid,
|
||||||
"content": content,
|
"content": content,
|
||||||
"filepath": filepath,
|
"filepath": filepath,
|
||||||
|
"reply_to": replyTo,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func routes() http.Handler {
|
|||||||
// --- Entries ---
|
// --- Entries ---
|
||||||
r.Get("/entry/feed/{page}", handleFeed)
|
r.Get("/entry/feed/{page}", handleFeed)
|
||||||
r.Get("/entry/{pid}/votes", handleVotes)
|
r.Get("/entry/{pid}/votes", handleVotes)
|
||||||
|
r.Get("/entry/{pid}/thread", handleThread)
|
||||||
|
r.Get("/e/{pid}", serveEntryPage)
|
||||||
|
|
||||||
// --- User (öffentlich) ---
|
// --- User (öffentlich) ---
|
||||||
r.Get("/u/{username}", serveUserPage)
|
r.Get("/u/{username}", serveUserPage)
|
||||||
|
|||||||
@@ -22,3 +22,27 @@ UPDATE entry SET filepath = '' WHERE filepath = 'none';
|
|||||||
```
|
```
|
||||||
|
|
||||||
Idempotent — kann gefahrlos mehrfach laufen.
|
Idempotent — kann gefahrlos mehrfach laufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threading-Spalten in `entry`
|
||||||
|
|
||||||
|
**Wann:** beim Deploy der Threading-Version gegen eine bestehende `kver.db`, die
|
||||||
|
die Tabelle `entry` noch ohne die Threading-Spalten hat.
|
||||||
|
|
||||||
|
**Hintergrund:** Das Threading führt `reply_to` (pid des Eltern-Posts, `0` = Root),
|
||||||
|
`reply_count` (Anzahl Antworten im gesamten Teilbaum) und `last_activity`
|
||||||
|
(Sortierschlüssel des Hauptfeeds) ein. `CREATE TABLE IF NOT EXISTS` ergänzt diese
|
||||||
|
Spalten bei einer vorhandenen Tabelle nicht.
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE entry ADD COLUMN reply_to INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE entry ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE entry ADD COLUMN last_activity INTEGER;
|
||||||
|
UPDATE entry SET last_activity = created_at WHERE last_activity IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Bestehende Zeilen werden so zu Root-Beiträgen (`reply_to = 0`, `reply_count = 0`)
|
||||||
|
mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur
|
||||||
|
einmalig ausführen.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>kver</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>kver</h1>
|
||||||
|
<div id="headerbar"><a href="/">zurück zum Feed</a></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="ancestors"></section>
|
||||||
|
<div id="focus"></div>
|
||||||
|
|
||||||
|
<!-- Nur für eingeloggte Nutzer -->
|
||||||
|
<section id="reply-box" hidden>
|
||||||
|
<form id="reply-form">
|
||||||
|
<textarea name="content" rows="3" maxlength="1000" placeholder="Antworten…"></textarea>
|
||||||
|
<input name="file" type="file" accept="image/*">
|
||||||
|
<button type="submit">Antworten</button>
|
||||||
|
</form>
|
||||||
|
<p class="msg" id="reply-msg"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h2 id="replies-heading">Antworten</h2>
|
||||||
|
<section id="replies"></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/feed.js"></script>
|
||||||
|
<script src="/js/entry.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// pid aus dem Pfad /e/<pid>.
|
||||||
|
const pid = location.pathname.split("/")[2] || "";
|
||||||
|
|
||||||
|
document.getElementById("reply-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.target);
|
||||||
|
fd.append("reply_to", pid);
|
||||||
|
const res = await api.post("/entry/create", fd);
|
||||||
|
if (res.ok) {
|
||||||
|
e.target.reset();
|
||||||
|
document.getElementById("reply-msg").textContent = "";
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
document.getElementById("reply-msg").textContent =
|
||||||
|
res.data.error || "Antwort fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await api.get(`/entry/${pid}/thread`);
|
||||||
|
|
||||||
|
const focus = document.getElementById("focus");
|
||||||
|
if (data.error) {
|
||||||
|
focus.textContent = "Beitrag nicht gefunden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ahnenkette (Root zuerst), dann der fokussierte Beitrag.
|
||||||
|
const ancestors = document.getElementById("ancestors");
|
||||||
|
ancestors.innerHTML = "";
|
||||||
|
data.ancestors.forEach((a) => ancestors.appendChild(renderEntry(a)));
|
||||||
|
|
||||||
|
focus.innerHTML = "";
|
||||||
|
focus.appendChild(renderEntry(data.entry));
|
||||||
|
|
||||||
|
// Reply-Box nur eingeloggt zeigen.
|
||||||
|
const h = await api.get("/auth/headerbar");
|
||||||
|
document.getElementById("reply-box").hidden = !h.loggedin;
|
||||||
|
|
||||||
|
// Direkte Antworten.
|
||||||
|
const replies = document.getElementById("replies");
|
||||||
|
replies.innerHTML = "";
|
||||||
|
if (!data.replies.length) {
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.className = "muted";
|
||||||
|
p.textContent = "Noch keine Antworten.";
|
||||||
|
replies.appendChild(p);
|
||||||
|
} else {
|
||||||
|
data.replies.forEach((e) => replies.appendChild(renderEntry(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
@@ -40,6 +40,9 @@ function renderEntry(e) {
|
|||||||
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
|
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replyLabel =
|
||||||
|
e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`;
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="muted"><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}</div>
|
<div class="muted"><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}</div>
|
||||||
<div class="content">${linkify(e.content)}</div>
|
<div class="content">${linkify(e.content)}</div>
|
||||||
@@ -49,6 +52,7 @@ function renderEntry(e) {
|
|||||||
<span class="muted">– / –</span>
|
<span class="muted">– / –</span>
|
||||||
<button class="ghost" data-mode="right">Rechts</button>
|
<button class="ghost" data-mode="right">Rechts</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="muted"><a href="/e/${e.pid}">${replyLabel}</a></div>
|
||||||
`;
|
`;
|
||||||
setupVoting(el, e.pid);
|
setupVoting(el, e.pid);
|
||||||
return el;
|
return el;
|
||||||
|
|||||||
Reference in New Issue
Block a user