Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
import type { MouseEvent } from "react";
|
|
import { Link, useNavigate } from "react-router";
|
|
import type { Entry } from "../types";
|
|
import { avatarUrl, formatTime, linkify } from "../format";
|
|
import { Votes } from "./Votes";
|
|
import { useBump } from "./Bump";
|
|
|
|
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link
|
|
// oder Knopf darin.
|
|
function useOpenOnClick(pid: number) {
|
|
const navigate = useNavigate();
|
|
return (ev: MouseEvent) => {
|
|
if (!(ev.target as Element).closest("a, button")) navigate(`/e/${pid}`);
|
|
};
|
|
}
|
|
|
|
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} />;
|
|
}
|
|
|
|
function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: (ev: MouseEvent) => void }) {
|
|
const bump = useBump(e);
|
|
const profile = `/u/${encodeURIComponent(e.username)}`;
|
|
|
|
return (
|
|
<article className="card" onClick={open}>
|
|
<div className="muted byline">
|
|
<Link to={profile}>
|
|
<img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" />
|
|
</Link>
|
|
<span>
|
|
<Link to={profile}>{e.username}</Link> · {when}
|
|
<span className="cooldown">{bump.cooldown}</span>
|
|
</span>
|
|
</div>
|
|
<div className="threadline-wrapper">
|
|
<div className="threadline"></div>
|
|
<div className="body">
|
|
<div className="content">{linkify(e.content)}</div>
|
|
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
|
|
<div className="interactions">
|
|
<Link className="button reply" to={`/e/${e.pid}`}>Antworten ({e.reply_count})</Link>
|
|
<Votes pid={e.pid} />
|
|
{bump.button}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
// Discussion zeigt einen Beitrag mit der Antwort, die seine Diskussion zuletzt
|
|
// fortgesetzt hat: [Beitrag] ── "k weitere Beiträge" ── [jüngste Antwort].
|
|
// Der Strich verbindet die Glieder wie in der Ahnenkette; "k weitere" steht
|
|
// als Unterbrechung auf dem Strich und führt wie die Antwort selbst zu deren
|
|
// Seite, wo die ganze Kette darüber steht.
|
|
export function Discussion({ entry: e }: { entry: Entry }) {
|
|
if (!e.latest) return <EntryCard entry={e} />;
|
|
const { entry: latest, skipped } = e.latest;
|
|
return (
|
|
<div className="chain">
|
|
<EntryCard entry={e} />
|
|
{skipped > 0 && (
|
|
<div className="chain-gap">
|
|
<Link className="button" to={`/e/${latest.pid}`}>
|
|
{skipped === 1 ? "1 weiterer Beitrag" : `${skipped} weitere Beiträge`}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
<EntryCard entry={latest} />
|
|
</div>
|
|
);
|
|
}
|