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:
203
src/components/ProjectCard.tsx
Normal file
203
src/components/ProjectCard.tsx
Normal 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;
|
||||||
@@ -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 (
|
||||||
|
project.name.toLowerCase().includes(term) ||
|
||||||
|
project.repo.toLowerCase().includes(term) ||
|
||||||
|
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 (
|
return (
|
||||||
<section id="projects" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-background relative">
|
<section id="projects" className="relative bg-background pb-24 pt-8 md:pb-32 md:pt-12">
|
||||||
{/* TextHoverEffect */}
|
{/* Überschrift */}
|
||||||
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
<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}
|
</p>
|
||||||
glowIntensity={0.8}
|
</div>
|
||||||
coneSpread={25}
|
{data && (
|
||||||
colors={['#c084fc', '#f472b6', '#38bdf8']}
|
<div className="shrink-0 text-[11px] uppercase tracking-wider text-muted-foreground md:text-right">
|
||||||
>
|
<div className="font-display text-3xl font-medium tracking-tight text-foreground">
|
||||||
<a
|
{data.total}
|
||||||
href={project.url}
|
</div>
|
||||||
target={project.url.startsWith("http") ? "_blank" : undefined}
|
<div>Repositories</div>
|
||||||
rel={project.url.startsWith("http") ? "noopener noreferrer" : undefined}
|
{stand && (
|
||||||
className="group block p-6 md:p-8"
|
<div className="mt-1 inline-flex items-center gap-1.5">
|
||||||
style={{ animationDelay: `${index * 0.1}s` }}
|
<RefreshCw className={`h-3 w-3 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
>
|
Stand {stand} Uhr
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<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">
|
|
||||||
<img
|
|
||||||
src={project.image}
|
|
||||||
alt={project.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ArrowUpRight className="w-6 h-6 text-muted-foreground group-hover:text-foreground transition-colors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
)}
|
||||||
</BorderGlow>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</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 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
109
src/lib/projects.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user