From e97bd43b23893a26f7c8ba1f193dfe5a24036035 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 23:41:42 +0200 Subject: [PATCH] feat(ui): README auf der Projektseite anzeigen Lange READMEs sind eingeklappt, ein Knopf zeigt alles. Kurze stehen gleich vollstaendig da - ein Knopf, der nichts freigibt, waere nur Ballast. Relative Links werden gegen das Verzeichnis der Datei im Repository aufgeloest. Ohne das liefen sie ins Leere, denn im README steht "docs/x.md" und nicht die volle Adresse. Bilder werden nicht geladen, sondern als anklickbare Marke gezeigt. Die Content-Security-Policy erlaubt img-src nur von der eigenen Instanz; sie dafuer zu oeffnen hiesse, jedem fremden README das Nachladen beliebiger Bilder zu gestatten - also auch Zaehlpixeln. Fuer Abzeichen und Bildschirmfotos, in einer Vorschau ohnehin Beiwerk, ist das kein guter Tausch. Der Umbau passiert im Baum und nicht ueber eine Komponente, weil es auf den Elternknoten ankommt: "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel, und ein a in einem a ist ungueltiges HTML. Der Browser zieht es beim Parsen auseinander - der erste Versuch scheiterte prompt an einem Hydrationsfehler. Innerhalb eines Links bleibt jetzt nur der Text stehen. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/components.css | 24 +++++++++ src/app/projects/[id]/page.tsx | 26 +++++++++ src/components/Markdown.tsx | 97 ++++++++++++++++++++++++++++++++-- src/components/ReadmePanel.tsx | 86 ++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/components/ReadmePanel.tsx diff --git a/src/app/components.css b/src/app/components.css index bf79c83..cedc800 100644 --- a/src/app/components.css +++ b/src/app/components.css @@ -124,6 +124,30 @@ } .md a:hover { color: #ddd6fe; } + /* Bilder werden nicht geladen, sondern als Marke gezeigt – siehe die + Begruendung in Markdown.tsx. Damit sie nicht wie Fliesstext aussieht, + bekommt sie einen Rahmen und ein Symbol davor. */ + .md .md-image { + display: inline-flex; + align-items: center; + gap: 0.3em; + border: 1px dashed rgba(255, 255, 255, 0.16); + border-radius: 0.4rem; + padding: 0.05em 0.45em; + font-size: 0.85em; + color: #8a98b0; + text-decoration: none; + vertical-align: baseline; + } + .md .md-image::before { + content: "▣"; + opacity: 0.6; + } + .md a.md-image:hover { + color: #c4b5fd; + border-color: rgba(196, 181, 253, 0.4); + } + .md strong { color: #f1f5f9; font-weight: 600; } .md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; } diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 3fdbfc6..b975665 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -5,6 +5,7 @@ import { ActivityFeed } from "@/components/ActivityFeed"; import { GitPanel } from "@/components/GitPanel"; import { Markdown } from "@/components/Markdown"; import { NotesPanel } from "@/components/NotesPanel"; +import { ReadmePanel } from "@/components/ReadmePanel"; import { ProjectHeader } from "@/components/ProjectHeader"; import { TaskBoard } from "@/components/tasks/TaskBoard"; import { requireUserPage } from "@/lib/auth/guard"; @@ -54,6 +55,20 @@ export default async function ProjectPage({ params }: PageProps) { const detail = project as unknown as ProjectDetail; + const fuehrend = detail.repositories[0]?.snapshot; + const readme = fuehrend?.readme + ? { + content: fuehrend.readme, + path: fuehrend.readmePath, + baseUrl: fuehrend.readmeBaseUrl, + // Die Basis endet auf dem Verzeichnis; angehaengt ergibt das die Datei. + fileUrl: + fuehrend.readmeBaseUrl && fuehrend.readmePath + ? `${fuehrend.readmeBaseUrl}${fuehrend.readmePath.split("/").pop()}` + : (fuehrend.htmlUrl ?? null), + } + : null; + return (
+ {/* Das README des fuehrenden Repositories – bei mehreren waere eine + zweite Vorschau untereinander mehr Laenge als Nutzen. */} + {readme ? ( + + ) : null} +
diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 5babf2e..ad3e2f3 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -24,12 +24,93 @@ const schema = { }, }; +/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */ +function absolut(href: string | undefined, base: string | null | undefined): string | undefined { + if (!href || !base) return href; + if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) { + return href; + } + try { + return new URL(href, base).toString(); + } catch { + return href; + } +} + +type HastNode = { + type: string; + tagName?: string; + properties?: Record; + children?: HastNode[]; + value?: string; +}; + +/** + * Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung + * unten am `.md-image`-Stil. + * + * Das passiert im Baum und nicht ueber eine Komponente, weil es auf den + * Elternknoten ankommt: `[![Abzeichen](bild)](ziel)` ist in READMEs die + * Regel, und ein `` in einem `` ist ungueltiges HTML. Der Browser + * zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt – + * genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links + * bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das, + * was man anklicken will. + */ +function bilderAlsMarke(base: string | null | undefined) { + return () => (tree: HastNode) => { + const gehe = (knoten: HastNode, inLink: boolean) => { + const kinder = knoten.children; + if (!kinder) return; + + for (let index = 0; index < kinder.length; index += 1) { + const kind = kinder[index]; + + if (kind.type === "element" && kind.tagName === "img") { + const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined; + const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : ""; + const text: HastNode = { type: "text", value: alt || "Bild" }; + const ziel = absolut(quelle, base); + + kinder[index] = + inLink || !ziel + ? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] } + : { + type: "element", + tagName: "a", + properties: { + className: ["md-image"], + href: ziel, + title: ziel, + target: "_blank", + rel: "noreferrer noopener nofollow", + }, + children: [text], + }; + continue; + } + + gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a")); + } + }; + + gehe(tree, false); + }; +} + export function Markdown({ children, className, + baseUrl, }: { children: string | null | undefined; className?: string; + /** + * Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus + * einem Repository – dort zeigen Links auf Dateien daneben, die ohne + * Basis ins Leere liefen. + */ + baseUrl?: string | null; }) { if (!children?.trim()) return null; @@ -37,12 +118,22 @@ export function Markdown({
( - + // className muss durchgereicht werden, sonst verlieren die + // Bildmarken aus dem Umbau oben ihre Gestalt. + a: ({ href, className: klasse, title, children: content }) => ( + {content} ), diff --git a/src/components/ReadmePanel.tsx b/src/components/ReadmePanel.tsx new file mode 100644 index 0000000..0a986f2 --- /dev/null +++ b/src/components/ReadmePanel.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import { BookOpen, ChevronDown, ExternalLink } from "lucide-react"; +import { Markdown } from "@/components/Markdown"; +import { cn } from "@/lib/utils"; + +/** + * Das README des verknuepften Repositories. + * + * Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes + * Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht: + * eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles. + */ +export function ReadmePanel({ + content, + path, + baseUrl, + fileUrl, +}: { + content: string; + path: string | null; + baseUrl: string | null; + /** Direktlink auf die Datei im Git-System. */ + fileUrl: string | null; +}) { + const [offen, setOffen] = useState(false); + + // Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem + // Knopf zu versehen, der nichts freigibt. + const kurz = content.length < 900; + + return ( +
+
+

+ + {path ?? "README"} +

+ {fileUrl ? ( + + Im Git öffnen + + + ) : null} +
+ +
+
+ {content} +
+ + {/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass + es weitergeht. Nur wenn wirklich etwas verdeckt wird. */} + {!kurz && !offen ? ( +
+ ) : null} +
+ + {!kurz ? ( + + ) : null} +
+ ); +}