React-Frontend: Feed mit Beitragskarte, Votes und Repost
- Feed per useInfiniteQuery mit Sentinel-Nachladen, Duplikate gefiltert, Stopp an der Backend-Seitengrenze - EntryCard mit unverändertem Markup (inkl. [deleted]-Platzhalter) - Votes mit optimistischem Umschalten, Rollback bei Fehler - Repost mit Cooldown-Anzeige und 429-Auswertung - linkify ohne innerHTML 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
37279ac90d
commit
bd5c59bcad
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
|
||||
export function avatarUrl(path: string): string {
|
||||
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
|
||||
}
|
||||
|
||||
export function formatTime(unix: number): string {
|
||||
return new Date(unix * 1000).toLocaleString("de-DE");
|
||||
}
|
||||
|
||||
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.
|
||||
export function linkify(text: string): ReactNode[] {
|
||||
const out: ReactNode[] = [];
|
||||
text.split("\n").forEach((line, i) => {
|
||||
if (i > 0) out.push(<br key={`br${i}`} />);
|
||||
let last = 0;
|
||||
for (const m of line.matchAll(urlPattern)) {
|
||||
const start = m.index;
|
||||
if (start > last) out.push(line.slice(last, start));
|
||||
out.push(
|
||||
<a key={`a${i}-${start}`} href={m[0]} target="_blank" rel="noopener">
|
||||
{m[0]}
|
||||
</a>,
|
||||
);
|
||||
last = start + m[0].length;
|
||||
}
|
||||
if (last < line.length) out.push(line.slice(last));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user