React-Frontend: Login, Registrierung, Header, neuer Beitrag

- AuthBox mit Login/Registrierung (Auto-Login nach Registrierung)
- Compose mit Zwischenablage-Bild, Feed springt danach an den Anfang
- Headerbar mit Profil-Link und Logout
- useAuthChanged: me und Votes nach Login/Logout neu laden
- kein Aufblitzen der Login-Box, solange der Anmeldestatus lädt

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:38:03 +02:00
co-authored by Claude Opus 5.5
parent b82c432bce
commit ca65ddcacc
5 changed files with 165 additions and 4 deletions
+22 -1
View File
@@ -1,4 +1,6 @@
import { Link, Outlet, useLocation } from "react-router"; 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 // Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
// Version). // Version).
@@ -30,7 +32,7 @@ export function Layout() {
<p id="stats" className="tagline"> <p id="stats" className="tagline">
Ohne Datenverlust seit {plural(days, "Tag", "Tagen")} Ohne Datenverlust seit {plural(days, "Tag", "Tagen")}
</p> </p>
<div id="headerbar"></div> <Headerbar />
</> </>
) : ( ) : (
<div id="headerbar"><Link className="button" to="/">zurück zum Feed</Link></div> <div id="headerbar"><Link className="button" to="/">zurück zum Feed</Link></div>
@@ -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 <div id="headerbar"></div>;
// 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 (
<div id="headerbar">
<Link className="button" to={`/u/${encodeURIComponent(me.username)}`}>mein Profil</Link>{" "}
<button id="logout-btn" onClick={logout}>Logout</button>
</div>
);
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "./api"; import { ApiError, api } from "./api";
import type { Me } from "./types"; 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"] }),
]);
}
+73
View File
@@ -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<unknown>, fallback: string) =>
async (e: FormEvent<HTMLFormElement>) => {
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 (
<section id="auth-box">
<form id="login-form" hidden={mode !== "login"} onSubmit={login}>
<h2>Login</h2>{" "}
<input name="user" placeholder="Nutzername" autoComplete="username" />{" "}
<input name="pass" type="password" placeholder="Passwort" autoComplete="current-password" />{" "}
<input name="timeout" type="hidden" value="86400" />{" "}
<button type="submit" disabled={busy}>Einloggen</button>{" "}
<p className="muted">Noch kein Konto? <a href="#" onClick={show("register")}>Registrieren</a></p>
</form>
<form id="register-form" hidden={mode !== "register"} onSubmit={register}>
<h2>Registrieren</h2>{" "}
<input name="user" placeholder="Nutzername" autoComplete="username" />{" "}
<input name="pass1" type="password" placeholder="Passwort" autoComplete="new-password" />{" "}
<input name="pass2" type="password" placeholder="Passwort wiederholen" autoComplete="new-password" />{" "}
<button type="submit" disabled={busy}>Konto erstellen</button>{" "}
<p className="muted">Schon registriert? <a href="#" onClick={show("login")}>Zum Login</a></p>
</form>
<p className="msg" id="auth-msg">{msg}</p>
</section>
);
}
+45
View File
@@ -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<CreatedEntry>("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
create.mutate(new FormData(form), {
onSuccess: () => {
form.reset();
setPasteMsg("");
qc.resetQueries({ queryKey: ["feed"] });
},
});
};
return (
<section id="compose">
<form
id="entry-form"
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={4} maxLength={1000} placeholder="Was gibt's Neues?"></textarea>{" "}
<p className="muted" id="paste-msg">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={create.isPending}>Eintrag erstellen</button>
</form>
<p className="msg" id="entry-msg">{create.isError ? errMsg(create.error, "Beitrag fehlgeschlagen.") : ""}</p>
</section>
);
}
+12 -2
View File
@@ -1,6 +1,16 @@
import { useMe } from "../auth";
import { AuthBox } from "../components/AuthBox";
import { Compose } from "../components/Compose";
import { Feed } from "../components/Feed"; 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() { export function FeedPage() {
return <Feed queryKey={["feed"]} urlFor={(page) => `/entry/feed/${page}`} />; const me = useMe();
return (
<>
{me.isSuccess && (me.data ? <Compose /> : <AuthBox />)}
<Feed queryKey={["feed"]} urlFor={(page) => `/entry/feed/${page}`} />
</>
);
} }