Projekte: Live-Ansicht aller Gitea-Repositories statt fester Liste

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 <noreply@anthropic.com>
This commit is contained in:
root
2026-08-06 20:18:59 +00:00
parent 04e1fd537d
commit a21441c0ca
3 changed files with 513 additions and 87 deletions

View File

@@ -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<T extends HTMLElement>(rootMargin = "300px") {
const ref = useRef<T>(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<T extends HTMLElement>(designWidth: number) {
const ref = useRef<T>(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<LiveProject["state"], { dot: string; text: string; Icon: typeof Globe }> = {
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<HTMLAnchorElement>();
const { ref: frameRef, scale } = useScaleToWidth<HTMLDivElement>(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 (
<BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor={cardBg}
borderRadius={10}
glowRadius={26}
glowIntensity={0.7}
coneSpread={25}
colors={["#c084fc", "#f472b6", "#38bdf8"]}
>
<a
ref={viewRef}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="group flex h-full flex-col"
aria-label={`${project.name} ${STATE_LABELS[project.state]}`}
>
{/* Vorschau: echtes Live-Iframe, sonst Live-Daten-Kachel */}
<div
ref={frameRef}
className="relative aspect-[16/10] w-full overflow-hidden rounded-t-[10px] border-b border-border/60 bg-secondary/30"
>
{showFrame ? (
<iframe
src={project.url ?? undefined}
title={project.name}
loading="lazy"
tabIndex={-1}
aria-hidden="true"
sandbox="allow-scripts allow-same-origin"
referrerPolicy="no-referrer"
onError={() => setFrameFailed(true)}
style={{
position: "absolute",
top: 0,
left: 0,
width: DESIGN_WIDTH,
height: DESIGN_HEIGHT,
border: 0,
transform: `scale(${scale})`,
transformOrigin: "top left",
pointerEvents: "none",
}}
/>
) : (
<div
className="absolute inset-0 flex items-center justify-center"
style={{ background: `linear-gradient(135deg, ${accent.from}, ${accent.to})` }}
>
<span className="font-display text-5xl font-medium tracking-tight text-background/80 mix-blend-overlay md:text-6xl">
{monogram(project.name)}
</span>
</div>
)}
{/* Status-Badge */}
<div className="absolute left-3 top-3 flex items-center gap-2 rounded-full bg-background/85 px-3 py-1 backdrop-blur-sm">
<span className={`h-1.5 w-1.5 rounded-full ${state.dot}`} />
<span className={`text-[10px] uppercase tracking-wider ${state.text}`}>
{STATE_LABELS[project.state]}
</span>
</div>
<div className="absolute right-3 top-3 rounded-full bg-background/85 px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
{CATEGORY_LABELS[project.category]}
</div>
</div>
{/* Text */}
<div className="flex flex-1 flex-col p-5 md:p-6">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="font-display text-lg font-medium uppercase leading-tight tracking-tight text-foreground transition-colors group-hover:text-muted-foreground md:text-xl">
{project.name}
</h3>
<ArrowUpRight className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
{project.description && (
<p className="mb-4 line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{project.description}
</p>
)}
<div className="mt-auto space-y-2 border-t border-border/60 pt-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] uppercase tracking-wider text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<state.Icon className="h-3 w-3" />
{project.repo}
</span>
{project.language && <span>{project.language}</span>}
{updated && <span>{updated}</span>}
</div>
{project.lastCommit?.message && (
<p className="truncate font-mono text-[11px] text-muted-foreground/80">
{project.lastCommit.message}
</p>
)}
</div>
</div>
</a>
</BorderGlow>
);
};
export default ProjectCard;

View File

@@ -1,105 +1,219 @@
import { ArrowUpRight } from "lucide-react"; import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import BorderGlow from "@/components/BorderGlow"; import { Search, RefreshCw, AlertCircle } from "lucide-react";
import { TextHoverEffect } from "@/components/ui/text-hover-effect"; import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import ProjectCard from "@/components/ProjectCard";
import {
fetchProjects,
CATEGORY_LABELS,
CATEGORY_ORDER,
type LiveProject,
type ProjectCategory,
} from "@/lib/projects";
type Project = { const PAGE_SIZE = 12;
title: string;
description: string;
image: string;
url: string;
};
const projects: Project[] = [ type Filter = ProjectCategory | "all";
{
title: "Email Sorter",
description: "Automatisierung · E-Mail-Workflows für Teams",
image: "/project%20pics/emailsorter.png",
url: "https://emailsorter.webklar.com/",
},
{
title: "Neutral",
description: "Website · Markenauftritt & Custom Development",
image: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&h=600&fit=crop",
url: "#",
},
{
title: "Verbatim Labs",
description: "Website · UI/UX & individuelle Entwicklung",
image: "https://images.unsplash.com/photo-1559028012-481c04fa702d?w=800&h=600&fit=crop",
url: "#",
},
{
title: "JMK Engineers",
description: "Website · Technische Präsentation & Lead-Generierung",
image: "https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=800&h=600&fit=crop",
url: "#",
},
{
title: "GOODZ Club",
description: "Website · Mehrsprachig & skalierbare Plattform",
image: "https://images.unsplash.com/photo-1542744094-3a31f272c490?w=800&h=600&fit=crop",
url: "#",
},
];
const ProjectShowcase = () => { const ProjectShowcase = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const cardBg = resolvedTheme === "dark" ? "hsl(0 0% 6%)" : "hsl(0 0% 96%)"; const cardBg = resolvedTheme === "dark" ? "hsl(0 0% 6%)" : "hsl(0 0% 96%)";
const [filter, setFilter] = useState<Filter>("all");
const [search, setSearch] = useState("");
const [visible, setVisible] = useState(PAGE_SIZE);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["projects"],
queryFn: ({ signal }) => fetchProjects(signal),
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
});
const projects = data?.projects ?? [];
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return projects.filter((project) => {
if (filter !== "all" && project.category !== filter) return false;
if (!term) return true;
return ( return (
<section id="projects" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-background relative"> project.name.toLowerCase().includes(term) ||
{/* TextHoverEffect */} project.repo.toLowerCase().includes(term) ||
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10"> project.description.toLowerCase().includes(term) ||
(project.subdomain ?? "").toLowerCase().includes(term)
);
});
}, [projects, filter, search]);
const shown = filtered.slice(0, visible);
const chips: { key: Filter; label: string; count: number }[] = [
{ key: "all", label: "Alle", count: projects.length },
...CATEGORY_ORDER.filter((c) => (data?.counts?.[c] ?? 0) > 0).map((c) => ({
key: c as Filter,
label: CATEGORY_LABELS[c],
count: data?.counts?.[c] ?? 0,
})),
];
const applyFilter = (key: Filter) => {
setFilter(key);
setVisible(PAGE_SIZE);
};
const stand = data?.generatedAt
? new Date(data.generatedAt).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
: null;
return (
<section id="projects" className="relative bg-background pb-24 pt-8 md:pb-32 md:pt-12">
{/* Überschrift */}
<div className="relative z-10 -mb-4 flex h-[14rem] items-center justify-center">
<TextHoverEffect text="Projekte" /> <TextHoverEffect text="Projekte" />
</div> </div>
<div className="container mx-auto px-6">
{/* Projects Grid */} <div className="container mx-auto px-6">
<div className="space-y-2"> {/* Live-Status */}
{projects.map((project, index) => ( <div className="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<BorderGlow <div>
key={project.title} <div className="label-tag mb-3">Live aus unserem Git</div>
edgeSensitivity={30} <p className="max-w-2xl leading-relaxed text-muted-foreground">
glowColor="40 80 80" Kein gepflegtes Portfolio, sondern der echte Stand: jedes Repository, das gerade auf{" "}
backgroundColor={cardBg} <span className="text-foreground">git.webklar.com</span> liegt inklusive letztem Commit
borderRadius={8} und Hosting-Status. Öffentlich erreichbare Seiten zeigen wir als echte Live-Vorschau.
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<a
href={project.url}
target={project.url.startsWith("http") ? "_blank" : undefined}
rel={project.url.startsWith("http") ? "noopener noreferrer" : undefined}
className="group block p-6 md:p-8"
style={{ animationDelay: `${index * 0.1}s` }}
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex-1">
<h3 className="text-2xl md:text-3xl font-display font-medium text-foreground mb-2 group-hover:text-muted-foreground transition-colors uppercase tracking-tight">
{project.title}
</h3>
<p className="text-muted-foreground text-sm uppercase tracking-wider">
{project.description}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> {data && (
<div className="w-24 h-16 md:w-32 md:h-20 rounded overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-500"> <div className="shrink-0 text-[11px] uppercase tracking-wider text-muted-foreground md:text-right">
<img <div className="font-display text-3xl font-medium tracking-tight text-foreground">
src={project.image} {data.total}
alt={project.title}
className="w-full h-full object-cover"
/>
</div> </div>
<ArrowUpRight className="w-6 h-6 text-muted-foreground group-hover:text-foreground transition-colors" /> <div>Repositories</div>
{stand && (
<div className="mt-1 inline-flex items-center gap-1.5">
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
Stand {stand} Uhr
</div> </div>
)}
</div> </div>
</a> )}
</BorderGlow> </div>
{/* Filter + Suche */}
{projects.length > 0 && (
<div className="mb-8 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<button
key={chip.key}
type="button"
onClick={() => applyFilter(chip.key)}
className={`rounded-full border px-4 py-1.5 text-[11px] uppercase tracking-wider transition-colors ${
filter === chip.key
? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground"
}`}
>
{chip.label}
<span className="ml-2 opacity-60">{chip.count}</span>
</button>
))} ))}
</div> </div>
<div className="relative lg:w-72">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setVisible(PAGE_SIZE);
}}
placeholder="Projekt oder Repository suchen"
className="w-full rounded-full border border-border bg-transparent py-2 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-foreground/40 focus:outline-none"
/>
</div>
</div>
)}
{/* Ladezustand */}
{isLoading && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="animate-pulse overflow-hidden rounded-[10px] border border-border"
>
<div className="aspect-[16/10] w-full bg-secondary/50" />
<div className="space-y-3 p-6">
<div className="h-4 w-2/3 rounded bg-secondary/60" />
<div className="h-3 w-full rounded bg-secondary/40" />
<div className="h-3 w-1/2 rounded bg-secondary/40" />
</div>
</div>
))}
</div>
)}
{/* Fehler */}
{isError && (
<div className="flex flex-col items-start gap-4 rounded-[10px] border border-border p-8 md:flex-row md:items-center md:justify-between">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div>
<p className="mb-1 font-display uppercase tracking-tight text-foreground">
Projekte gerade nicht abrufbar
</p>
<p className="text-sm text-muted-foreground">
Die Live-Verbindung zu unserem Git antwortet nicht. Schreiben Sie uns wir zeigen
Ihnen die passenden Referenzen persönlich.
</p>
</div>
</div>
<button
type="button"
onClick={() => refetch()}
className="shrink-0 rounded-full border border-border px-5 py-2 text-[11px] uppercase tracking-wider text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
>
Erneut versuchen
</button>
</div>
)}
{/* Karten */}
{!isLoading && !isError && (
<>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{shown.map((project: LiveProject) => (
<ProjectCard key={project.id} project={project} cardBg={cardBg} />
))}
</div>
{filtered.length === 0 && (
<p className="py-12 text-center text-sm text-muted-foreground">
Kein Projekt gefunden. Andere Kategorie oder Suchbegriff probieren.
</p>
)}
{visible < filtered.length && (
<div className="mt-10 flex justify-center">
<button
type="button"
onClick={() => setVisible((current) => current + PAGE_SIZE)}
className="rounded-full border border-border px-8 py-3 text-[11px] uppercase tracking-wider text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
>
Mehr laden
<span className="ml-2 opacity-60">
{filtered.length - visible} weitere
</span>
</button>
</div>
)}
</>
)}
</div> </div>
</section> </section>
); );

