From a21441c0ca2900909152d1f7db4b18d6ff31673c Mon Sep 17 00:00:00 2001 From: root Date: Thu, 6 Aug 2026 20:18:59 +0000 Subject: [PATCH] Projekte: Live-Ansicht aller Gitea-Repositories statt fester Liste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Abschnitt "Projekte" zog bisher aus einem hartkodierten Array mit Unsplash-Platzhaltern. Jetzt kommt der Inhalt live von GET /api/projects, das serverseitig alle Repositories aus Gitea mit den Hosting-Metadaten aus Appwrite (websiteProjects) zusammenführt. - Hybride Vorschau: öffentlich erreichbare und einbettbare Seiten laufen als echtes Live-Iframe (lazy, erst im Viewport, pointer-events: none), alles andere bekommt eine Live-Daten-Kachel mit Status, Sprache, letztem Commit und deterministischer Akzentfarbe. - Filter-Chips je Kategorie, Volltextsuche, "Mehr laden" (12er-Schritte). - Ladeskelett und Fehlerzustand mit Retry, damit der Abschnitt nie leer oder kaputt aussieht. Co-Authored-By: Claude Opus 5 --- src/components/ProjectCard.tsx | 203 ++++++++++++++++++++ src/components/ProjectShowcase.tsx | 288 ++++++++++++++++++++--------- src/lib/projects.ts | 109 +++++++++++ 3 files changed, 513 insertions(+), 87 deletions(-) create mode 100644 src/components/ProjectCard.tsx create mode 100644 src/lib/projects.ts diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx new file mode 100644 index 0000000..aed9431 --- /dev/null +++ b/src/components/ProjectCard.tsx @@ -0,0 +1,203 @@ +import { useEffect, useRef, useState } from "react"; +import { ArrowUpRight, GitBranch, Lock, Archive, Globe } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { de } from "date-fns/locale"; +import BorderGlow from "@/components/BorderGlow"; +import { + accentFor, + monogram, + STATE_LABELS, + CATEGORY_LABELS, + type LiveProject, +} from "@/lib/projects"; + +/** Breite, mit der die eingebettete Seite gerendert und dann herunterskaliert wird */ +const DESIGN_WIDTH = 1280; +const DESIGN_HEIGHT = 800; + +/** Iframe erst laden, wenn die Karte in Sichtweite kommt – 100 Karten sollen den Browser nicht killen. */ +function useInViewport(rootMargin = "300px") { + const ref = useRef(null); + const [inView, setInView] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || inView) return; + if (typeof IntersectionObserver === "undefined") { + setInView(true); + return; + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setInView(true); + observer.disconnect(); + } + }, + { rootMargin } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [inView, rootMargin]); + + return { ref, inView }; +} + +/** Skalierungsfaktor, damit die 1280px-Seite exakt in die Kachel passt */ +function useScaleToWidth(designWidth: number) { + const ref = useRef(null); + const [scale, setScale] = useState(0.25); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const update = () => setScale(el.clientWidth / designWidth); + update(); + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + } + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [designWidth]); + + return { ref, scale }; +} + +function relativeDate(iso: string | null): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return formatDistanceToNow(date, { addSuffix: true, locale: de }); +} + +const STATE_STYLES: Record = { + live: { dot: "bg-emerald-400", text: "text-emerald-400", Icon: Globe }, + online: { dot: "bg-emerald-400", text: "text-emerald-400", Icon: Globe }, + protected: { dot: "bg-amber-400", text: "text-amber-400", Icon: Lock }, + hosted: { dot: "bg-sky-400", text: "text-sky-400", Icon: Globe }, + repo: { dot: "bg-muted-foreground", text: "text-muted-foreground", Icon: GitBranch }, + archived: { dot: "bg-muted-foreground/60", text: "text-muted-foreground", Icon: Archive }, +}; + +type Props = { project: LiveProject; cardBg: string }; + +const ProjectCard = ({ project, cardBg }: Props) => { + const { ref: viewRef, inView } = useInViewport(); + const { ref: frameRef, scale } = useScaleToWidth(DESIGN_WIDTH); + const [frameFailed, setFrameFailed] = useState(false); + + const accent = accentFor(project.repo); + const state = STATE_STYLES[project.state]; + const showFrame = project.embeddable && project.url && inView && !frameFailed; + const updated = relativeDate(project.lastCommit?.date ?? project.updatedAt); + const linkUrl = project.reachable && project.url ? project.url : project.repoUrl; + + return ( + + + {/* Vorschau: echtes Live-Iframe, sonst Live-Daten-Kachel */} +
+ {showFrame ? ( +