diff --git a/endpoints_test.go b/endpoints_test.go index 2b895d5..60e4152 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -568,9 +568,6 @@ func TestReport(t *testing.T) { return n } - if got := report(newClient(t), pid, ""); got != http.StatusUnauthorized { - t.Fatalf("anon report: erwartet 401, bekam %d", got) - } if got := report(bob, pid, " Spam "); got != http.StatusOK { t.Fatalf("report: erwartet 200, bekam %d", got) } @@ -580,11 +577,16 @@ func TestReport(t *testing.T) { t.Fatalf("grund: %q", reason) } - // Erneut melden legt keine zweite offene Meldung an, ein anderer Nutzer schon. + // Erneut melden legt keine zweite offene Meldung an, ein anderer Nutzer + // schon. Anonyme Meldungen (uid 0) sind nicht unterscheidbar und zählen alle. report(bob, pid, "") report(alice, pid, "") - if n := openReports(); n != 2 { - t.Fatalf("offene meldungen: erwartet 2, bekam %d", n) + report(newClient(t), pid, "") + if got := report(newClient(t), pid, ""); got != http.StatusOK { + t.Fatalf("anon report: erwartet 200, bekam %d", got) + } + if n := openReports(); n != 4 { + t.Fatalf("offene meldungen: erwartet 4, bekam %d", n) } if got := report(bob, 999999, ""); got != http.StatusNotFound { diff --git a/entry.go b/entry.go index da3e38c..123a2b8 100644 --- a/entry.go +++ b/entry.go @@ -441,8 +441,9 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error { return nil } -// handleReport meldet einen Beitrag, optional mit Grund. Wer denselben Beitrag -// erneut meldet, solange die Meldung offen ist, legt keine zweite an. +// handleReport meldet einen Beitrag, optional mit Grund. Melden darf jeder; +// ohne Session ist der Melder anonym (uid 0). Angemeldete legen für denselben +// Beitrag keine zweite Meldung an, solange ihre erste offen ist. func handleReport(w http.ResponseWriter, r *http.Request) error { pid, err := pidParam(r) if err != nil { @@ -453,12 +454,16 @@ func handleReport(w http.ResponseWriter, r *http.Request) error { return errEntryNotFound } reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500) + var uid int64 + if s, ok := getSession(r); ok { + uid = s.UID + } if _, err := db.Exec( `INSERT INTO report (pid, uid, reason, created_at) SELECT $1, $2, $3, $4 - WHERE NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`, - pid, uidFromContext(r.Context()), reason, time.Now().Unix(), + WHERE $2::bigint = 0 OR NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`, + pid, uid, reason, time.Now().Unix(), ); err != nil { return Internal(err) } diff --git a/frontend/src/app.css b/frontend/src/app.css index ef11d7a..895802a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -235,6 +235,9 @@ img.avatar-lg { width: 72px; height: 72px; } auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */ .byline span { font-size: 1rem; } +/* Melden steht rechts in der Byline. */ +.byline .report { margin-left: auto; } + /* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */ .profile-head { display: flex; diff --git a/frontend/src/components/EntryCard.tsx b/frontend/src/components/EntryCard.tsx index 29acf20..51451ac 100644 --- a/frontend/src/components/EntryCard.tsx +++ b/frontend/src/components/EntryCard.tsx @@ -4,13 +4,15 @@ import type { Entry } from "../types"; import { avatarUrl, formatTime, linkify } from "../format"; import { Votes } from "./Votes"; import { useBump } from "./Bump"; +import { useReport } from "./Report"; +import { useMe } from "../auth"; -// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link -// oder Knopf darin. +// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link, +// Knopf oder Formular darin. function useOpenOnClick(pid: number) { const navigate = useNavigate(); return (ev: MouseEvent) => { - if (!(ev.target as Element).closest("a, button")) navigate(`/e/${pid}`); + if (!(ev.target as Element).closest("a, button, form")) navigate(`/e/${pid}`); }; } @@ -37,6 +39,8 @@ function DeletedCard({ entry: e }: { entry: Entry }) { function LiveCard({ entry: e }: { entry: Entry }) { const open = useOpenOnClick(e.pid); const bump = useBump(e); + const report = useReport(e.pid); + const own = useMe().data?.uid === e.uid; const profile = `/u/${encodeURIComponent(e.username)}`; return ( @@ -49,10 +53,12 @@ function LiveCard({ entry: e }: { entry: Entry }) { {e.username} · {formatTime(e.created_at)} {bump.cooldown} + {!own && report.button}
+ {report.form}
{linkify(e.content)}
{e.filepath && }
diff --git a/frontend/src/components/Report.tsx b/frontend/src/components/Report.tsx new file mode 100644 index 0000000..c67dc0b --- /dev/null +++ b/frontend/src/components/Report.tsx @@ -0,0 +1,38 @@ +import { useState, type FormEvent } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { api, errMsg } from "../api"; + +// useReport hält das Melden eines Beitrags: der Knopf steht rechts in der +// Byline, das Formular für den optionalen Grund darunter im Beitrag -- +// deshalb ein Hook statt einer Komponente (wie useBump). Melden geht auch +// ohne Anmeldung, der Melder ist dann anonym. +export function useReport(pid: number) { + const [open, setOpen] = useState(false); + const report = useMutation({ + mutationFn: (fd: FormData) => api.post(`/entry/${pid}/report`, fd), + onSuccess: () => setOpen(false), + }); + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + report.mutate(new FormData(e.currentTarget)); + }; + const cancel = () => { + report.reset(); + setOpen(false); + }; + + return { + button: report.isSuccess + ? gemeldet + : , + form: open && ( +
+ {" "} + {" "} + {" "} +

{report.isError ? errMsg(report.error, "Melden fehlgeschlagen.") : ""}

+
+ ), + }; +} diff --git a/frontend/src/pages/EntryPage.tsx b/frontend/src/pages/EntryPage.tsx index 9b6e876..93b026b 100644 --- a/frontend/src/pages/EntryPage.tsx +++ b/frontend/src/pages/EntryPage.tsx @@ -38,8 +38,7 @@ function ThreadView({ pid }: { pid: string }) { ); } const data = thread.data; - const live = !!me && !!data && !data.entry.deleted; - const isOwner = live && me.uid === data.entry.uid; + const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted; return ( <> @@ -48,9 +47,7 @@ function ThreadView({ pid }: { pid: string }) {
{data && } - {live && (isOwner - ? - : )} + {data && isOwner && }