109
src/lib/projects.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Live-Projektdaten für den Abschnitt „Projekte".
*
* Quelle: GET /api/projects (contact-api-Container) führt serverseitig alle
* Gitea-Repositories mit den Hosting-Metadaten aus Appwrite zusammen.
* Im Browser liegen dadurch weder Gitea-Token noch Appwrite-Key.
*/
export type ProjectCategory = "website" | "preview" | "template" | "app" | "internal" | "test";
/** live = öffentlich + einbettbar, online = öffentlich, protected = hinter Portal-Login */
export type ProjectState = "live" | "online" | "protected" | "hosted" | "repo" | "archived";
export type LiveProject = {
id: string;
name: string;
repo: string;
owner: string;
description: string;
category: ProjectCategory;
state: ProjectState;
url: string | null;
repoUrl: string;
language: string | null;
sizeKb: number;
updatedAt: string | null;
archived: boolean;
private: boolean;
hosted: boolean;
hostingStatus: string | null;
subdomain: string | null;
embeddable: boolean;
reachable: boolean;
gated: boolean;
lastCommit: { message: string; author: string | null; date: string | null } | null;
};
export type ProjectsSnapshot = {
generatedAt: string;
total: number;
counts: Partial<Record<ProjectCategory, number>>;
embeddable: number;
source: string;
projects: LiveProject[];
};
const API_URL = import.meta.env.VITE_PROJECTS_API ?? "/api/projects";
export async function fetchProjects(signal?: AbortSignal): Promise<ProjectsSnapshot> {
const res = await fetch(API_URL, { signal, headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Projekte konnten nicht geladen werden (HTTP ${res.status})`);
const data = (await res.json()) as ProjectsSnapshot;
if (!Array.isArray(data?.projects)) throw new Error("Unerwartete Antwort der Projekt-API");
return data;
}
export const CATEGORY_LABELS: Record<ProjectCategory, string> = {
website: "Kundenprojekte",
preview: "Kunden-Previews",
template: "Vorlagen",
app: "Apps & Tools",
internal: "Eigene Systeme",
test: "Tests",
};
/** Reihenfolge der Filter-Chips */
export const CATEGORY_ORDER: ProjectCategory[] = [
"website",
"preview",
"app",
"template",
"internal",
"test",
];
export const STATE_LABELS: Record<ProjectState, string> = {
live: "Live-Vorschau",
online: "Öffentlich online",
protected: "Login geschützt",
hosted: "Gehostet",
repo: "Nur Repository",
archived: "Archiviert",
};
/**
* Deterministische Akzentfarbe aus dem Repo-Namen gleiche Kachel behält beim
* Neuladen ihre Farbe, ohne dass wir Farben pflegen müssen.
*/
export function accentFor(seed: string): { hue: number; from: string; to: string } {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
const hue = hash % 360;
return {
hue,
from: `hsl(${hue} 70% 55% / 0.55)`,
to: `hsl(${(hue + 48) % 360} 70% 45% / 0.18)`,
};
}
/** Kürzel für die Platzhalter-Kachel (max. 2 Zeichen) */
export function monogram(name: string): string {
const words = name
.replace(/[^\p{L}\p{N}\s-]/gu, " ")
.split(/[\s-]+/)
.filter(Boolean);
if (words.length === 0) return "··";
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[1][0]).toUpperCase();
}