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:
co-authored by
Claude Opus 5.5
parent
bd5c59bcad
commit
b82c432bce
@@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user