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:
1 parent
04e1fd537d
commit
a21441c0ca
3 files changed
+513
-87
No files matched your search
@@ -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