feat(ui): Repositories im Git-Panel verwalten

Eine Leiste mit allen Repositories des Projekts, sobald es mehr als eines
gibt - bei einem einzigen waere eine Leiste mit genau einem Knopf nur
Ballast. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich.

Verknuepfen und Loesen laufen ueber einen eigenen Dialog mit derselben
Repo-Auswahl wie beim Projekt. Der Projektdialog bearbeitet weiterhin nur
das fuehrende: eine Liste darin wuerde den Dialog sprengen, in dem es
eigentlich um das Projekt geht.

Eine frei waehlbare Bezeichnung tritt an die Stelle von owner/name. Die
Karte zeigt "+2 Repos" und im Titel, welche es sind.

repoTitle steht in lib/git/meta statt bei den uebrigen Repository-Helfern:
lib/repositories zieht Prisma und damit node:-Module nach, die in einer
Client-Komponente nicht landen duerfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:24:08 +02:00
co-authored by Claude Opus 5
parent a843f08211
commit b882c870c3
9 changed files with 558 additions and 67 deletions
+4 -1
View File
@@ -87,7 +87,10 @@ export default async function ProjectPage({ params }: PageProps) {
</div> </div>
<div className="space-y-6 lg:col-span-7"> <div className="space-y-6 lg:col-span-7">
<GitPanel project={detail} /> <GitPanel
project={detail}
connections={connections as unknown as ConnectionSafe[]}
/>
<ActivityFeed projectId={detail.id} initial={detail.activities} /> <ActivityFeed projectId={detail.id} initial={detail.activities} />
</div> </div>
</div> </div>
+4 -4
View File
@@ -140,8 +140,8 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
} }
async function remove(connection: ConnectionWithCount) { async function remove(connection: ConnectionWithCount) {
const linked = connection._count?.projects ?? 0; const linked = connection._count?.repositories ?? 0;
const hint = linked > 0 ? `\n\n${linked} Projekt(e) verlieren die Verknuepfung.` : ""; const hint = linked > 0 ? `\n\n${linked} Repository(s) verlieren die Verknuepfung.` : "";
if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return; if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return;
setBusyId(connection.id); setBusyId(connection.id);
await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined); await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined);
@@ -257,9 +257,9 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
</div> </div>
) : null} ) : null}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Projekte</dt> <dt className="text-slate-500">Repositories</dt>
<dd className="tabular-nums text-slate-300"> <dd className="tabular-nums text-slate-300">
{connection._count?.projects ?? 0} {connection._count?.repositories ?? 0}
</dd> </dd>
</div> </div>
{connection.lastCheckedAt ? ( {connection.lastCheckedAt ? (
+150 -16
View File
@@ -16,19 +16,25 @@ import {
Loader2, Loader2,
Lock, Lock,
Package, Package,
Plus,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
Star, Star,
Tag, Tag,
Unlink,
} from "lucide-react"; } from "lucide-react";
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { Sparkline } from "@/components/Sparkline"; import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asRecord } from "@/types"; import { asArray, asRecord } from "@/types";
import type { import type {
BranchInfo, BranchInfo,
CommitInfo, CommitInfo,
ConnectionSafe,
ProjectDetail, ProjectDetail,
ReleaseInfo, ReleaseInfo,
RepositoryWithSnapshot,
TagInfo, TagInfo,
TicketInfo, TicketInfo,
} from "@/types"; } from "@/types";
@@ -45,22 +51,37 @@ const TABS = [
type TabKey = (typeof TABS)[number]["key"]; type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ project }: { project: ProjectDetail }) { export function GitPanel({
project,
connections,
}: {
project: ProjectDetail;
connections: ConnectionSafe[];
}) {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<TabKey>("pulls"); const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [addOpen, setAddOpen] = useState(false);
const repositories = project.repositories;
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
// zurueckfallen, statt eine leere Ansicht zu zeigen.
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
async function importIssues() { async function importIssues() {
if (!repository) return;
setImporting(true); setImporting(true);
setMessage(null); setMessage(null);
try { try {
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, { const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "{}", body: JSON.stringify({ repositoryId: repository.id }),
}); });
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen."); if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
@@ -79,7 +100,7 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
} }
} }
const snapshot = project.snapshot; const snapshot = repository?.snapshot ?? null;
const branches = asArray<BranchInfo>(snapshot?.branches); const branches = asArray<BranchInfo>(snapshot?.branches);
const tags = asArray<TagInfo>(snapshot?.tags); const tags = asArray<TagInfo>(snapshot?.tags);
const commits = asArray<CommitInfo>(snapshot?.commits); const commits = asArray<CommitInfo>(snapshot?.commits);
@@ -89,13 +110,15 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
const issues = asArray<TicketInfo>(snapshot?.issues); const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity); const activity = asArray<number>(snapshot?.commitActivity);
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl); /** Ein einzelnes Repository oder – ohne Argument – alle. */
async function sync(nurDieses?: string) {
async function sync() {
setSyncing(true); setSyncing(true);
setMessage(null); setMessage(null);
try { try {
const response = await fetch(`/api/projects/${project.id}/sync`, { method: "POST" }); const url = nurDieses
? `/api/repositories/${nurDieses}/sync`
: `/api/projects/${project.id}/sync`;
const response = await fetch(url, { method: "POST" });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." }); setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
router.refresh(); router.refresh();
@@ -109,16 +132,41 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
} }
} }
if (!hasRepo) { async function removeRepository(entry: RepositoryWithSnapshot) {
if (
!window.confirm(
`„${repoTitle(entry)}“ vom Projekt loesen? Der geholte Stand geht dabei verloren.`,
)
) {
return;
}
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
setSelectedId(null);
router.refresh();
}
if (!repository) {
return ( return (
<>
<div className="glass px-5 py-10 text-center"> <div className="glass px-5 py-10 text-center">
<GitBranch className="mx-auto h-8 w-8 text-slate-600" /> <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> <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"> <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 Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
Branches, Tags, Commits und Releases hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
</p> </p>
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4" />
Repository verknuepfen
</button>
</div> </div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
</>
); );
} }
@@ -129,12 +177,53 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
Leiste mit genau einem Knopf nur Ballast. */}
{repositories.length > 1 ? (
<div className="glass flex flex-wrap items-center gap-1.5 p-2" role="tablist">
{repositories.map((entry) => {
const aktiv = entry.id === repository.id;
return (
<button
key={entry.id}
type="button"
role="tab"
aria-selected={aktiv}
onClick={() => setSelectedId(entry.id)}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs transition-colors",
aktiv
? "bg-white/9 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200",
)}
>
{entry.syncError ? (
<AlertTriangle className="h-3 w-3 text-rose-300" />
) : (
<GitBranch className="h-3 w-3 text-slate-500" />
)}
{repoTitle(entry)}
</button>
);
})}
<button
type="button"
onClick={() => setAddOpen(true)}
className="ml-auto rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/5 hover:text-violet-300"
aria-label="Weiteres Repository verknuepfen"
title="Weiteres Repository verknuepfen"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
) : null}
{/* Kopf mit Sync */} {/* Kopf mit Sync */}
<div className="glass flex flex-wrap items-center justify-between gap-3 p-4"> <div className="glass flex flex-wrap items-center justify-between gap-3 p-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="truncate font-mono text-sm text-slate-100"> <h2 className="truncate font-mono text-sm text-slate-100">
{snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`} {snapshot?.fullName ?? repoTitle(repository)}
</h2> </h2>
{snapshot?.visibility ? ( {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"> <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">
@@ -160,13 +249,50 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} /> <CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
</div> </div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning> <p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt {repository.lastSyncedAt
? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}` ? `Zuletzt synchronisiert ${formatRelative(repository.lastSyncedAt)} · ${formatDateTime(repository.lastSyncedAt)}`
: "Noch nie synchronisiert."} : "Noch nie synchronisiert."}
</p> </p>
</div> </div>
<button type="button" className="btn btn-primary" onClick={sync} disabled={syncing}> <div className="flex flex-wrap items-center gap-2">
{repositories.length > 1 ? (
<button
type="button"
className="btn btn-ghost"
onClick={() => sync()}
disabled={syncing}
title="Alle Repositories dieses Projekts abgleichen"
>
<RefreshCw className="h-4 w-4" />
Alle
</button>
) : (
<button
type="button"
onClick={() => setAddOpen(true)}
className="btn btn-ghost"
title="Weiteres Repository verknuepfen"
>
<Plus className="h-4 w-4" />
Repository
</button>
)}
<button
type="button"
onClick={() => removeRepository(repository)}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-rose-500/10 hover:text-rose-300"
aria-label={`${repoTitle(repository)} vom Projekt loesen`}
title="Vom Projekt loesen"
>
<Unlink className="h-4 w-4" />
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => sync(repository.id)}
disabled={syncing}
>
{syncing ? ( {syncing ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
@@ -175,6 +301,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
Jetzt synchronisieren Jetzt synchronisieren
</button> </button>
</div> </div>
</div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
{message ? ( {message ? (
<p <p
@@ -189,10 +323,10 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</p> </p>
) : null} ) : null}
{project.syncError && !message ? ( {repository.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"> <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" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{project.syncError}</span> <span>{repository.syncError}</span>
</p> </p>
) : null} ) : null}
+4 -3
View File
@@ -256,11 +256,12 @@ function KanbanCard({
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] text-slate-500"> <div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] text-slate-500">
<CiBadge state={project.snapshot?.ciState} compact /> {/* Auf der schmalen Kanban-Karte nur das fuehrende Repository. */}
{project.snapshot?.defaultBranch ? ( <CiBadge state={project.repositories[0]?.snapshot?.ciState} compact />
{project.repositories[0]?.snapshot?.defaultBranch ? (
<span className="inline-flex items-center gap-1 font-mono"> <span className="inline-flex items-center gap-1 font-mono">
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{project.snapshot.defaultBranch} {project.repositories[0].snapshot.defaultBranch}
</span> </span>
) : null} ) : null}
{project._count.notes > 0 ? ( {project._count.notes > 0 ? (
+3 -1
View File
@@ -140,7 +140,9 @@ export function ProjectBoard({
(project.summary ?? "").toLowerCase().includes(needle) || (project.summary ?? "").toLowerCase().includes(needle) ||
(project.description ?? "").toLowerCase().includes(needle) || (project.description ?? "").toLowerCase().includes(needle) ||
project.tags.some((tag) => tag.includes(needle)) || project.tags.some((tag) => tag.includes(needle)) ||
(project.snapshot?.fullName ?? "").toLowerCase().includes(needle) || project.repositories.some((repo) =>
(repo.snapshot?.fullName ?? "").toLowerCase().includes(needle),
) ||
// Treffer in Notizen kommen vom Server dazu. // Treffer in Notizen kommen vom Server dazu.
noteHitProjectIds.has(project.id) noteHitProjectIds.has(project.id)
); );
+23 -8
View File
@@ -14,6 +14,7 @@ import {
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline"; import { Sparkline } from "@/components/Sparkline";
import { StatusSelect } from "@/components/StatusSelect"; import { StatusSelect } from "@/components/StatusSelect";
import { repoTitle } from "@/lib/git/meta";
import { PRIORITY_META, accentOf } from "@/lib/status"; import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatRelative, truncate } from "@/lib/utils"; import { cn, formatRelative, truncate } from "@/lib/utils";
import { asArray, type ProjectListItem } from "@/types"; import { asArray, type ProjectListItem } from "@/types";
@@ -31,7 +32,14 @@ const PROVIDER_LABEL: Record<string, string> = {
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) { export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) {
const accent = accentOf(project.accent); const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.snapshot; // Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es
// insgesamt sind, sagt die Zahl daneben.
const leading = project.repositories[0] ?? null;
const snapshot = leading?.snapshot ?? null;
const weitere = project.repositories.length - 1;
// Ein Fehler an irgendeinem Repo gehoert auf die Karte, nicht nur einer am
// fuehrenden – sonst bliebe er unbemerkt.
const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null;
const activity = asArray<number>(snapshot?.commitActivity); const activity = asArray<number>(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite); const [favorite, setFavorite] = useState(project.favorite);
@@ -125,10 +133,20 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400"> <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact /> <CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact />
{project.connection ? ( {leading?.connection ? (
<span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1"> <span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1">
<span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" /> <span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" />
{PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider} {PROVIDER_LABEL[leading.connection.provider] ?? leading.connection.provider}
</span>
) : null}
{weitere > 0 ? (
<span
className="inline-flex items-center gap-1.5"
title={project.repositories.map((repo) => repoTitle(repo)).join(", ")}
>
<GitBranch className="h-3.5 w-3.5 text-slate-500" />+{weitere} Repo
{weitere === 1 ? "" : "s"}
</span> </span>
) : null} ) : null}
@@ -173,11 +191,8 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span> </span>
) : null} ) : null}
{project.syncError ? ( {syncError ? (
<span <span className="inline-flex items-center gap-1.5 text-rose-300/80" title={syncError}>
className="inline-flex items-center gap-1.5 text-rose-300/80"
title={project.syncError}
>
<AlertTriangle className="h-3.5 w-3.5" /> <AlertTriangle className="h-3.5 w-3.5" />
Sync-Fehler Sync-Fehler
</span> </span>
+8 -5
View File
@@ -87,11 +87,14 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
accent: project.accent, accent: project.accent,
tags: project.tags.join(", "), tags: project.tags.join(", "),
favorite: project.favorite, favorite: project.favorite,
connectionId: project.connectionId ?? "", // Der Dialog bearbeitet das fuehrende Repository. Weitere
repoUrl: project.repoUrl ?? "", // verwaltet das Git-Panel – hier wuerde eine Liste den Dialog
repoOwner: project.repoOwner ?? "", // sprengen, in dem es eigentlich um das Projekt geht.
repoName: project.repoName ?? "", connectionId: project.repositories[0]?.connectionId ?? "",
autoSync: project.autoSync, repoUrl: project.repositories[0]?.repoUrl ?? "",
repoOwner: project.repositories[0]?.repoOwner ?? "",
repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks, progressFromTasks: project.progressFromTasks,
} }
: EMPTY, : EMPTY,
+10 -11
View File
@@ -19,6 +19,7 @@ import {
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { StatusSelect } from "@/components/StatusSelect"; import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
import { repoTitle } from "@/lib/git/meta";
import { PRIORITY_META, accentOf } from "@/lib/status"; import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatDate } from "@/lib/utils"; import { cn, formatDate } from "@/lib/utils";
import type { ConnectionSafe, ProjectDetail } from "@/types"; import type { ConnectionSafe, ProjectDetail } from "@/types";
@@ -132,24 +133,22 @@ export function ProjectHeader({
angelegt {formatDate(project.createdAt)} angelegt {formatDate(project.createdAt)}
</span> </span>
<span style={{ color: priority.color }}>Prioritaet: {priority.label}</span> <span style={{ color: priority.color }}>Prioritaet: {priority.label}</span>
{project.connection ? ( {/* Alle Repositories verlinken – bei einem einzigen sieht das aus
<span className="inline-flex items-center gap-1.5"> wie vorher, bei mehreren steht jedes fuer sich. */}
{project.connection.name} {project.repositories
<span className="text-slate-700">·</span> .filter((repository) => repository.repoUrl)
{project.connection.provider} .map((repository) => (
</span>
) : null}
{project.repoUrl ? (
<a <a
href={project.repoUrl} key={repository.id}
href={repository.repoUrl!}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200" className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200"
> >
Repository {project.repositories.length === 1 ? "Repository" : repoTitle(repository)}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </a>
) : null} ))}
</div> </div>
{project.tags.length > 0 ? ( {project.tags.length > 0 ? (
+334
View File
@@ -0,0 +1,334 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import type { ConnectionSafe, RepoListEntry, RepositoryWithSnapshot } from "@/types";
/**
* Ein weiteres Repository mit einem Projekt verknuepfen – oder ein
* vorhandenes umbenennen und umhaengen.
*
* Beim Anlegen des Projekts genuegt der Projektdialog: dort setzen die
* Git-Felder das erste Repository. Alles darueber hinaus laeuft hier.
*/
interface FormState {
label: string;
connectionId: string;
repoUrl: string;
repoOwner: string;
repoName: string;
autoSync: boolean;
}
const EMPTY: FormState = {
label: "",
connectionId: "",
repoUrl: "",
repoOwner: "",
repoName: "",
autoSync: true,
};
export function RepositoryDialog({
open,
onClose,
projectId,
connections,
repository,
}: {
open: boolean;
onClose: () => void;
projectId: string;
connections: ConnectionSafe[];
/** Vorhandenes = Bearbeiten, sonst Verknuepfen. */
repository?: RepositoryWithSnapshot | null;
}) {
const router = useRouter();
const [form, setForm] = useState<FormState>(EMPTY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState("");
const isEdit = Boolean(repository);
useEffect(() => {
if (!open) return;
setError(null);
setPickerOpen(false);
setRepos([]);
setFilter("");
setForm(
repository
? {
label: repository.label ?? "",
connectionId: repository.connectionId ?? "",
repoUrl: repository.repoUrl ?? "",
repoOwner: repository.repoOwner ?? "",
repoName: repository.repoName ?? "",
autoSync: repository.autoSync,
}
: EMPTY,
);
}, [open, repository]);
const gefiltert = useMemo(() => {
const needle = filter.toLowerCase().trim();
if (!needle) return repos.slice(0, 60);
return repos.filter((repo) => repo.fullPath.toLowerCase().includes(needle)).slice(0, 60);
}, [repos, filter]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((previous) => ({ ...previous, [key]: value }));
}
async function loadRepos() {
if (!form.connectionId) {
setError("Bitte zuerst eine Git-Verbindung auswaehlen.");
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/connections/${form.connectionId}/repos`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error ?? "Repos konnten nicht geladen werden.");
setRepos(payload.repos ?? []);
setPickerOpen(true);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setLoading(false);
}
}
function pick(repo: RepoListEntry) {
setForm((previous) => ({
...previous,
repoUrl: repo.htmlUrl,
repoOwner: repo.owner,
repoName: repo.name,
}));
setPickerOpen(false);
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
const response = await fetch(
isEdit ? `/api/repositories/${repository?.id}` : `/api/projects/${projectId}/repositories`,
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: form.label,
connectionId: form.connectionId || null,
repoUrl: form.repoUrl,
repoOwner: form.repoOwner,
repoName: form.repoName,
autoSync: form.autoSync,
}),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="lg"
title={isEdit ? "Repository bearbeiten" : "Repository verknuepfen"}
description={
isEdit
? "Zeigt das Repository woandershin, wird der geholte Stand verworfen."
: "Ein Projekt darf mehrere Repositories haben – etwa Frontend und Backend."
}
>
<form onSubmit={submit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="field-label" htmlFor="repo-label">
Bezeichnung (optional)
</label>
<input
id="repo-label"
className="field"
value={form.label}
onChange={(event) => update("label", event.target.value)}
placeholder="Frontend"
maxLength={80}
/>
<p className="mt-1 text-xs text-slate-500">
Ohne Angabe steht schlicht owner/name da.
</p>
</div>
<div>
<label className="field-label" htmlFor="repo-connection">
Verbindung
</label>
<select
id="repo-connection"
className="field"
value={form.connectionId}
onChange={(event) => update("connectionId", event.target.value)}
>
<option value="">– keine –</option>
{connections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.name} ({connection.provider})
</option>
))}
</select>
</div>
<div className="flex items-end">
<button
type="button"
className="btn btn-ghost w-full"
onClick={loadRepos}
disabled={loading || !form.connectionId}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Repos laden
</button>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="repo-url">
Repository-URL
</label>
<input
id="repo-url"
className="field"
value={form.repoUrl}
onChange={(event) => update("repoUrl", event.target.value)}
placeholder="https://github.com/user/repo"
/>
</div>
<div>
<label className="field-label" htmlFor="repo-owner">
Owner / Gruppe
</label>
<input
id="repo-owner"
className="field font-mono text-xs"
value={form.repoOwner}
onChange={(event) => update("repoOwner", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
<div>
<label className="field-label" htmlFor="repo-name">
Repository
</label>
<input
id="repo-name"
className="field font-mono text-xs"
value={form.repoName}
onChange={(event) => update("repoName", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.autoSync}
onChange={(event) => update("autoSync", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Am automatischen Abgleich teilnehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
synchronisieren geht unabhaengig davon immer.
</span>
</span>
</label>
{pickerOpen ? (
<div className="rounded-xl border border-white/8 bg-[#080a18]/70">
<div className="flex items-center gap-2 border-b border-white/6 px-3 py-2">
<Search className="h-3.5 w-3.5 text-slate-500" />
<input
className="w-full bg-transparent text-sm text-slate-200 outline-none placeholder:text-slate-600"
placeholder={`${repos.length} Repositories durchsuchen…`}
value={filter}
onChange={(event) => setFilter(event.target.value)}
/>
</div>
<ul className="max-h-56 overflow-y-auto p-1">
{gefiltert.map((repo) => (
<li key={repo.fullPath}>
<button
type="button"
onClick={() => pick(repo)}
className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left hover:bg-white/6"
>
<span className="min-w-0">
<span className="block truncate font-mono text-xs text-slate-200">
{repo.fullPath}
</span>
{repo.description ? (
<span className="block truncate text-[11px] text-slate-500">
{repo.description}
</span>
) : null}
</span>
<span className="shrink-0 text-[10px] uppercase text-slate-600">
{repo.visibility}
</span>
</button>
</li>
))}
{gefiltert.length === 0 ? (
<li className="px-3 py-4 text-center text-xs text-slate-500">Keine Treffer.</li>
) : null}
</ul>
</div>
) : null}
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Verknuepfen"}
</button>
</div>
</form>
</Modal>
);
}