diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts new file mode 100644 index 0000000..9e4c541 --- /dev/null +++ b/frontend/src/auth.ts @@ -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("/user/info"); + } catch (err) { + if (err instanceof ApiError && err.status === 401) return null; + throw err; + } + }, + }); +} diff --git a/frontend/src/pages/EntryPage.tsx b/frontend/src/pages/EntryPage.tsx index b555a9d..183742d 100644 --- a/frontend/src/pages/EntryPage.tsx +++ b/frontend/src/pages/EntryPage.tsx @@ -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 ; +} + +function ThreadView({ pid }: { pid: string }) { + const qc = useQueryClient(); + const thread = useQuery({ + queryKey: ["thread", pid], + queryFn: () => api.get(`/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 ( + <> +
+
{errMsg(thread.error, "Beitrag konnte nicht geladen werden.")}
+ + ); + } + const data = thread.data; + const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted; + + return ( + <> +
+ {data?.ancestors.map((a) => )} +
+
+ {data && } + {data && isOwner && } +
+ + + +
+ {data && !data.replies.length &&

Noch keine Antworten.

} + {data?.replies.map((e) => )} +
+ + ); +} + +// 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) => { + e.preventDefault(); + edit.mutate(new FormData(e.currentTarget)); + }; + const cancel = () => { + edit.reset(); + setEditing(false); + }; + + return ( + <> + + + {editing && ( +
+ {" "} + {" "} + {" "} +

{edit.isError ? errMsg(edit.error, "Bearbeiten fehlgeschlagen.") : ""}

+
+ )} + + ); +} + +function ReplyForm({ pid, onDone }: { pid: string; onDone: () => void }) { + const [pasteMsg, setPasteMsg] = useState(""); + + const reply = useMutation({ + mutationFn: (fd: FormData) => api.post("/entry/create", fd), + }); + + const onSubmit = (e: FormEvent) => { + 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 ( + <> +
{ + if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen."); + }} + > + {" "} +

{pasteMsg}

{" "} + {" "} + +
+

{reply.isError ? errMsg(reply.error, "Antwort fehlgeschlagen.") : ""}

+ + ); } diff --git a/frontend/src/paste.ts b/frontend/src/paste.ts new file mode 100644 index 0000000..e6e7d33 --- /dev/null +++ b/frontend/src/paste.ts @@ -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): 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("input[name='file']"); + if (!input) return false; + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + return true; +}