React-Frontend: Thread-Ansicht mit Antworten, Bearbeiten, Löschen

- EntryPage: Vorfahren, Fokus, Antworten mit unverändertem Markup
- Bearbeiten/Löschen für eigene Beiträge, Antwortformular mit
  Zwischenablage-Bild
- useMe(): eigenes Konto oder null (401 = nicht angemeldet)
- nach Änderungen Thread und Feeds invalidieren

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-26 23:34:31 +02:00
co-authored by Claude Opus 5.5
parent bd5c59bcad
commit b82c432bce
3 changed files with 200 additions and 2 deletions
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import { ApiError, api } from "./api";
import type { Me } from "./types";
export const meKey = ["me"];
// useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401
// ist hier kein Fehler, sondern die Antwort "nicht angemeldet".
export function useMe() {
return useQuery({
queryKey: meKey,
queryFn: async () => {
try {
return await api.get<Me>("/user/info");
} catch (err) {
if (err instanceof ApiError && err.status === 401) return null;
throw err;
}
},
});
}
+151 -2
View File
@@ -1,4 +1,153 @@
// Platzhalter -- wird im jeweiligen Umbauschritt gefüllt.
import { useState, type FormEvent } from "react";
import { useParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { useMe } from "../auth";
import { pasteImage } from "../paste";
import { EntryCard } from "../components/EntryCard";
import type { CreatedEntry, Entry, Thread } from "../types";
// Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung,
// Meldungen) -- deshalb hängt die Seite per key an der pid.
export function EntryPage() {
return null;
const { pid = "" } = useParams();
return <ThreadView key={pid} pid={pid} />;
}
function ThreadView({ pid }: { pid: string }) {
const qc = useQueryClient();
const thread = useQuery({
queryKey: ["thread", pid],
queryFn: () => api.get<Thread>(`/entry/${pid}/thread`),
});
const me = useMe().data;
// Nach eigenen Änderungen den Thread und alle Feeds (Antwortzahl, Inhalt)
// neu laden.
const refresh = () => {
qc.invalidateQueries({ queryKey: ["thread"] });
qc.invalidateQueries({ queryKey: ["feed"] });
};
if (thread.isError) {
return (
<>
<section id="ancestors"></section>
<div id="focus">{errMsg(thread.error, "Beitrag konnte nicht geladen werden.")}</div>
</>
);
}
const data = thread.data;
const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted;
return (
<>
<section id="ancestors">
{data?.ancestors.map((a) => <EntryCard key={a.pid} entry={a} />)}
</section>
<div id="focus">
{data && <EntryCard entry={data.entry} />}
{data && isOwner && <OwnerActions entry={data.entry} onChange={refresh} />}
</div>
<section id="reply-box" hidden={!me}>
<ReplyForm pid={pid} onDone={refresh} />
</section>
<section id="replies">
{data && !data.replies.length && <p className="muted">Noch keine Antworten.</p>}
{data?.replies.map((e) => <EntryCard key={e.pid} entry={e} />)}
</section>
</>
);
}
// OwnerActions: Bearbeiten und Löschen für den eigenen, nicht gelöschten Beitrag.
// Die {" "} bilden die Leerzeichen des alten Markups nach -- Inputs und Knöpfe
// sind inline, der Abstand zwischen ihnen ist also sichtbar.
function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void }) {
const [editing, setEditing] = useState(false);
const edit = useMutation({
mutationFn: (fd: FormData) => api.post(`/entry/${entry.pid}/edit`, fd),
onSuccess: () => {
setEditing(false);
onChange();
},
});
const del = useMutation({
mutationFn: () => api.post(`/entry/${entry.pid}/delete`),
onSuccess: onChange,
onError: (err) => alert(errMsg(err, "Löschen fehlgeschlagen.")),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
edit.mutate(new FormData(e.currentTarget));
};
const cancel = () => {
edit.reset();
setEditing(false);
};
return (
<>
<button disabled={editing} onClick={() => setEditing(true)}>Bearbeiten</button>
<button
onClick={() => {
if (confirm("Beitrag wirklich löschen?")) del.mutate();
}}
>
Löschen
</button>
{editing && (
<form onSubmit={onSubmit}>
<textarea name="content" rows={4} maxLength={1000} defaultValue={entry.content}></textarea>{" "}
<button type="submit">Speichern</button>{" "}
<button type="button" className="cancel" onClick={cancel}>Abbrechen</button>{" "}
<p className="msg">{edit.isError ? errMsg(edit.error, "Bearbeiten fehlgeschlagen.") : ""}</p>
</form>
)}
</>
);
}
function ReplyForm({ pid, onDone }: { pid: string; onDone: () => void }) {
const [pasteMsg, setPasteMsg] = useState("");
const reply = useMutation({
mutationFn: (fd: FormData) => api.post<CreatedEntry>("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const fd = new FormData(form);
fd.append("reply_to", pid);
reply.mutate(fd, {
onSuccess: () => {
form.reset();
setPasteMsg("");
onDone();
},
});
};
return (
<>
<form
id="reply-form"
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={3} maxLength={1000} placeholder="Antworten…"></textarea>{" "}
<p className="muted" id="reply-paste-msg">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={reply.isPending}>Antworten</button>
</form>
<p className="msg" id="reply-msg">{reply.isError ? errMsg(reply.error, "Antwort fehlgeschlagen.") : ""}</p>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ClipboardEvent } from "react";
// pasteImage erlaubt Strg+V eines Bildes aus der Zwischenablage direkt im
// Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich
// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir
// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn
// wir wirklich ein Bild gegriffen haben. Gibt true zurück, wenn ein Bild
// übernommen wurde.
export function pasteImage(e: ClipboardEvent<HTMLFormElement>): boolean {
const item = [...(e.clipboardData?.items ?? [])].find(
(it) => it.kind === "file" && it.type.startsWith("image/"),
);
const original = item?.getAsFile();
if (!original) return false;
e.preventDefault();
// Browser benennen Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) ->
// neu verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
const ext = (original.type.split("/")[1] || "png").toLowerCase();
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
const input = e.currentTarget.querySelector<HTMLInputElement>("input[name='file']");
if (!input) return false;
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
return true;
}