Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1bd0f5f1d | ||
|
|
4616e8acbb | ||
|
|
ade853f560 | ||
|
|
8e79e94bbb | ||
|
|
4e0d27c4a6 |
@@ -17,3 +17,5 @@ Containerfile
|
|||||||
.containerignore
|
.containerignore
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
web/
|
web/
|
||||||
|
dev-up.sh
|
||||||
|
githooks/
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# dev-up.sh — baut das Image aus dem aktuellen Arbeitsverzeichnis neu und
|
||||||
|
# startet den lokalen DEV-Container (kver-dev) damit neu.
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# ./dev-up.sh
|
||||||
|
#
|
||||||
|
# Läuft auch automatisch nach jedem Commit (githooks/post-commit, im
|
||||||
|
# Hintergrund, Log in .git/dev-up.log). Einrichten einmalig per:
|
||||||
|
# git config core.hooksPath githooks
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - dev.env mit KVER_DSN (Host "localhost" wird für den Container auf
|
||||||
|
# host.containers.internal umgeschrieben)
|
||||||
|
# - Daten/Medien aus dem PROD-Dump unter $KVER_DEV_DATA (read-only gemountet)
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
NAME=kver-dev
|
||||||
|
IMAGE=localhost/kver:dev
|
||||||
|
PORT="${KVER_DEV_PORT:-8081}"
|
||||||
|
DATA="${KVER_DEV_DATA:-./prod_backup/2026-09-26}"
|
||||||
|
|
||||||
|
# Nur ein Lauf gleichzeitig (schnell aufeinanderfolgende Commits). podman
|
||||||
|
# bekommt fd 9 jeweils nicht mit (9>&-): conmon lebt so lange wie der
|
||||||
|
# Container und würde den Lock sonst für immer halten.
|
||||||
|
exec 9>.git/dev-up.lock
|
||||||
|
flock 9
|
||||||
|
|
||||||
|
DSN="$(grep '^KVER_DSN=' dev.env | cut -d= -f2- | sed 's/@localhost:/@host.containers.internal:/')"
|
||||||
|
if [[ -z "$DSN" ]]; then
|
||||||
|
echo "FEHLER: KVER_DSN fehlt in dev.env." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== $(date '+%F %T') Baue $IMAGE ($(git rev-parse --short HEAD))"
|
||||||
|
podman build -q -t "$IMAGE" . 9>&-
|
||||||
|
|
||||||
|
mounts=()
|
||||||
|
[[ -d "$DATA/data" ]] && mounts+=(-v "$DATA/data:/app/data:ro" -e KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb)
|
||||||
|
[[ -d "$DATA/media" ]] && mounts+=(-v "$DATA/media:/app/static/media:ro")
|
||||||
|
|
||||||
|
podman rm -f "$NAME" >/dev/null 2>&1 9>&- || true
|
||||||
|
podman run -d --name "$NAME" -p "127.0.0.1:$PORT:8080" \
|
||||||
|
-e KVER_DSN="$DSN" \
|
||||||
|
"${mounts[@]}" \
|
||||||
|
--security-opt label=disable "$IMAGE" >/dev/null 9>&-
|
||||||
|
|
||||||
|
# Warten, bis die API antwortet.
|
||||||
|
for _ in $(seq 30); do
|
||||||
|
if curl -fs -o /dev/null "http://127.0.0.1:$PORT/api/stats"; then
|
||||||
|
echo "== läuft: http://127.0.0.1:$PORT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "FEHLER: $NAME antwortet nicht. Log:" >&2
|
||||||
|
podman logs --tail 20 "$NAME" >&2
|
||||||
|
exit 1
|
||||||
@@ -314,6 +314,10 @@ type threadEntry struct {
|
|||||||
Deleted int64 `json:"deleted"`
|
Deleted int64 `json:"deleted"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Latest *struct {
|
||||||
|
Entry threadEntry `json:"entry"`
|
||||||
|
Skipped int64 `json:"skipped"`
|
||||||
|
} `json:"latest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// threadJSON ist die Antwortform von /entry/{pid}/thread. Nicht zu verwechseln
|
// threadJSON ist die Antwortform von /entry/{pid}/thread. Nicht zu verwechseln
|
||||||
@@ -438,6 +442,64 @@ func TestEditEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latestOf beschreibt Latest eines Beitrags knapp für Vergleiche und
|
||||||
|
// Fehlermeldungen: "pid/skipped" oder "-".
|
||||||
|
func latestOf(e threadEntry) string {
|
||||||
|
if e.Latest == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d/%d", e.Latest.Entry.PID, e.Latest.Skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestDescendant(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
|
|
||||||
|
// root ── a ── b ── c
|
||||||
|
// └─ d (d vor b und c erstellt)
|
||||||
|
root := createEntry(t, alice, srv, "wurzel")
|
||||||
|
a := createReply(t, alice, srv, "a", root)
|
||||||
|
d := createReply(t, alice, srv, "d", root)
|
||||||
|
b := createReply(t, alice, srv, "b", a)
|
||||||
|
c := createReply(t, alice, srv, "c", b)
|
||||||
|
|
||||||
|
feed := func() []threadEntry {
|
||||||
|
resp, err := http.Get(srv.URL + "/api/entry/feed/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var f []threadEntry
|
||||||
|
json.NewDecoder(resp.Body).Decode(&f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed: Root mit c, dazwischen a und b.
|
||||||
|
if f := feed(); len(f) != 1 || latestOf(f[0]) != fmt.Sprintf("%d/2", c) {
|
||||||
|
t.Fatalf("feed latest: erwartet %d/2, bekam %s", c, latestOf(f[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antworten auf Root: a mit c (b dazwischen), d ohne Nachfahren.
|
||||||
|
want := map[int64]string{a: fmt.Sprintf("%d/1", c), d: "-"}
|
||||||
|
for _, r := range getThread(t, srv, root).Replies {
|
||||||
|
if got := latestOf(r); got != want[r.PID] {
|
||||||
|
t.Fatalf("reply %d latest: erwartet %s, bekam %s", r.PID, want[r.PID], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direkte Antwort: skipped 0.
|
||||||
|
if r := getThread(t, srv, a).Replies; len(r) != 1 || latestOf(r[0]) != fmt.Sprintf("%d/0", c) {
|
||||||
|
t.Fatalf("b latest: erwartet %d/0, bekam %+v", c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gelöschter jüngster Beitrag wird übersprungen -> nächstjüngerer (b).
|
||||||
|
resp := postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, c), url.Values{})
|
||||||
|
resp.Body.Close()
|
||||||
|
if f := feed(); latestOf(f[0]) != fmt.Sprintf("%d/1", b) {
|
||||||
|
t.Fatalf("feed latest nach delete: erwartet %d/1, bekam %s", b, latestOf(f[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSoftDelete(t *testing.T) {
|
func TestSoftDelete(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ type Entry struct {
|
|||||||
LastBump int64 `json:"last_bump"`
|
LastBump int64 `json:"last_bump"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
|
// Latest ist nur in Feed und Antwortliste gesetzt, siehe attachLatest.
|
||||||
|
Latest *Latest `json:"latest,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest ist der jüngste (nicht gelöschte) Beitrag im Teilbaum unter einem
|
||||||
|
// Beitrag -- die Antwort, die die Diskussion zuletzt fortgesetzt hat. Skipped
|
||||||
|
// zählt die Beiträge, die auf dem Pfad dazwischen liegen (0 = direkte Antwort).
|
||||||
|
type Latest struct {
|
||||||
|
Entry Entry `json:"entry"`
|
||||||
|
Skipped int64 `json:"skipped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
|
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
|
||||||
@@ -124,12 +134,90 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
|||||||
return scanEntries(rows)
|
return scanEntries(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag
|
||||||
|
// aus seinem Teilbaum (Latest). So bildet eine flache Liste auch tiefe
|
||||||
|
// Diskussionen ab: [Beitrag] -- "k weitere" -- [jüngste Antwort], ein Klick
|
||||||
|
// führt ans aktuelle Ende.
|
||||||
|
//
|
||||||
|
// Ein Query für alle Beiträge: die rekursive CTE steigt von allen zugleich
|
||||||
|
// ab, top merkt sich den Ausgangsbeitrag, depth die Tiefe darunter. Das Limit
|
||||||
|
// deckelt die Tiefe wie bei der Ahnenkette (threadData).
|
||||||
|
func attachLatest(entries []Entry) error {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pids := make([]int64, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
pids[i] = e.PID
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(
|
||||||
|
`WITH RECURSIVE sub(top, pid, depth) AS (
|
||||||
|
SELECT e.reply_to, e.pid, 1 FROM entry e WHERE e.reply_to = ANY($1)
|
||||||
|
UNION ALL
|
||||||
|
SELECT s.top, e.pid, s.depth + 1
|
||||||
|
FROM entry e JOIN sub s ON e.reply_to = s.pid
|
||||||
|
WHERE s.depth < 50
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (s.top) s.top, s.pid, s.depth
|
||||||
|
FROM sub s JOIN entry x ON x.pid = s.pid
|
||||||
|
WHERE x.deleted = 0
|
||||||
|
ORDER BY s.top, x.created_at DESC, x.id DESC`, pids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type hit struct{ pid, depth int64 }
|
||||||
|
hits := map[int64]hit{}
|
||||||
|
latestPIDs := []int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var top int64
|
||||||
|
var h hit
|
||||||
|
if err := rows.Scan(&top, &h.pid, &h.depth); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hits[top] = h
|
||||||
|
latestPIDs = append(latestPIDs, h.pid)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(latestPIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = db.Query(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
latest, err := scanEntries(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byPID := make(map[int64]Entry, len(latest))
|
||||||
|
for _, e := range latest {
|
||||||
|
byPID[e.PID] = e
|
||||||
|
}
|
||||||
|
for i := range entries {
|
||||||
|
if h, ok := hits[entries[i].PID]; ok {
|
||||||
|
if e, ok := byPID[h.pid]; ok {
|
||||||
|
entries[i].Latest = &Latest{Entry: e, Skipped: h.depth - 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
||||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||||
entries, err := feedPage(page, 0, true)
|
entries, err := feedPage(page, 0, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Internal(err)
|
return Internal(err)
|
||||||
}
|
}
|
||||||
|
if err := attachLatest(entries); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, entries)
|
writeJSON(w, http.StatusOK, entries)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -166,7 +254,7 @@ func pidParam(r *http.Request) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
||||||
// direkten Antworten (öffentlich).
|
// direkten Antworten samt deren jüngstem Nachfahren (öffentlich).
|
||||||
func handleThread(w http.ResponseWriter, r *http.Request) error {
|
func handleThread(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := pidParam(r)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,6 +320,9 @@ func threadData(pid int64) (thread, error) {
|
|||||||
if t.Replies, err = scanEntries(rows); err != nil {
|
if t.Replies, err = scanEntries(rows); err != nil {
|
||||||
return thread{}, Internal(err)
|
return thread{}, Internal(err)
|
||||||
}
|
}
|
||||||
|
if err := attachLatest(t.Replies); err != nil {
|
||||||
|
return thread{}, Internal(err)
|
||||||
|
}
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,32 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */
|
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */
|
||||||
#ancestors .threadline { border-right: var(--line-w) solid gray; }
|
#ancestors .threadline { border-right: var(--line-w) solid gray; }
|
||||||
|
|
||||||
|
/* Diskussionskette in Feed und Antworten (Discussion): Beitrag, ggf.
|
||||||
|
"k weitere Beiträge", jüngste Antwort. Gleiches Bild wie die Ahnenkette --
|
||||||
|
Strich an allen Gliedern außer dem letzten. */
|
||||||
|
.chain > :not(:last-child) .threadline {
|
||||||
|
display: block;
|
||||||
|
flex: 0 0 calc(var(--avatar-sm) / 2 - var(--line-w));
|
||||||
|
margin-right: calc(var(--avatar-sm) / 2);
|
||||||
|
border-right: var(--line-w) solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "k weitere Beiträge" unterbricht den Strich: der Button steht bündig mit
|
||||||
|
dem Avatar, der Strich läuft oben auf ihn zu und setzt darunter (::after)
|
||||||
|
wieder an bis zum nächsten Avatar. .chain-gap a hebt den Button-Margin auf,
|
||||||
|
damit Strich und Rahmen sich berühren. */
|
||||||
|
.chain-gap a {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.chain-gap::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 0.6rem;
|
||||||
|
margin-left: calc(var(--avatar-sm) / 2 - var(--line-w));
|
||||||
|
border-left: var(--line-w) solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
|
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
|
||||||
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
|
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
|
||||||
wandert in die Byline (siehe setupBump). */
|
wandert in die Byline (siehe setupBump). */
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export function AuthBox() {
|
|||||||
<option value="604800">1 Woche</option>
|
<option value="604800">1 Woche</option>
|
||||||
<option value="31536000">1 Jahr</option>
|
<option value="31536000">1 Jahr</option>
|
||||||
</select>
|
</select>
|
||||||
</label>{" "}
|
</label>
|
||||||
|
<br />
|
||||||
<button type="submit" disabled={busy}>Einloggen</button>{" "}
|
<button type="submit" disabled={busy}>Einloggen</button>{" "}
|
||||||
<p className="muted">Noch kein Konto? <a href="#" onClick={show("register")}>Registrieren</a></p>
|
<p className="muted">Noch kein Konto? <a href="#" onClick={show("register")}>Registrieren</a></p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -65,3 +65,26 @@ function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open:
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discussion zeigt einen Beitrag mit der Antwort, die seine Diskussion zuletzt
|
||||||
|
// fortgesetzt hat: [Beitrag] ── "k weitere Beiträge" ── [jüngste Antwort].
|
||||||
|
// Der Strich verbindet die Glieder wie in der Ahnenkette; "k weitere" steht
|
||||||
|
// als Unterbrechung auf dem Strich und führt wie die Antwort selbst zu deren
|
||||||
|
// Seite, wo die ganze Kette darüber steht.
|
||||||
|
export function Discussion({ entry: e }: { entry: Entry }) {
|
||||||
|
if (!e.latest) return <EntryCard entry={e} />;
|
||||||
|
const { entry: latest, skipped } = e.latest;
|
||||||
|
return (
|
||||||
|
<div className="chain">
|
||||||
|
<EntryCard entry={e} />
|
||||||
|
{skipped > 0 && (
|
||||||
|
<div className="chain-gap">
|
||||||
|
<Link className="button" to={`/e/${latest.pid}`}>
|
||||||
|
{skipped === 1 ? "1 weiterer Beitrag" : `${skipped} weitere Beiträge`}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<EntryCard entry={latest} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { api, errMsg } from "../api";
|
import { api, errMsg } from "../api";
|
||||||
import type { Entry } from "../types";
|
import type { Entry } from "../types";
|
||||||
import { EntryCard } from "./EntryCard";
|
import { Discussion } from "./EntryCard";
|
||||||
|
|
||||||
// Das Backend deckelt die Seitenzahl bei 100 und liefert darüber wieder
|
// Das Backend deckelt die Seitenzahl bei 100 und liefert darüber wieder
|
||||||
// Seite 100 -- ohne diese Grenze würde der Feed sie endlos wiederholen.
|
// Seite 100 -- ohne diese Grenze würde der Feed sie endlos wiederholen.
|
||||||
@@ -54,7 +54,7 @@ export function Feed({ queryKey, urlFor, as: Container = "section" }: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container id="feed">
|
<Container id="feed">
|
||||||
{entries.map((e) => <EntryCard key={e.pid} entry={e} />)}
|
{entries.map((e) => <Discussion key={e.pid} entry={e} />)}
|
||||||
{feed.isError && <p className="msg">{errMsg(feed.error, "Feed konnte nicht geladen werden.")}</p>}
|
{feed.isError && <p className="msg">{errMsg(feed.error, "Feed konnte nicht geladen werden.")}</p>}
|
||||||
{feed.isSuccess && !hasNextPage && <p className="muted">YOU REACHED THE END!</p>}
|
{feed.isSuccess && !hasNextPage && <p className="muted">YOU REACHED THE END!</p>}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { api, errMsg } from "../api";
|
import { api, errMsg } from "../api";
|
||||||
import { useMe } from "../auth";
|
import { useMe } from "../auth";
|
||||||
import { pasteImage } from "../paste";
|
import { pasteImage } from "../paste";
|
||||||
import { EntryCard } from "../components/EntryCard";
|
import { Discussion, EntryCard } from "../components/EntryCard";
|
||||||
import type { CreatedEntry, Entry, Thread } from "../types";
|
import type { CreatedEntry, Entry, Thread } from "../types";
|
||||||
|
|
||||||
// Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung,
|
// Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung,
|
||||||
@@ -56,7 +56,7 @@ function ThreadView({ pid }: { pid: string }) {
|
|||||||
|
|
||||||
<section id="replies">
|
<section id="replies">
|
||||||
{data && !data.replies.length && <p className="muted">Noch keine Antworten.</p>}
|
{data && !data.replies.length && <p className="muted">Noch keine Antworten.</p>}
|
||||||
{data?.replies.map((e) => <EntryCard key={e.pid} entry={e} />)}
|
{data?.replies.map((e) => <Discussion key={e.pid} entry={e} />)}
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export interface Entry {
|
|||||||
last_bump: number;
|
last_bump: number;
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
// Nur in Feed und Antwortliste: jüngster Beitrag im Teilbaum, skipped =
|
||||||
|
// Beiträge auf dem Pfad dazwischen (0 = direkte Antwort).
|
||||||
|
latest?: { entry: Entry; skipped: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Thread {
|
export interface Thread {
|
||||||
|
|||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Nach jedem Commit den DEV-Container im Hintergrund neu bauen und starten,
|
||||||
|
# damit der Commit nicht auf den Build wartet. Ausgabe: .git/dev-up.log
|
||||||
|
# Aussetzen für einen Commit: KVER_NO_DEV_UP=1 git commit ...
|
||||||
|
[[ -n "${KVER_NO_DEV_UP:-}" ]] && exit 0
|
||||||
|
root="$(git rev-parse --show-toplevel)"
|
||||||
|
nohup "$root/dev-up.sh" >"$root/.git/dev-up.log" 2>&1 </dev/null &
|
||||||
|
echo "dev-up: DEV-Container wird neu gebaut (Log: .git/dev-up.log)"
|
||||||
+12
-2
@@ -71,10 +71,20 @@ Ein Beitrag (`Entry`):
|
|||||||
"last_bump": 1700000400, "username": "max", "avatar": "static/media/…" }
|
"last_bump": 1700000400, "username": "max", "avatar": "static/media/…" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In Hauptfeed und `replies` des Threads trägt jeder Beitrag mit Antworten
|
||||||
|
zusätzlich `latest`: den jüngsten nicht gelöschten Beitrag aus seinem ganzen
|
||||||
|
Teilbaum (nach `created_at`, höchstens 50 Ebenen tief). `skipped` zählt die
|
||||||
|
Beiträge auf dem Pfad dazwischen, gelöschte eingeschlossen (0 = direkte
|
||||||
|
Antwort). Ohne passenden Nachfahren fehlt das Feld.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"latest": { "entry": Entry, "skipped": 2 }
|
||||||
|
```
|
||||||
|
|
||||||
| Methode | Pfad | Auth | Request | Erfolg |
|
| Methode | Pfad | Auth | Request | Erfolg |
|
||||||
|---------|------|------|---------|--------|
|
|---------|------|------|---------|--------|
|
||||||
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
|
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
|
||||||
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}` |
|
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
|
||||||
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
|
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) |
|
||||||
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
|
| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` |
|
||||||
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
|
| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` |
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ Vite reicht `/api` und `/static` ans Backend durch (`frontend/vite.config.ts`,
|
|||||||
(gitignored). Von dort liefert das Go-Backend aus (`frontend.go`), lokal also
|
(gitignored). Von dort liefert das Go-Backend aus (`frontend.go`), lokal also
|
||||||
auch ohne Vite: `npm run build`, dann `go run .`.
|
auch ohne Vite: `npm run build`, dann `go run .`.
|
||||||
|
|
||||||
|
### DEV-Container
|
||||||
|
|
||||||
|
`./dev-up.sh` baut das Image aus dem Arbeitsverzeichnis und startet den
|
||||||
|
Container `kver-dev` auf http://127.0.0.1:8081 neu (DSN aus `dev.env`, Daten
|
||||||
|
und Medien read-only aus `prod_backup/`). Mit `git config core.hooksPath
|
||||||
|
githooks` läuft das nach jedem Commit automatisch im Hintergrund (Log:
|
||||||
|
`.git/dev-up.log`); einmalig aussetzen mit `KVER_NO_DEV_UP=1 git commit …`.
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
| Datei | Zweck |
|
| Datei | Zweck |
|
||||||
|
|||||||
Reference in New Issue
Block a user