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:
@@ -87,7 +87,10 @@ export default async function ProjectPage({ params }: PageProps) {
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,8 +140,8 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
|
||||
}
|
||||
|
||||
async function remove(connection: ConnectionWithCount) {
|
||||
const linked = connection._count?.projects ?? 0;
|
||||
const hint = linked > 0 ? `\n\n${linked} Projekt(e) verlieren die Verknuepfung.` : "";
|
||||
const linked = connection._count?.repositories ?? 0;
|
||||
const hint = linked > 0 ? `\n\n${linked} Repository(s) verlieren die Verknuepfung.` : "";
|
||||
if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return;
|
||||
setBusyId(connection.id);
|
||||
await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined);
|
||||
@@ -257,9 +257,9 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
|
||||
</div>
|
||||
) : null}
|
||||
<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">
|
||||
{connection._count?.projects ?? 0}
|
||||
{connection._count?.repositories ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
{connection.lastCheckedAt ? (
|
||||
|
||||
+150
-16
@@ -16,19 +16,25 @@ import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Tag,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { CiBadge } from "@/components/CiBadge";
|
||||
import { RepositoryDialog } from "@/components/RepositoryDialog";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { repoTitle } from "@/lib/git/meta";
|
||||
import { asArray, asRecord } from "@/types";
|
||||
import type {
|
||||
BranchInfo,
|
||||
CommitInfo,
|
||||
ConnectionSafe,
|
||||
ProjectDetail,
|
||||
ReleaseInfo,
|
||||
RepositoryWithSnapshot,
|
||||
TagInfo,
|
||||
TicketInfo,
|
||||
} from "@/types";
|
||||
@@ -45,22 +51,37 @@ const TABS = [
|
||||
|
||||
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 [tab, setTab] = useState<TabKey>("pulls");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
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. */
|
||||
async function importIssues() {
|
||||
if (!repository) return;
|
||||
setImporting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
body: JSON.stringify({ repositoryId: repository.id }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
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 tags = asArray<TagInfo>(snapshot?.tags);
|
||||
const commits = asArray<CommitInfo>(snapshot?.commits);
|
||||
@@ -89,13 +110,15 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
const issues = asArray<TicketInfo>(snapshot?.issues);
|
||||
const activity = asArray<number>(snapshot?.commitActivity);
|
||||
|
||||
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl);
|
||||
|
||||
async function sync() {
|
||||
/** Ein einzelnes Repository oder – ohne Argument – alle. */
|
||||
async function sync(nurDieses?: string) {
|
||||
setSyncing(true);
|
||||
setMessage(null);
|
||||
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(() => ({}));
|
||||
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
|
||||
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 (
|
||||
<>
|
||||
<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.
|
||||
Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
|
||||
Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Repository verknuepfen
|
||||
</button>
|
||||
</div>
|
||||
<RepositoryDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
projectId={project.id}
|
||||
connections={connections}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,12 +177,53 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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}`}
|
||||
{snapshot?.fullName ?? repoTitle(repository)}
|
||||
</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">
|
||||
@@ -160,13 +249,50 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
|
||||
{project.lastSyncedAt
|
||||
? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}`
|
||||
{repository.lastSyncedAt
|
||||
? `Zuletzt synchronisiert ${formatRelative(repository.lastSyncedAt)} · ${formatDateTime(repository.lastSyncedAt)}`
|
||||
: "Noch nie synchronisiert."}
|
||||
</p>
|
||||
</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 ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -175,6 +301,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
Jetzt synchronisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RepositoryDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
projectId={project.id}
|
||||
connections={connections}
|
||||
/>
|
||||
|
||||
{message ? (
|
||||
<p
|
||||
@@ -189,10 +323,10 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
|
||||
</p>
|
||||
) : 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">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{project.syncError}</span>
|
||||
<span>{repository.syncError}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -256,11 +256,12 @@ function KanbanCard({
|
||||
</div>
|
||||
|
||||
<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 />
|
||||
{project.snapshot?.defaultBranch ? (
|
||||
{/* Auf der schmalen Kanban-Karte nur das fuehrende Repository. */}
|
||||
<CiBadge state={project.repositories[0]?.snapshot?.ciState} compact />
|
||||
{project.repositories[0]?.snapshot?.defaultBranch ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{project.snapshot.defaultBranch}
|
||||
{project.repositories[0].snapshot.defaultBranch}
|
||||
</span>
|
||||
) : null}
|
||||
{project._count.notes > 0 ? (
|
||||
|
||||
@@ -140,7 +140,9 @@ export function ProjectBoard({
|
||||
(project.summary ?? "").toLowerCase().includes(needle) ||
|
||||
(project.description ?? "").toLowerCase().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.
|
||||
noteHitProjectIds.has(project.id)
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { CiBadge } from "@/components/CiBadge";
|
||||
import { Sparkline } from "@/components/Sparkline";
|
||||
import { StatusSelect } from "@/components/StatusSelect";
|
||||
import { repoTitle } from "@/lib/git/meta";
|
||||
import { PRIORITY_META, accentOf } from "@/lib/status";
|
||||
import { cn, formatRelative, truncate } from "@/lib/utils";
|
||||
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 }) {
|
||||
const accent = accentOf(project.accent);
|
||||
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 [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">
|
||||
<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="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>
|
||||
) : null}
|
||||
|
||||
@@ -173,11 +191,8 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{project.syncError ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-rose-300/80"
|
||||
title={project.syncError}
|
||||
>
|
||||
{syncError ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-rose-300/80" title={syncError}>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Sync-Fehler
|
||||
</span>
|
||||
|
||||
@@ -87,11 +87,14 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
accent: project.accent,
|
||||
tags: project.tags.join(", "),
|
||||
favorite: project.favorite,
|
||||
connectionId: project.connectionId ?? "",
|
||||
repoUrl: project.repoUrl ?? "",
|
||||
repoOwner: project.repoOwner ?? "",
|
||||
repoName: project.repoName ?? "",
|
||||
autoSync: project.autoSync,
|
||||
// Der Dialog bearbeitet das fuehrende Repository. Weitere
|
||||
// verwaltet das Git-Panel – hier wuerde eine Liste den Dialog
|
||||
// sprengen, in dem es eigentlich um das Projekt geht.
|
||||
connectionId: project.repositories[0]?.connectionId ?? "",
|
||||
repoUrl: project.repositories[0]?.repoUrl ?? "",
|
||||
repoOwner: project.repositories[0]?.repoOwner ?? "",
|
||||
repoName: project.repositories[0]?.repoName ?? "",
|
||||
autoSync: project.repositories[0]?.autoSync ?? true,
|
||||
progressFromTasks: project.progressFromTasks,
|
||||
}
|
||||
: EMPTY,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||
import { StatusSelect } from "@/components/StatusSelect";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import { repoTitle } from "@/lib/git/meta";
|
||||
import { PRIORITY_META, accentOf } from "@/lib/status";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||
@@ -132,24 +133,22 @@ export function ProjectHeader({
|
||||
angelegt {formatDate(project.createdAt)}
|
||||
</span>
|
||||
<span style={{ color: priority.color }}>Prioritaet: {priority.label}</span>
|
||||
{project.connection ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{project.connection.name}
|
||||
<span className="text-slate-700">·</span>
|
||||
{project.connection.provider}
|
||||
</span>
|
||||
) : null}
|
||||
{project.repoUrl ? (
|
||||
{/* Alle Repositories verlinken – bei einem einzigen sieht das aus
|
||||
wie vorher, bei mehreren steht jedes fuer sich. */}
|
||||
{project.repositories
|
||||
.filter((repository) => repository.repoUrl)
|
||||
.map((repository) => (
|
||||
<a
|
||||
href={project.repoUrl}
|
||||
key={repository.id}
|
||||
href={repository.repoUrl!}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
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" />
|
||||
</a>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
|
||||
{project.tags.length > 0 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user