Code-Review: tote Stellen entfernt, Umwege vereinfacht

- SQLite-Import, migrate.sh, notes/migrations.md und altes jQuery-Skript entfernt
- Backend: scanEntry/queryEntries statt doppelter Scan-Liste, Feed-Queries
  direkt statt feedWhere, Thread ohne Map-Umweg, gemeinsame Helfer für
  Cookie-Löschen und Mediennamen, usernameTaken
- Frontend: PostForm für Beitrag und Antwort, EntryCard in Deleted/LiveCard
  aufgeteilt, tote CSS-Regeln und Typen entfernt, Strich-Regeln zusammengelegt
- Rechtstexte verlinken /stats; Kommentare ohne Vorgeschichte

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-27 15:03:22 +02:00
co-authored by Claude Opus 5.5
parent d1bd0f5f1d
commit df0cfec132
33 changed files with 235 additions and 933 deletions
+2 -3
View File
@@ -10,9 +10,8 @@ function plural(n: number, one: string, many: string) {
return `${n} ${n === 1 ? one : many}`;
}
// Layout ist der gemeinsame Seitenrahmen. Markup und IDs entsprechen dem alten
// Frontend (web/*.html), damit app.css unverändert greift: die Startseite hat
// Tagline und Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
export function Layout() {
const isHome = useLocation().pathname === "/";
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
+14 -45
View File
@@ -87,23 +87,6 @@ h2 {
color: black;
}
/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe,
gedämpft per opacity (die SVGs sind teils Strich-, teils Flächenzeichnungen,
eine gemeinsame Farbe gäbe es nicht -> Transparenz vereinheitlicht den Look).
aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
#sigils {
display: flex;
justify-content: center;
align-items: center;
gap: 1.2rem;
margin: 0.3rem 0;
}
#sigils img {
height: 50px;
width: auto;
}
#headerbar {
background-color: white;
text-align: center;
@@ -258,9 +241,13 @@ img.avatar-lg { width: 72px; height: 72px; }
Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der
Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum
Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem
rechten Avatarrand. Der .body füllt per flex:1 den Rest. */
rechten Avatarrand. Der .body füllt per flex:1 den Rest.
Dasselbe gilt für die Diskussionskette in Feed und Antworten (Discussion:
Beitrag, ggf. "k weitere Beiträge", jüngste Antwort) an allen Gliedern
außer dem letzten. */
#ancestors .threadline,
#replies .threadline {
#replies .threadline,
.chain > :not(:last-child) .threadline {
display: block;
/* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right
landet genau auf der Avatarmitte statt rechts daneben. */
@@ -268,16 +255,10 @@ img.avatar-lg { width: 72px; height: 72px; }
margin-right: calc(var(--avatar-sm) / 2);
}
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */
#ancestors .threadline { border-right: var(--line-w) solid gray; }
/* Diskussionskette in Feed und Antworten (Discussion): Beitrag, ggf.
"k weitere Beiträge", jüngste Antwort. Gleiches Bild wie die Ahnenkette --
Strich an allen Gliedern außer dem letzten. */
/* Strich nur, wo eine Kette ist: Vorfahren und Diskussionsketten. Direkte
Antworten ohne Kette sind nur eingerückt. */
#ancestors .threadline,
.chain > :not(:last-child) .threadline {
display: block;
flex: 0 0 calc(var(--avatar-sm) / 2 - var(--line-w));
margin-right: calc(var(--avatar-sm) / 2);
border-right: var(--line-w) solid gray;
}
@@ -299,7 +280,7 @@ img.avatar-lg { width: 72px; height: 72px; }
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
wandert in die Byline (siehe setupBump). */
wandert in die Byline (siehe useBump). */
.interactions {
display: flex;
align-items: center;
@@ -308,8 +289,7 @@ img.avatar-lg { width: 72px; height: 72px; }
margin-top: 0.25rem;
}
/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown
gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */
/* Repost-Button: im Cooldown gesperrt (gilt pro Beitrag für alle). */
.bump:disabled {
color: gray;
border-color: #ccc;
@@ -328,12 +308,6 @@ img.avatar-lg { width: 72px; height: 72px; }
.ghost.selected { font-weight: bold; }
.msg { color: red; }
p.warning {
background-color: #faa;
padding: 10px 15px;
font-weight: bold;
}
/* --- Formulare --- */
form {
background-color: white;
@@ -360,11 +334,6 @@ form input[type="file"] {
outline: none;
}
hr {
border: none;
border-top: 1px solid #ccc;
}
/* --- Statistik-Seite --- */
.statgrid {
display: flex;
@@ -381,8 +350,8 @@ hr {
line-height: 1.1;
}
/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS
gesetzt (CSSOM, kein Inline-style -> CSP-konform). */
/* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM
(kein Inline-style im HTML -> CSP-konform). */
.bars {
padding: 0.3rem 0.5rem 0.6rem;
}
@@ -414,7 +383,7 @@ hr {
font-size: 0.95rem;
}
/* ASN-Namen sind länger als Datum/Pfad -> mehr Platz, Umbruch statt Ellipse. */
/* ASN-Namen sind länger als ein Datum -> mehr Platz, Umbruch statt Ellipse. */
#stat-asns .bar-label {
flex-basis: 13rem;
white-space: normal;
+1 -1
View File
@@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "./api";
import type { Me } from "./types";
export const meKey = ["me"];
const meKey = ["me"];
// useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401
// ist hier kein Fehler, sondern die Antwort "nicht angemeldet".
+2 -3
View File
@@ -2,9 +2,8 @@ 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.
// AuthBox: Login und Registrierung im Wechsel. Die {" "} sind Absicht:
// Überschrift, Inputs und Knöpfe sind inline, der Abstand ist sichtbar.
export function AuthBox() {
const [mode, setMode] = useState<"login" | "register">("login");
const [msg, setMsg] = useState("");
+1 -1
View File
@@ -8,7 +8,7 @@ const nowSec = () => Math.floor(Date.now() / 1000);
// cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden
// aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
export function cooldownText(retryAfter: number): string {
function cooldownText(retryAfter: number): string {
return ` · Repost in ${Math.max(1, Math.ceil(retryAfter / 3600))}h`;
}
-45
View File
@@ -1,45 +0,0 @@
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>
);
}
+21 -21
View File
@@ -14,28 +14,28 @@ function useOpenOnClick(pid: number) {
};
}
export function EntryCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const when = formatTime(e.created_at);
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
return (
<article className="card" onClick={open}>
<div className="muted byline"><span>[deleted] · {when}</span></div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
<div className="content">[deleted]</div>
</div>
</div>
</article>
);
}
return <LiveCard entry={e} when={when} open={open} />;
export function EntryCard({ entry }: { entry: Entry }) {
return entry.deleted ? <DeletedCard entry={entry} /> : <LiveCard entry={entry} />;
}
function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: (ev: MouseEvent) => void }) {
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
function DeletedCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
return (
<article className="card" onClick={open}>
<div className="muted byline"><span>[deleted] · {formatTime(e.created_at)}</span></div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
<div className="content">[deleted]</div>
</div>
</div>
</article>
);
}
function LiveCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const bump = useBump(e);
const profile = `/u/${encodeURIComponent(e.username)}`;
@@ -46,7 +46,7 @@ function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open:
<img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" />
</Link>
<span>
<Link to={profile}>{e.username}</Link> · {when}
<Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
<span className="cooldown">{bump.cooldown}</span>
</span>
</div>
+2 -2
View File
@@ -10,8 +10,8 @@ const maxPage = 100;
// Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im
// Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den
// API-Pfad der Seite. as ist das Container-Element wie im alten Markup:
// <section> auf der Startseite, <div> innerhalb des Profils.
// API-Pfad der Seite. as ist das Container-Element: <section> auf der
// Startseite, <div> innerhalb des Profils.
export function Feed({ queryKey, urlFor, as: Container = "section" }: {
queryKey: unknown[];
urlFor: (page: number) => string;
+50
View File
@@ -0,0 +1,50 @@
import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { pasteImage } from "../paste";
// PostForm verfasst einen Beitrag, mit replyTo eine Antwort darauf.
export function PostForm({ replyTo, rows, placeholder, submitLabel, fallback, onDone }: {
replyTo?: string;
rows: number;
placeholder: string;
submitLabel: string;
fallback: string;
onDone: () => void;
}) {
const [pasteMsg, setPasteMsg] = useState("");
const create = useMutation({
mutationFn: (fd: FormData) => api.post("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const fd = new FormData(form);
if (replyTo) fd.append("reply_to", replyTo);
create.mutate(fd, {
onSuccess: () => {
form.reset();
setPasteMsg("");
onDone();
},
});
};
return (
<>
<form
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={rows} maxLength={1000} placeholder={placeholder}></textarea>{" "}
<p className="muted">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={create.isPending}>{submitLabel}</button>
</form>
<p className="msg">{create.isError ? errMsg(create.error, fallback) : ""}</p>
</>
);
}
+2 -3
View File
@@ -11,9 +11,8 @@ export function formatTime(unix: number): string {
const urlPattern = /https?:\/\/[^\s<]+/g;
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. React
// escaped den Text selbst -- anders als im alten Frontend gibt es hier kein
// innerHTML und damit keinen Escaping-Schritt, der vergessen werden könnte.
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. Den Text
// escaped React selbst.
export function linkify(text: string): ReactNode[] {
const out: ReactNode[] = [];
text.split("\n").forEach((line, i) => {
+10 -45
View File
@@ -3,9 +3,9 @@ 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 { Discussion, EntryCard } from "../components/EntryCard";
import type { CreatedEntry, Entry, Thread } from "../types";
import { PostForm } from "../components/PostForm";
import type { 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.
@@ -51,7 +51,14 @@ function ThreadView({ pid }: { pid: string }) {
</div>
<section id="reply-box" hidden={!me}>
<ReplyForm pid={pid} onDone={refresh} />
<PostForm
replyTo={pid}
rows={3}
placeholder="Antworten…"
submitLabel="Antworten"
fallback="Antwort fehlgeschlagen."
onDone={refresh}
/>
</section>
<section id="replies">
@@ -63,8 +70,6 @@ function ThreadView({ pid }: { pid: string }) {
}
// 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);
@@ -111,43 +116,3 @@ function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void
</>
);
}
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>
</>
);
}
+19 -1
View File
@@ -1,7 +1,8 @@
import { useQueryClient } from "@tanstack/react-query";
import { useMe } from "../auth";
import { AuthBox } from "../components/AuthBox";
import { Compose } from "../components/Compose";
import { Feed } from "../components/Feed";
import { PostForm } from "../components/PostForm";
// Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen --
// sonst blitzt bei Angemeldeten kurz die Login-Box auf.
@@ -14,3 +15,20 @@ export function FeedPage() {
</>
);
}
// Nach einem neuen Beitrag springt der Feed zurück auf Seite 0, damit er oben
// steht.
function Compose() {
const qc = useQueryClient();
return (
<section id="compose">
<PostForm
rows={4}
placeholder="Was gibt's Neues?"
submitLabel="Eintrag erstellen"
fallback="Beitrag fehlgeschlagen."
onDone={() => qc.resetQueries({ queryKey: ["feed"] })}
/>
</section>
);
}
+2 -3
View File
@@ -36,7 +36,7 @@ function ProfileView({ username }: { username: string }) {
<img
id="profile-avatar"
className="avatar avatar-lg"
src={info ? avatarUrl(info.avatar) + avatarBust : "/static/assets/no_profile_pic.jpg"}
src={avatarUrl(info?.avatar ?? "") + avatarBust}
alt=""
/>
<div>
@@ -74,8 +74,7 @@ function useFormAction<T>(path: string, fallback: string, onSuccess: (form: HTML
return { onSubmit, busy: m.isPending, msg: m.isError ? errMsg(m.error, fallback) : "" };
}
// Settings: nur für den Eigentümer. Die {" "} bilden die Leerzeichen des alten
// Markups nach -- Inputs und Knöpfe sind inline.
// Settings: nur für den Eigentümer.
function Settings({ onAvatar }: { onAvatar: () => void }) {
const qc = useQueryClient();
const navigate = useNavigate();
-7
View File
@@ -43,13 +43,6 @@ export interface BumpResult {
retry_after: number;
}
export interface CreatedEntry {
pid: number;
content: string;
filepath: string;
reply_to: number;
}
// GET /api/user/info -- das eigene Konto.
export interface Me {
uid: number;