diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx
index 53a8418..bf1813f 100644
--- a/frontend/src/Layout.tsx
+++ b/frontend/src/Layout.tsx
@@ -1,4 +1,6 @@
import { Link, Outlet, useLocation } from "react-router";
+import { api } from "./api";
+import { useAuthChanged, useMe } from "./auth";
// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
// Version).
@@ -30,7 +32,7 @@ export function Layout() {
Ohne Datenverlust seit {plural(days, "Tag", "Tagen")}
-
+
>
) : (
@@ -43,3 +45,22 @@ export function Layout() {
>
);
}
+
+// Headerbar der Startseite: angemeldet Profil-Link und Logout, sonst leer.
+function Headerbar() {
+ const me = useMe().data;
+ const authChanged = useAuthChanged();
+ if (!me) return ;
+
+ // POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
+ const logout = async () => {
+ await api.post("/auth/logout").catch(() => undefined);
+ await authChanged();
+ };
+ return (
+
+ );
+}
diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts
index 9e4c541..bd15319 100644
--- a/frontend/src/auth.ts
+++ b/frontend/src/auth.ts
@@ -1,4 +1,4 @@
-import { useQuery } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "./api";
import type { Me } from "./types";
@@ -19,3 +19,15 @@ export function useMe() {
},
});
}
+
+// useAuthChanged liefert die Aktion nach Login/Logout: alles, was vom
+// Anmeldestatus abhängt, neu laden -- das eigene Konto und die eigene Auswahl
+// bei den Votes.
+export function useAuthChanged() {
+ const qc = useQueryClient();
+ return () =>
+ Promise.all([
+ qc.invalidateQueries({ queryKey: meKey }),
+ qc.invalidateQueries({ queryKey: ["votes"] }),
+ ]);
+}
diff --git a/frontend/src/components/AuthBox.tsx b/frontend/src/components/AuthBox.tsx
new file mode 100644
index 0000000..49c233f
--- /dev/null
+++ b/frontend/src/components/AuthBox.tsx
@@ -0,0 +1,73 @@
+import { useState, type FormEvent, type MouseEvent } from "react";
+import { api, errMsg } from "../api";
+import { useAuthChanged } from "../auth";
+
+// AuthBox: Login und Registrierung im Wechsel. Die {" "} bilden die
+// Leerzeichen des alten Markups nach -- Überschrift, Inputs und Knöpfe sind
+// inline, der Abstand zwischen ihnen ist also sichtbar.
+export function AuthBox() {
+ const [mode, setMode] = useState<"login" | "register">("login");
+ const [msg, setMsg] = useState("");
+ const [busy, setBusy] = useState(false);
+ const authChanged = useAuthChanged();
+
+ const show = (which: "login" | "register") => (e: MouseEvent) => {
+ e.preventDefault();
+ setMode(which);
+ setMsg("");
+ };
+
+ // submit sperrt das Formular für die Dauer des Requests und zeigt einen
+ // Fehler als Meldung; action wirft bei Misserfolg (ApiError).
+ const submit = (action: (fd: FormData) => Promise, fallback: string) =>
+ async (e: FormEvent) => {
+ e.preventDefault();
+ setBusy(true);
+ try {
+ await action(new FormData(e.currentTarget));
+ setMsg("");
+ await authChanged();
+ } catch (err) {
+ setMsg(errMsg(err, fallback));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const login = submit((fd) => api.post("/auth/login", fd), "Login fehlgeschlagen.");
+
+ // Nach der Registrierung direkt mit denselben Daten einloggen, damit der
+ // Nutzer gleich drin ist. newuser meldet alle Feldfehler auf einmal.
+ const register = submit(async (fd) => {
+ await api.post("/auth/newuser", fd);
+ const creds = new FormData();
+ creds.append("user", String(fd.get("user")));
+ creds.append("pass", String(fd.get("pass1")));
+ creds.append("timeout", "86400");
+ await api.post("/auth/login", creds);
+ }, "Registrierung fehlgeschlagen.");
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Compose.tsx b/frontend/src/components/Compose.tsx
new file mode 100644
index 0000000..5f23cd6
--- /dev/null
+++ b/frontend/src/components/Compose.tsx
@@ -0,0 +1,45 @@
+import { useState, type FormEvent } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { api, errMsg } from "../api";
+import { pasteImage } from "../paste";
+import type { CreatedEntry } from "../types";
+
+// Compose: neuer Beitrag. Nach Erfolg springt der Feed zurück auf Seite 0,
+// damit der eigene Beitrag oben steht.
+export function Compose() {
+ const qc = useQueryClient();
+ const [pasteMsg, setPasteMsg] = useState("");
+ const create = useMutation({
+ mutationFn: (fd: FormData) => api.post("/entry/create", fd),
+ });
+
+ const onSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ const form = e.currentTarget;
+ create.mutate(new FormData(form), {
+ onSuccess: () => {
+ form.reset();
+ setPasteMsg("");
+ qc.resetQueries({ queryKey: ["feed"] });
+ },
+ });
+ };
+
+ return (
+
+
+ {create.isError ? errMsg(create.error, "Beitrag fehlgeschlagen.") : ""}
+
+ );
+}
diff --git a/frontend/src/pages/FeedPage.tsx b/frontend/src/pages/FeedPage.tsx
index 9658aca..ece080c 100644
--- a/frontend/src/pages/FeedPage.tsx
+++ b/frontend/src/pages/FeedPage.tsx
@@ -1,6 +1,16 @@
+import { useMe } from "../auth";
+import { AuthBox } from "../components/AuthBox";
+import { Compose } from "../components/Compose";
import { Feed } from "../components/Feed";
-// Login/Registrierung und das Formular für neue Beiträge folgen in Schritt 4.
+// Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen --
+// sonst blitzt bei Angemeldeten kurz die Login-Box auf.
export function FeedPage() {
- return `/entry/feed/${page}`} />;
+ const me = useMe();
+ return (
+ <>
+ {me.isSuccess && (me.data ? : )}
+ `/entry/feed/${page}`} />
+ >
+ );
}