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:
rootandClaude Opus 5 committed 2026-08-06 20:18:59 +00:00
1 parent 04e1fd537d
commit a21441c0ca
3 files changed
+513 -87

No files matched your search

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