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 ? ( +