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} +
+ ); +}