Kopfbereich mit Statusauswahl samt Erklaerung je Zustand, Favorit, Fortschrittsregler, Bearbeiten und Loeschen. Notizen: - beliebig viele je Projekt, Titel optional, Zeilenumbrueche bleiben erhalten - anpinnen, bearbeiten, loeschen direkt in der Liste - "bearbeitet vor ..." erscheint nur bei echter Aenderung, nicht direkt nach dem Anlegen Git-Panel: - Kennzahlen zu Stars, Forks, offenen PRs/MRs, Issues, Branches und Groesse - letzter Commit und aktuelle Version als eigene Kacheln - Sprachverteilung als Balken - Reiter fuer Branches, Tags, Commits und Releases mit Links ins Repo - "Jetzt synchronisieren" holt den Stand; Fehler erscheinen im Klartext statt als Fehlerseite Ohne verknuepftes Repository zeigt das Panel einen Hinweis statt leerer Kacheln. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
477 lines
18 KiB
TypeScript
477 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
AlertTriangle,
|
||
ExternalLink,
|
||
GitBranch,
|
||
GitCommitHorizontal,
|
||
GitMerge,
|
||
GitPullRequest,
|
||
HardDrive,
|
||
Loader2,
|
||
Lock,
|
||
Package,
|
||
RefreshCw,
|
||
ShieldCheck,
|
||
Star,
|
||
Tag,
|
||
} from "lucide-react";
|
||
import { asArray, asRecord } from "@/types";
|
||
import type {
|
||
BranchInfo,
|
||
CommitInfo,
|
||
ProjectDetail,
|
||
ReleaseInfo,
|
||
TagInfo,
|
||
} from "@/types";
|
||
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
|
||
|
||
const TABS = [
|
||
{ key: "branches", label: "Branches", icon: GitBranch },
|
||
{ key: "tags", label: "Tags", icon: Tag },
|
||
{ key: "commits", label: "Commits", icon: GitCommitHorizontal },
|
||
{ key: "releases", label: "Releases", icon: Package },
|
||
] as const;
|
||
|
||
type TabKey = (typeof TABS)[number]["key"];
|
||
|
||
export function GitPanel({ project }: { project: ProjectDetail }) {
|
||
const router = useRouter();
|
||
const [tab, setTab] = useState<TabKey>("branches");
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||
|
||
const snapshot = project.snapshot;
|
||
const branches = asArray<BranchInfo>(snapshot?.branches);
|
||
const tags = asArray<TagInfo>(snapshot?.tags);
|
||
const commits = asArray<CommitInfo>(snapshot?.commits);
|
||
const releases = asArray<ReleaseInfo>(snapshot?.releases);
|
||
const languages = asRecord(snapshot?.languages);
|
||
|
||
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl);
|
||
|
||
async function sync() {
|
||
setSyncing(true);
|
||
setMessage(null);
|
||
try {
|
||
const response = await fetch(`/api/projects/${project.id}/sync`, { method: "POST" });
|
||
const payload = await response.json().catch(() => ({}));
|
||
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
|
||
router.refresh();
|
||
} catch (caught) {
|
||
setMessage({
|
||
ok: false,
|
||
text: caught instanceof Error ? caught.message : String(caught),
|
||
});
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
}
|
||
|
||
if (!hasRepo) {
|
||
return (
|
||
<div className="glass px-5 py-10 text-center">
|
||
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
|
||
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
|
||
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
|
||
Hinterlege im Projekt eine Repository-URL und waehle eine Git-Verbindung, um
|
||
Branches, Tags, Commits und Releases hier zu sehen.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const languageTotal = Object.values(languages).reduce((sum, value) => sum + value, 0);
|
||
const topLanguages = Object.entries(languages)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Kopf mit Sync */}
|
||
<div className="glass flex flex-wrap items-center justify-between gap-3 p-4">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="truncate font-mono text-sm text-slate-100">
|
||
{snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`}
|
||
</h2>
|
||
{snapshot?.visibility ? (
|
||
<span className="inline-flex items-center gap-1 rounded-md bg-white/6 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
|
||
{snapshot.visibility === "private" ? (
|
||
<Lock className="h-2.5 w-2.5" />
|
||
) : (
|
||
<ShieldCheck className="h-2.5 w-2.5" />
|
||
)}
|
||
{snapshot.visibility}
|
||
</span>
|
||
) : null}
|
||
{snapshot?.htmlUrl ? (
|
||
<a
|
||
href={snapshot.htmlUrl}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="text-slate-500 transition-colors hover:text-violet-300"
|
||
aria-label="Repository oeffnen"
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5" />
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
||
{project.lastSyncedAt
|
||
? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}`
|
||
: "Noch nie synchronisiert."}
|
||
</p>
|
||
</div>
|
||
|
||
<button type="button" className="btn btn-primary" onClick={sync} disabled={syncing}>
|
||
{syncing ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<RefreshCw className="h-4 w-4" />
|
||
)}
|
||
Jetzt synchronisieren
|
||
</button>
|
||
</div>
|
||
|
||
{message ? (
|
||
<p
|
||
className={cn(
|
||
"rounded-xl border px-3 py-2 text-sm",
|
||
message.ok
|
||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||
)}
|
||
>
|
||
{message.text}
|
||
</p>
|
||
) : null}
|
||
|
||
{project.syncError && !message ? (
|
||
<p className="flex items-start gap-2 rounded-xl border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<span>{project.syncError}</span>
|
||
</p>
|
||
) : null}
|
||
|
||
{snapshot ? (
|
||
<>
|
||
{/* Kennzahlen */}
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||
<Metric icon={Star} label="Stars" value={formatNumber(snapshot.stars)} />
|
||
<Metric icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} />
|
||
<Metric
|
||
icon={GitPullRequest}
|
||
label="Offene PRs/MRs"
|
||
value={formatNumber(snapshot.openPullRequests)}
|
||
/>
|
||
<Metric
|
||
icon={AlertTriangle}
|
||
label="Offene Issues"
|
||
value={formatNumber(snapshot.openIssues)}
|
||
/>
|
||
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} />
|
||
<Metric
|
||
icon={HardDrive}
|
||
label="Groesse"
|
||
value={snapshot.sizeKb > 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`}
|
||
/>
|
||
</div>
|
||
|
||
{/* Letzter Commit + Version */}
|
||
<div className="grid gap-3 lg:grid-cols-2">
|
||
<div className="glass p-4">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||
Letzter Commit
|
||
</p>
|
||
{snapshot.lastCommitSha ? (
|
||
<>
|
||
<p className="mt-2 line-clamp-2 text-sm text-slate-200">
|
||
{snapshot.lastCommitMessage}
|
||
</p>
|
||
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||
<span className="rounded bg-white/6 px-1.5 py-0.5 font-mono text-[11px] text-violet-200">
|
||
{snapshot.lastCommitSha.slice(0, 7)}
|
||
</span>
|
||
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
|
||
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span>
|
||
</p>
|
||
</>
|
||
) : (
|
||
<p className="mt-2 text-sm text-slate-500">Keine Commit-Daten.</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="glass p-4">
|
||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||
Aktuelle Version
|
||
</p>
|
||
<div className="mt-2 flex flex-wrap items-baseline gap-3">
|
||
<span className="font-mono text-2xl text-white">
|
||
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
|
||
</span>
|
||
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? (
|
||
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-1 text-xs text-slate-500">
|
||
{snapshot.latestReleaseAt
|
||
? `Release vom ${formatDateTime(snapshot.latestReleaseAt)}`
|
||
: `${snapshot.tagCount} Tag${snapshot.tagCount === 1 ? "" : "s"} im Repository`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sprachen */}
|
||
{languageTotal > 0 ? (
|
||
<div className="glass p-4">
|
||
<p className="mb-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||
Sprachen
|
||
</p>
|
||
<div className="flex h-2 overflow-hidden rounded-full bg-white/6">
|
||
{topLanguages.map(([name, bytes], index) => (
|
||
<span
|
||
key={name}
|
||
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
|
||
style={{
|
||
width: `${(bytes / languageTotal) * 100}%`,
|
||
backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-slate-400">
|
||
{topLanguages.map(([name, bytes], index) => (
|
||
<span key={name} className="inline-flex items-center gap-1.5">
|
||
<span
|
||
className="h-2 w-2 rounded-full"
|
||
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }}
|
||
/>
|
||
{name}
|
||
<span className="text-slate-600">
|
||
{Math.round((bytes / languageTotal) * 100)}%
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Listen */}
|
||
<div className="glass overflow-hidden">
|
||
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
|
||
{TABS.map(({ key, label, icon: Icon }) => {
|
||
const count =
|
||
key === "branches"
|
||
? branches.length
|
||
: key === "tags"
|
||
? tags.length
|
||
: key === "commits"
|
||
? commits.length
|
||
: releases.length;
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => setTab(key)}
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs transition-colors",
|
||
tab === key
|
||
? "bg-white/9 text-white"
|
||
: "text-slate-400 hover:bg-white/5 hover:text-slate-200",
|
||
)}
|
||
>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
{label}
|
||
<span className="tabular-nums text-slate-600">{count}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="max-h-[26rem] overflow-y-auto">
|
||
{tab === "branches" ? <BranchList branches={branches} /> : null}
|
||
{tab === "tags" ? <TagList tags={tags} /> : null}
|
||
{tab === "commits" ? <CommitList commits={commits} /> : null}
|
||
{tab === "releases" ? <ReleaseList releases={releases} /> : null}
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
|
||
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const LANGUAGE_COLORS = ["#a78bfa", "#22d3ee", "#34d399", "#fbbf24", "#f472b6"];
|
||
|
||
function Metric({
|
||
icon: Icon,
|
||
label,
|
||
value,
|
||
}: {
|
||
icon: typeof Star;
|
||
label: string;
|
||
value: string;
|
||
}) {
|
||
return (
|
||
<div className="glass-soft px-3 py-2.5">
|
||
<p className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-slate-500">
|
||
<Icon className="h-3 w-3" />
|
||
{label}
|
||
</p>
|
||
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="flex items-center justify-between gap-3 border-b border-white/4 px-4 py-2.5 last:border-b-0 hover:bg-white/3">
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Empty({ text }: { text: string }) {
|
||
return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>;
|
||
}
|
||
|
||
function BranchList({ branches }: { branches: BranchInfo[] }) {
|
||
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
|
||
return (
|
||
<div>
|
||
{branches.map((branch) => (
|
||
<Row key={branch.name}>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||
<span className="truncate font-mono text-xs text-slate-200">{branch.name}</span>
|
||
{branch.isDefault ? (
|
||
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
|
||
default
|
||
</span>
|
||
) : null}
|
||
{branch.protected ? (
|
||
<Lock className="h-3 w-3 shrink-0 text-amber-300/70" />
|
||
) : null}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
||
{branch.lastCommitAt ? (
|
||
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span>
|
||
) : null}
|
||
{branch.sha ? (
|
||
<span className="font-mono text-slate-600">{branch.sha.slice(0, 7)}</span>
|
||
) : null}
|
||
</div>
|
||
</Row>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TagList({ tags }: { tags: TagInfo[] }) {
|
||
if (tags.length === 0) return <Empty text="Keine Tags vorhanden." />;
|
||
return (
|
||
<div>
|
||
{tags.map((tag) => (
|
||
<Row key={tag.name}>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||
<span className="truncate font-mono text-xs text-slate-200">{tag.name}</span>
|
||
{tag.message ? (
|
||
<span className="truncate text-[11px] text-slate-500">{tag.message}</span>
|
||
) : null}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
|
||
{tag.date ? <span suppressHydrationWarning>{formatRelative(tag.date)}</span> : null}
|
||
{tag.sha ? (
|
||
<span className="font-mono text-slate-600">{tag.sha.slice(0, 7)}</span>
|
||
) : null}
|
||
</div>
|
||
</Row>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CommitList({ commits }: { commits: CommitInfo[] }) {
|
||
if (commits.length === 0) return <Empty text="Keine Commits geladen." />;
|
||
return (
|
||
<div>
|
||
{commits.map((commit) => (
|
||
<Row key={commit.sha}>
|
||
<div className="min-w-0">
|
||
<p className="truncate text-xs text-slate-200">{commit.message}</p>
|
||
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
|
||
<span className="font-mono text-violet-300/80">{commit.shortSha}</span>
|
||
<span>{commit.author ?? "unbekannt"}</span>
|
||
</p>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
||
<span suppressHydrationWarning>{formatRelative(commit.date)}</span>
|
||
{commit.url ? (
|
||
<a
|
||
href={commit.url}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="text-slate-600 transition-colors hover:text-violet-300"
|
||
aria-label="Commit oeffnen"
|
||
>
|
||
<ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</Row>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
|
||
if (releases.length === 0) return <Empty text="Keine Releases veroeffentlicht." />;
|
||
return (
|
||
<div>
|
||
{releases.map((release) => (
|
||
<Row key={`${release.tag}-${release.name}`}>
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
|
||
<span className="truncate text-xs text-slate-200">{release.name}</span>
|
||
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
|
||
{release.tag}
|
||
</span>
|
||
{release.prerelease ? (
|
||
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-200">
|
||
pre
|
||
</span>
|
||
) : null}
|
||
{release.draft ? (
|
||
<span className="shrink-0 rounded bg-slate-500/15 px-1.5 py-0.5 text-[10px] text-slate-300">
|
||
draft
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
|
||
<span suppressHydrationWarning>{formatRelative(release.publishedAt)}</span>
|
||
{release.url ? (
|
||
<a
|
||
href={release.url}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="text-slate-600 transition-colors hover:text-violet-300"
|
||
aria-label="Release oeffnen"
|
||
>
|
||
<ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</Row>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|