Melden rechts in der Byline jeder Karte, auch anonym

- Melden-Knopf in der Byline aller Beiträge (außer eigenen), Grund-Formular
  darunter; ersetzt den Knopf unter dem fokussierten Beitrag
- /api/entry/{pid}/report öffentlich, 10/min pro IP; ohne Session anonym
  (uid 0), Doppelmeldungen nur für Angemeldete unterdrückt
- Klicks in Formularen öffnen die Karte nicht mehr

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-29 18:05:54 +02:00
co-authored by Claude Opus 5.5
parent 80c333cd58
commit 3d2447fe35
8 changed files with 73 additions and 52 deletions
+3
View File
@@ -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;
+9 -3
View File
@@ -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 }) {
<Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
<span className="cooldown">{bump.cooldown}</span>
</span>
{!own && report.button}
</div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
{report.form}
<div className="content">{linkify(e.content)}</div>
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
<div className="interactions">
+38
View File
@@ -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<HTMLFormElement>) => {
e.preventDefault();
report.mutate(new FormData(e.currentTarget));
};
const cancel = () => {
report.reset();
setOpen(false);
};
return {
button: report.isSuccess
? <span className="report">gemeldet</span>
: <button className="report" type="button" disabled={open} onClick={() => setOpen(true)}>Melden</button>,
form: open && (
<form onSubmit={onSubmit}>
<textarea name="reason" rows={2} maxLength={500} placeholder="Grund (optional)"></textarea>{" "}
<button type="submit" disabled={report.isPending}>Melden</button>{" "}
<button type="button" onClick={cancel}>Abbrechen</button>{" "}
<p className="msg">{report.isError ? errMsg(report.error, "Melden fehlgeschlagen.") : ""}</p>
</form>
),
};
}
+2 -37
View File
@@ -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 }) {
</section>
<div id="focus">
{data && <EntryCard entry={data.entry} />}
{live && (isOwner
? <OwnerActions entry={data.entry} onChange={refresh} />
: <ReportAction pid={data.entry.pid} />)}
{data && isOwner && <OwnerActions entry={data.entry} onChange={refresh} />}
</div>
<section id="reply-box" hidden={!me}>
@@ -119,35 +116,3 @@ function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void
</>
);
}
// ReportAction: fremden Beitrag melden, der Grund ist optional.
function ReportAction({ pid }: { pid: number }) {
const [open, setOpen] = useState(false);
const report = useMutation({
mutationFn: (fd: FormData) => api.post(`/entry/${pid}/report`, fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
report.mutate(new FormData(e.currentTarget));
};
const cancel = () => {
report.reset();
setOpen(false);
};
if (report.isSuccess) return <p className="muted">Danke, der Beitrag wurde gemeldet.</p>;
return (
<>
<button disabled={open} onClick={() => setOpen(true)}>Melden</button>
{open && (
<form onSubmit={onSubmit}>
<textarea name="reason" rows={2} maxLength={500} placeholder="Grund (optional)"></textarea>{" "}
<button type="submit" disabled={report.isPending}>Melden</button>{" "}
<button type="button" onClick={cancel}>Abbrechen</button>{" "}
<p className="msg">{report.isError ? errMsg(report.error, "Melden fehlgeschlagen.") : ""}</p>
</form>
)}
</>
);
}