diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 043f60b..3fdbfc6 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -87,7 +87,10 @@ export default async function ProjectPage({ params }: PageProps) {
- +
diff --git a/src/components/ConnectionsManager.tsx b/src/components/ConnectionsManager.tsx index 19cb374..3cc6518 100644 --- a/src/components/ConnectionsManager.tsx +++ b/src/components/ConnectionsManager.tsx @@ -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 ) : null}
-
Projekte
+
Repositories
- {connection._count?.projects ?? 0} + {connection._count?.repositories ?? 0}
{connection.lastCheckedAt ? ( diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 8b9f782..0a7caf1 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -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("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(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(snapshot?.branches); const tags = asArray(snapshot?.tags); const commits = asArray(snapshot?.commits); @@ -89,13 +110,15 @@ export function GitPanel({ project }: { project: ProjectDetail }) { const issues = asArray(snapshot?.issues); const activity = asArray(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 ( -
- -

Kein Repository verknuepft

-

- Hinterlege im Projekt eine Repository-URL und waehle eine Git-Verbindung, um - Branches, Tags, Commits und Releases hier zu sehen. -

-
+ <> +
+ +

Kein Repository verknuepft

+

+ Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen. + Ein Projekt darf auch mehrere haben – etwa Frontend und Backend. +

+ +
+ setAddOpen(false)} + projectId={project.id} + connections={connections} + /> + ); } @@ -129,12 +177,53 @@ export function GitPanel({ project }: { project: ProjectDetail }) { return (
+ {/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine + Leiste mit genau einem Knopf nur Ballast. */} + {repositories.length > 1 ? ( +
+ {repositories.map((entry) => { + const aktiv = entry.id === repository.id; + return ( + + ); + })} + +
+ ) : null} + {/* Kopf mit Sync */}

- {snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`} + {snapshot?.fullName ?? repoTitle(repository)}

{snapshot?.visibility ? ( @@ -160,22 +249,67 @@ export function GitPanel({ project }: { project: ProjectDetail }) {

- {project.lastSyncedAt - ? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}` + {repository.lastSyncedAt + ? `Zuletzt synchronisiert ${formatRelative(repository.lastSyncedAt)} · ${formatDateTime(repository.lastSyncedAt)}` : "Noch nie synchronisiert."}

- ) : ( - + )} - Jetzt synchronisieren - + + +
+ setAddOpen(false)} + projectId={project.id} + connections={connections} + /> + {message ? (

) : null} - {project.syncError && !message ? ( + {repository.syncError && !message ? (

- {project.syncError} + {repository.syncError}

) : null} diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx index 020910b..f5c94d9 100644 --- a/src/components/KanbanBoard.tsx +++ b/src/components/KanbanBoard.tsx @@ -256,11 +256,12 @@ function KanbanCard({
- - {project.snapshot?.defaultBranch ? ( + {/* Auf der schmalen Kanban-Karte nur das fuehrende Repository. */} + + {project.repositories[0]?.snapshot?.defaultBranch ? ( - {project.snapshot.defaultBranch} + {project.repositories[0].snapshot.defaultBranch} ) : null} {project._count.notes > 0 ? ( diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx index 74d4c48..4ce84e4 100644 --- a/src/components/ProjectBoard.tsx +++ b/src/components/ProjectBoard.tsx @@ -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) ); diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index 9f199e7..1e24545 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -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 = { 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(snapshot?.commitActivity); const [favorite, setFavorite] = useState(project.favorite); @@ -125,10 +133,20 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
- {project.connection ? ( + {leading?.connection ? ( + ) : null} + + {weitere > 0 ? ( + repoTitle(repo)).join(", ")} + > + +{weitere} Repo + {weitere === 1 ? "" : "s"} ) : null} @@ -173,11 +191,8 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; ) : null} - {project.syncError ? ( - + {syncError ? ( + Sync-Fehler diff --git a/src/components/ProjectDialog.tsx b/src/components/ProjectDialog.tsx index 472f3ec..c963ffa 100644 --- a/src/components/ProjectDialog.tsx +++ b/src/components/ProjectDialog.tsx @@ -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, diff --git a/src/components/ProjectHeader.tsx b/src/components/ProjectHeader.tsx index f8ac77c..439e948 100644 --- a/src/components/ProjectHeader.tsx +++ b/src/components/ProjectHeader.tsx @@ -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)} Prioritaet: {priority.label} - {project.connection ? ( - - {project.connection.name} - · - {project.connection.provider} - - ) : null} - {project.repoUrl ? ( - - Repository - - - ) : null} + {/* 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) => ( + + {project.repositories.length === 1 ? "Repository" : repoTitle(repository)} + + + ))}
{project.tags.length > 0 ? ( diff --git a/src/components/RepositoryDialog.tsx b/src/components/RepositoryDialog.tsx new file mode 100644 index 0000000..aa5a59b --- /dev/null +++ b/src/components/RepositoryDialog.tsx @@ -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(EMPTY); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const [repos, setRepos] = useState([]); + 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(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 ( + +
+
+
+ + update("label", event.target.value)} + placeholder="Frontend" + maxLength={80} + /> +

+ Ohne Angabe steht schlicht owner/name da. +

+
+ +
+ + +
+ +
+ +
+ +
+ + update("repoUrl", event.target.value)} + placeholder="https://github.com/user/repo" + /> +
+ +
+ + update("repoOwner", event.target.value)} + placeholder="wird aus der URL abgeleitet" + /> +
+ +
+ + update("repoName", event.target.value)} + placeholder="wird aus der URL abgeleitet" + /> +
+
+ + + + {pickerOpen ? ( +
+
+ + setFilter(event.target.value)} + /> +
+
    + {gefiltert.map((repo) => ( +
  • + +
  • + ))} + {gefiltert.length === 0 ? ( +
  • Keine Treffer.
  • + ) : null} +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +}