SSR: Repost ohne JS, return_to in Formularen, Werbebanner entfernt
Letzter vollständiger SSR-Stand vor dem Rückbau auf reine SPA. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c66df283d1
commit
eccddcebe6
+111
-3
@@ -288,6 +288,85 @@ func TestBump(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRepostOhneJavaScript deckt den Repost-Weg ohne JS ab: der Knopf muss im
|
||||||
|
// Feed stehen, der Klick per 303 an den Beitrag zurückführen, danach gesperrt
|
||||||
|
// sein -- und ein erneuter Klick im Cooldown eine HTML-Seite liefern, nicht die
|
||||||
|
// JSON-Zeile, die ein Browser roh anzeigen würde.
|
||||||
|
func TestRepostOhneJavaScript(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
|
pid := createEntry(t, alice, srv, "hebt euch")
|
||||||
|
|
||||||
|
// Angemeldet: Repost-Formular mit Rücksprungziel, Knopf noch frei.
|
||||||
|
_, page := getHTML(t, alice, srv.URL+"/feed")
|
||||||
|
if !strings.Contains(page, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) {
|
||||||
|
t.Fatalf("kein repost-formular im feed:\n%s", excerpt(page, "interactions"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(page, "Repost (0)") {
|
||||||
|
t.Errorf("repost-zähler falsch:\n%s", excerpt(page, "bump"))
|
||||||
|
}
|
||||||
|
if strings.Contains(page, `class="bump" type="submit" disabled`) {
|
||||||
|
t.Errorf("repost-knopf ohne bisherigen repost gesperrt:\n%s", excerpt(page, "bump"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reposten -> 303 zurück an den Beitrag.
|
||||||
|
resp := postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid),
|
||||||
|
url.Values{"return_to": {fmt.Sprintf("/feed#e%d", pid)}})
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusSeeOther {
|
||||||
|
t.Fatalf("repost: erwartet 303, bekam %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got, want := resp.Header.Get("Location"), fmt.Sprintf("/feed#e%d", pid); got != want {
|
||||||
|
t.Fatalf("repost: Location = %q, erwartet %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jetzt im Cooldown: Zähler hoch, Knopf gesperrt, Wartezeit sichtbar.
|
||||||
|
_, page = getHTML(t, alice, srv.URL+"/feed")
|
||||||
|
if !strings.Contains(page, "Repost (1)") {
|
||||||
|
t.Errorf("repost-zähler nicht erhöht:\n%s", excerpt(page, "bump"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(page, `class="bump" type="submit" disabled`) {
|
||||||
|
t.Errorf("repost-knopf im cooldown nicht gesperrt:\n%s", excerpt(page, "bump"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erneuter Repost im Cooldown -> HTML-Fehlerseite, nicht rohes JSON.
|
||||||
|
resp = postFormHTML(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{})
|
||||||
|
cooldown := body(t, resp)
|
||||||
|
if resp.StatusCode != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("repost im cooldown: erwartet 429, bekam %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Fatalf("repost im cooldown: erwartet HTML, bekam %q", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cooldown, "erst kürzlich repostet") {
|
||||||
|
t.Errorf("keine verständliche meldung:\n%s", cooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der JSON-Client bekommt weiterhin JSON mit Nutzdaten.
|
||||||
|
resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{})
|
||||||
|
jsonBody := body(t, resp)
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||||
|
t.Fatalf("API-Client: erwartet JSON, bekam %q", ct)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonBody, `"retry_after"`) {
|
||||||
|
t.Errorf("JSON-Antwort ohne retry_after:\n%s", jsonBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht angemeldet: kein Formular im Feed, POST -> 401 als HTML.
|
||||||
|
_, anonPage := getHTML(t, newClient(t), srv.URL+"/feed")
|
||||||
|
if strings.Contains(anonPage, fmt.Sprintf(`action="/entry/%d/bump"`, pid)) {
|
||||||
|
t.Error("repost-formular für nicht angemeldete besucher")
|
||||||
|
}
|
||||||
|
resp = postFormHTML(t, newClient(t), fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid), url.Values{})
|
||||||
|
page = body(t, resp)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("anon repost: erwartet 401, bekam %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Fatalf("anon repost: erwartet HTML, bekam %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createReply erstellt eine Antwort auf parentPID und liefert deren pid.
|
// 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 {
|
func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1212,6 +1291,11 @@ func TestBeitragOhneJavaScript(t *testing.T) {
|
|||||||
if !strings.Contains(page, `type="file"`) {
|
if !strings.Contains(page, `type="file"`) {
|
||||||
t.Errorf("kein dateifeld:\n%s", excerpt(page, "compose"))
|
t.Errorf("kein dateifeld:\n%s", excerpt(page, "compose"))
|
||||||
}
|
}
|
||||||
|
// Das Formular trägt den Rücksprungort mit, damit ein abgelehnter Post ihn
|
||||||
|
// nicht verliert und die neu gerenderten Knöpfe auf "/feed" zeigen statt "/".
|
||||||
|
if !strings.Contains(page, `name="return_to" value="/feed"`) {
|
||||||
|
t.Errorf("beitragsformular ohne return_to:\n%s", excerpt(page, "compose"))
|
||||||
|
}
|
||||||
|
|
||||||
// Nicht angemeldet: kein Formular.
|
// Nicht angemeldet: kein Formular.
|
||||||
_, anonPage := getHTML(t, newClient(t), srv.URL+"/feed")
|
_, anonPage := getHTML(t, newClient(t), srv.URL+"/feed")
|
||||||
@@ -1252,7 +1336,9 @@ func TestBeitragOhneJavaScript(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Leerer Beitrag -> Feed mit Meldung und erhaltenem Text, kein Redirect.
|
// Leerer Beitrag -> Feed mit Meldung und erhaltenem Text, kein Redirect.
|
||||||
resp = postFormHTML(t, alice, srv.URL+"/entry/create", url.Values{"content": {" "}})
|
// return_to wie vom Formular mitgeschickt.
|
||||||
|
resp = postFormHTML(t, alice, srv.URL+"/entry/create",
|
||||||
|
url.Values{"content": {" "}, "return_to": {"/feed"}, "page": {"0"}})
|
||||||
failPage := body(t, resp)
|
failPage := body(t, resp)
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
t.Fatalf("leerer beitrag: erwartet 400, bekam %d", resp.StatusCode)
|
t.Fatalf("leerer beitrag: erwartet 400, bekam %d", resp.StatusCode)
|
||||||
@@ -1264,6 +1350,14 @@ func TestBeitragOhneJavaScript(t *testing.T) {
|
|||||||
if !strings.Contains(failPage, "mein erster ssr-beitrag") {
|
if !strings.Contains(failPage, "mein erster ssr-beitrag") {
|
||||||
t.Errorf("feed nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "feed"))
|
t.Errorf("feed nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "feed"))
|
||||||
}
|
}
|
||||||
|
// Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Feed, nicht
|
||||||
|
// auf "/" (die noch per JS bediente Startseite).
|
||||||
|
if strings.Contains(failPage, `name="return_to" value="/#e`) {
|
||||||
|
t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(failPage, `name="return_to" value="/feed#e`) {
|
||||||
|
t.Errorf("interaktions-knöpfe ohne feed-rücksprung:\n%s", excerpt(failPage, "vote"))
|
||||||
|
}
|
||||||
|
|
||||||
// Getippter Text überlebt einen abgelehnten Upload.
|
// Getippter Text überlebt einen abgelehnten Upload.
|
||||||
resp = postMultipartHTML(t, alice, srv.URL+"/entry/create",
|
resp = postMultipartHTML(t, alice, srv.URL+"/entry/create",
|
||||||
@@ -1367,6 +1461,10 @@ func TestThreadOhneJavaScript(t *testing.T) {
|
|||||||
if !strings.Contains(page, fmt.Sprintf(`name="reply_to" value="%d"`, mitte)) {
|
if !strings.Contains(page, fmt.Sprintf(`name="reply_to" value="%d"`, mitte)) {
|
||||||
t.Fatalf("antwortformular ohne reply_to:\n%s", excerpt(page, "compose"))
|
t.Fatalf("antwortformular ohne reply_to:\n%s", excerpt(page, "compose"))
|
||||||
}
|
}
|
||||||
|
// Das Antwortformular trägt den Rücksprung in den Thread mit.
|
||||||
|
if !strings.Contains(page, fmt.Sprintf(`name="return_to" value="/beitrag/%d"`, mitte)) {
|
||||||
|
t.Errorf("antwortformular ohne return_to:\n%s", excerpt(page, "compose"))
|
||||||
|
}
|
||||||
|
|
||||||
// Antworten ohne JS -> 303 in den Thread des Elternbeitrags.
|
// Antworten ohne JS -> 303 in den Thread des Elternbeitrags.
|
||||||
resp := postFormHTML(t, alice, srv.URL+"/entry/create",
|
resp := postFormHTML(t, alice, srv.URL+"/entry/create",
|
||||||
@@ -1390,9 +1488,11 @@ func TestThreadOhneJavaScript(t *testing.T) {
|
|||||||
t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor)
|
t.Errorf("anker %q aus dem redirect existiert nicht in der seite", anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehlerfall: leere Antwort -> Thread neu, nicht der Feed.
|
// Fehlerfall: leere Antwort -> Thread neu, nicht der Feed. return_to wie
|
||||||
|
// vom Formular mitgeschickt.
|
||||||
resp = postFormHTML(t, alice, srv.URL+"/entry/create",
|
resp = postFormHTML(t, alice, srv.URL+"/entry/create",
|
||||||
url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)}})
|
url.Values{"content": {" "}, "reply_to": {fmt.Sprint(mitte)},
|
||||||
|
"return_to": {fmt.Sprintf("/beitrag/%d", mitte)}})
|
||||||
failPage := body(t, resp)
|
failPage := body(t, resp)
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
t.Fatalf("leere antwort: erwartet 400, bekam %d", resp.StatusCode)
|
t.Fatalf("leere antwort: erwartet 400, bekam %d", resp.StatusCode)
|
||||||
@@ -1403,6 +1503,14 @@ func TestThreadOhneJavaScript(t *testing.T) {
|
|||||||
if !strings.Contains(failPage, "mittlere antwort") {
|
if !strings.Contains(failPage, "mittlere antwort") {
|
||||||
t.Errorf("thread nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "focus"))
|
t.Errorf("thread nach fehler nicht mitgerendert:\n%s", excerpt(failPage, "focus"))
|
||||||
}
|
}
|
||||||
|
// Die neu gerenderten Interaktions-Knöpfe zeigen zurück in den Thread,
|
||||||
|
// nicht auf "/".
|
||||||
|
if strings.Contains(failPage, `name="return_to" value="/#e`) {
|
||||||
|
t.Errorf("interaktions-knöpfe zeigen nach dem fehler auf \"/\":\n%s", excerpt(failPage, "vote"))
|
||||||
|
}
|
||||||
|
if !strings.Contains(failPage, fmt.Sprintf(`name="return_to" value="/beitrag/%d#e`, mitte)) {
|
||||||
|
t.Errorf("interaktions-knöpfe ohne thread-rücksprung:\n%s", excerpt(failPage, "vote"))
|
||||||
|
}
|
||||||
|
|
||||||
// Unbekannter Beitrag -> 404, keine leere Seite.
|
// Unbekannter Beitrag -> 404, keine leere Seite.
|
||||||
if status, _ := getHTML(t, anon, srv.URL+"/beitrag/999999"); status != http.StatusNotFound {
|
if status, _ := getHTML(t, anon, srv.URL+"/beitrag/999999"); status != http.StatusNotFound {
|
||||||
|
|||||||
@@ -496,11 +496,20 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
|
|||||||
if remaining < 0 {
|
if remaining < 0 {
|
||||||
remaining = 0
|
remaining = 0
|
||||||
}
|
}
|
||||||
// Bewusst kein Taxonomie-Fehler: die Antwort trägt Nutzdaten
|
// Ohne JavaScript: der Cooldown ist eine eigene Fehlerseite mit der
|
||||||
// (retry_after, bump_count), die das Frontend zum Sperren des Buttons
|
// Restzeit -- ein stiller Rücksprung wäre von "hat geklappt" nicht zu
|
||||||
// braucht -- ein reiner Fehlerkörper kann die nicht transportieren.
|
// unterscheiden.
|
||||||
// Der Cooldown ist auch kein Fehlerfall, sondern ein regulärer
|
if !wantsJSON(r) {
|
||||||
// Ausgang des Bumpens.
|
hours := (remaining + 3599) / 3600
|
||||||
|
if hours < 1 {
|
||||||
|
hours = 1
|
||||||
|
}
|
||||||
|
return RateLimited("entry.bump_cooldown", fmt.Sprintf(
|
||||||
|
"Dieser Beitrag wurde erst kürzlich repostet. Nächster Repost in etwa %d Stunde(n).", hours))
|
||||||
|
}
|
||||||
|
// JSON-Zweig: bewusst kein Taxonomie-Fehler -- die Antwort trägt
|
||||||
|
// Nutzdaten (retry_after, bump_count), die das Frontend zum Sperren des
|
||||||
|
// Buttons braucht, und der Cooldown ist ein regulärer Ausgang.
|
||||||
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
||||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||||
"error": apiErrorBody{
|
"error": apiErrorBody{
|
||||||
@@ -512,6 +521,13 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
|
|||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ohne JavaScript zurück an den Beitrag, auf dem repostet wurde -- der
|
||||||
|
// Anker in return_to hält die Position.
|
||||||
|
if !wantsJSON(r) {
|
||||||
|
redirectBack(w, r, threadAnchor(pid))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"bump_count": bumpCount,
|
"bump_count": bumpCount,
|
||||||
|
|||||||
+44
-4
@@ -80,6 +80,31 @@ type entryView struct {
|
|||||||
DeleteURL string
|
DeleteURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bumpRemaining ist die Restsperre des Reposts in Sekunden (0 = wieder
|
||||||
|
// möglich). Spiegelt die Bedingung im UPDATE von handleBump; die Vorlage
|
||||||
|
// braucht den Wert, um den Repost-Knopf zu sperren und die Wartezeit
|
||||||
|
// anzuzeigen -- das Gegenstück zu setupBump in web/js/feed.js.
|
||||||
|
func (v entryView) bumpRemaining() int64 {
|
||||||
|
rem := v.LastBump + v.BumpCount*bumpStep - time.Now().Unix()
|
||||||
|
if rem < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return rem
|
||||||
|
}
|
||||||
|
|
||||||
|
// BumpLocked meldet, ob der Repost dieses Beitrags gerade im Cooldown steckt.
|
||||||
|
func (v entryView) BumpLocked() bool { return v.bumpRemaining() > 0 }
|
||||||
|
|
||||||
|
// BumpWaitHours ist die Restsperre auf volle Stunden aufgerundet (mindestens
|
||||||
|
// 1, solange überhaupt Zeit übrig ist) -- wie die Anzeige im JS-Frontend.
|
||||||
|
func (v entryView) BumpWaitHours() int64 {
|
||||||
|
h := (v.bumpRemaining() + 3599) / 3600
|
||||||
|
if h < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
// composeForm hält die Eingaben des Beitragsformulars über einen
|
// composeForm hält die Eingaben des Beitragsformulars über einen
|
||||||
// fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei
|
// fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei
|
||||||
// zwangsläufig: ein <input type="file"> lässt sich aus Sicherheitsgründen
|
// zwangsläufig: ein <input type="file"> lässt sich aus Sicherheitsgründen
|
||||||
@@ -97,6 +122,13 @@ type feedView struct {
|
|||||||
PrevURL string
|
PrevURL string
|
||||||
NextURL string
|
NextURL string
|
||||||
|
|
||||||
|
// ReturnTo ist der Rücksprungort dieser Seite (beim GET die Seite selbst).
|
||||||
|
// Das Beitragsformular trägt ihn als verstecktes Feld, damit er einen
|
||||||
|
// abgelehnten Post übersteht -- sonst fiele das neu gerenderte Formular
|
||||||
|
// samt seiner Interaktions-Knöpfe auf den Default "/" zurück (die noch per
|
||||||
|
// JS bediente Startseite) statt auf "/feed".
|
||||||
|
ReturnTo string
|
||||||
|
|
||||||
// CanPost steuert, ob das Beitragsformular erscheint. Ohne Anmeldung gibt
|
// CanPost steuert, ob das Beitragsformular erscheint. Ohne Anmeldung gibt
|
||||||
// es nichts zu schreiben; über TOR wird das Schreiben abgelehnt (blockTOR),
|
// es nichts zu schreiben; über TOR wird das Schreiben abgelehnt (blockTOR),
|
||||||
// und ein Formular, das beim Absenden immer scheitert, wäre schlechter als
|
// und ein Formular, das beim Absenden immer scheitert, wäre schlechter als
|
||||||
@@ -178,11 +210,13 @@ func buildFeedView(r *http.Request, uid int64, rootsOnly bool) (feedView, error)
|
|||||||
return feedView{}, Internal(err)
|
return feedView{}, Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
back := returnTo(r)
|
||||||
v := feedView{
|
v := feedView{
|
||||||
Entries: toEntryViews(entries, tallies, returnTo(r), viewer),
|
Entries: toEntryViews(entries, tallies, back, viewer),
|
||||||
Page: page,
|
Page: page,
|
||||||
Pages: pages,
|
Pages: pages,
|
||||||
IsTOR: isTOR(r),
|
IsTOR: isTOR(r),
|
||||||
|
ReturnTo: back,
|
||||||
}
|
}
|
||||||
v.CanPost = viewer != 0 && !v.IsTOR
|
v.CanPost = viewer != 0 && !v.IsTOR
|
||||||
if page > 0 {
|
if page > 0 {
|
||||||
@@ -203,6 +237,11 @@ type threadView struct {
|
|||||||
CanPost bool
|
CanPost bool
|
||||||
IsTOR bool
|
IsTOR bool
|
||||||
Compose composeForm
|
Compose composeForm
|
||||||
|
|
||||||
|
// ReturnTo wie bei feedView: das Antwortformular trägt ihn versteckt mit,
|
||||||
|
// damit eine abgelehnte Antwort die Interaktions-Knöpfe nicht auf "/"
|
||||||
|
// zurückwirft statt auf den Thread.
|
||||||
|
ReturnTo string
|
||||||
}
|
}
|
||||||
|
|
||||||
// toEntryViews reichert Beiträge mit Abstimmungsstand, Rücksprungziel und
|
// toEntryViews reichert Beiträge mit Abstimmungsstand, Rücksprungziel und
|
||||||
@@ -272,6 +311,7 @@ func buildThreadView(r *http.Request, pid int64) (threadView, error) {
|
|||||||
Entry: toEntryViews([]Entry{t.Entry}, tallies, back, viewer)[0],
|
Entry: toEntryViews([]Entry{t.Entry}, tallies, back, viewer)[0],
|
||||||
Replies: toEntryViews(t.Replies, tallies, back, viewer),
|
Replies: toEntryViews(t.Replies, tallies, back, viewer),
|
||||||
IsTOR: isTOR(r),
|
IsTOR: isTOR(r),
|
||||||
|
ReturnTo: back,
|
||||||
}
|
}
|
||||||
// Auf einen gelöschten Beitrag lässt sich nicht antworten -- er hat keinen
|
// Auf einen gelöschten Beitrag lässt sich nicht antworten -- er hat keinen
|
||||||
// Autor mehr und der Platzhalter soll den Thread nur zusammenhalten.
|
// Autor mehr und der Platzhalter soll den Thread nur zusammenhalten.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB |
@@ -51,6 +51,14 @@
|
|||||||
<button class="ghost{{if eq .Selected "right"}} selected{{end}}"
|
<button class="ghost{{if eq .Selected "right"}} selected{{end}}"
|
||||||
name="mode" value="right">Rechts</button>
|
name="mode" value="right">Rechts</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{/* Repost hebt den Beitrag im Feed wieder nach oben. Der Cooldown
|
||||||
|
sitzt am Beitrag (global) und wächst pro Repost; ist er aktiv,
|
||||||
|
ist der Knopf gesperrt und die Restzeit steht daneben. */}}
|
||||||
|
<form method="post" action="/entry/{{.PID}}/bump">
|
||||||
|
<input type="hidden" name="return_to" value="{{.ReturnTo}}#e{{.PID}}">
|
||||||
|
<button class="bump" type="submit"{{if .BumpLocked}} disabled{{end}}>Repost ({{.BumpCount}})</button>
|
||||||
|
{{if .BumpLocked}}<span class="muted"> · in {{.BumpWaitHours}}h</span>{{end}}
|
||||||
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{/* Ohne Anmeldung nur der Stand -- ein Formular hier würde
|
{{/* Ohne Anmeldung nur der Stand -- ein Formular hier würde
|
||||||
ausschließlich 401 liefern. */}}
|
ausschließlich 401 liefern. */}}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
statt der Datei -- ohne JavaScript gibt es keinen zweiten Versuch. */}}
|
statt der Datei -- ohne JavaScript gibt es keinen zweiten Versuch. */}}
|
||||||
<form method="post" action="/entry/create" enctype="multipart/form-data">
|
<form method="post" action="/entry/create" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="page" value="{{.Page}}">
|
<input type="hidden" name="page" value="{{.Page}}">
|
||||||
|
{{/* return_to überlebt einen abgelehnten Post: sonst rendert failCompose
|
||||||
|
den Feed mit Interaktions-Knöpfen, die auf "/" statt "/feed" zeigen. */}}
|
||||||
|
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||||
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
||||||
<label for="content">Neuer Beitrag</label>
|
<label for="content">Neuer Beitrag</label>
|
||||||
<textarea id="content" name="content" rows="4" maxlength="1000"
|
<textarea id="content" name="content" rows="4" maxlength="1000"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<section id="compose">
|
<section id="compose">
|
||||||
<form method="post" action="/entry/create" enctype="multipart/form-data">
|
<form method="post" action="/entry/create" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="reply_to" value="{{.Entry.PID}}">
|
<input type="hidden" name="reply_to" value="{{.Entry.PID}}">
|
||||||
|
{{/* return_to überlebt eine abgelehnte Antwort: sonst zeigen die neu
|
||||||
|
gerenderten Interaktions-Knöpfe auf "/" statt auf den Thread. */}}
|
||||||
|
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||||
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
||||||
<label for="content">Antworten</label>
|
<label for="content">Antworten</label>
|
||||||
<textarea id="content" name="content" rows="4" maxlength="1000"
|
<textarea id="content" name="content" rows="4" maxlength="1000"
|
||||||
|
|||||||
+1
-2
@@ -78,8 +78,7 @@ registerForm.addEventListener("submit", async (e) => {
|
|||||||
const feed = createFeed(
|
const feed = createFeed(
|
||||||
document.getElementById("feed"),
|
document.getElementById("feed"),
|
||||||
document.getElementById("sentinel"),
|
document.getElementById("sentinel"),
|
||||||
(page) => `/entry/feed/${page}`,
|
(page) => `/entry/feed/${page}`
|
||||||
{ adEvery: 5 }
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Neuer Beitrag ---
|
// --- Neuer Beitrag ---
|
||||||
|
|||||||
+3
-31
@@ -261,35 +261,12 @@ function setupReport(el, pid) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderAd baut einen In-Feed-Werbeslot: das Banner (selbst gehostet, sonst
|
|
||||||
// würde die CSP default-src 'self' es blocken), darunter der Pflicht-Hinweis
|
|
||||||
// "Werbung" und der Link zur Eigenvermarktung. rel="sponsored nofollow" macht
|
|
||||||
// den bezahlten Charakter für Suchmaschinen kenntlich.
|
|
||||||
function renderAd() {
|
|
||||||
const el = document.createElement("aside");
|
|
||||||
el.className = "ad";
|
|
||||||
el.innerHTML = `
|
|
||||||
<a class="ad-banner" href="/werben" rel="sponsored nofollow noopener">
|
|
||||||
<img src="/static/ads/placeholder.png" alt="Werbung" loading="lazy">
|
|
||||||
</a>
|
|
||||||
<div class="ad-meta muted">
|
|
||||||
<span>Werbung</span>
|
|
||||||
<a href="/werben">Auf dem Kontrollverlust werben</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
|
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
|
||||||
// urlFor(page). opts.adEvery (>0) blendet nach je N Beiträgen einen
|
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
|
||||||
// Werbebanner ein — nur im Top-Level-Feed, nicht in User-/Thread-Feeds.
|
function createFeed(feedEl, sentinelEl, urlFor) {
|
||||||
// Gibt { reset } zurück, um den Feed neu zu laden.
|
|
||||||
function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
|
|
||||||
const adEvery = opts.adEvery || 0;
|
|
||||||
let page = 0;
|
let page = 0;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let done = false;
|
let done = false;
|
||||||
let shown = 0; // fortlaufende Beitragszahl über Seitengrenzen hinweg
|
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading || done) return;
|
if (loading || done) return;
|
||||||
@@ -303,11 +280,7 @@ function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
|
|||||||
end.textContent = "YOU REACHED THE END!";
|
end.textContent = "YOU REACHED THE END!";
|
||||||
feedEl.appendChild(end);
|
feedEl.appendChild(end);
|
||||||
} else {
|
} else {
|
||||||
entries.forEach((e) => {
|
entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
|
||||||
feedEl.appendChild(renderEntry(e));
|
|
||||||
shown++;
|
|
||||||
if (adEvery && shown % adEvery === 0) feedEl.appendChild(renderAd());
|
|
||||||
});
|
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -326,7 +299,6 @@ function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
|
|||||||
function reset() {
|
function reset() {
|
||||||
page = 0;
|
page = 0;
|
||||||
done = false;
|
done = false;
|
||||||
shown = 0;
|
|
||||||
feedEl.innerHTML = "";
|
feedEl.innerHTML = "";
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user