From 826706aecaf7b451c9138a2019ab35e475a73e82 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 19:34:40 +0200 Subject: [PATCH] feat(ui): Projektdetail mit Notizen und Git-Panel 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) --- src/app/projects/[id]/page.tsx | 73 +++++ src/components/GitPanel.tsx | 476 +++++++++++++++++++++++++++++++ src/components/NotesPanel.tsx | 214 ++++++++++++++ src/components/ProjectHeader.tsx | 227 +++++++++++++++ 4 files changed, 990 insertions(+) create mode 100644 src/app/projects/[id]/page.tsx create mode 100644 src/components/GitPanel.tsx create mode 100644 src/components/NotesPanel.tsx create mode 100644 src/components/ProjectHeader.tsx diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx new file mode 100644 index 0000000..317e32d --- /dev/null +++ b/src/app/projects/[id]/page.tsx @@ -0,0 +1,73 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { FileText } from "lucide-react"; +import { GitPanel } from "@/components/GitPanel"; +import { NotesPanel } from "@/components/NotesPanel"; +import { ProjectHeader } from "@/components/ProjectHeader"; +import { prisma } from "@/lib/db"; +import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects"; +import type { ConnectionSafe, ProjectDetail } from "@/types"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ id: string }> }; + +export async function generateMetadata({ params }: PageProps): Promise { + const { id } = await params; + const project = await prisma.project.findUnique({ + where: { id }, + select: { name: true, summary: true }, + }); + return { + title: project?.name ?? "Projekt", + description: project?.summary ?? undefined, + }; +} + +export default async function ProjectPage({ params }: PageProps) { + const { id } = await params; + + const [project, connections] = await Promise.all([ + prisma.project.findUnique({ where: { id }, include: projectDetailInclude }), + prisma.gitConnection.findMany({ select: connectionSafeSelect, orderBy: { name: "asc" } }), + ]); + + if (!project) notFound(); + + const detail = project as unknown as ProjectDetail; + + return ( +
+ + +
+
+
+

+ + Beschreibung +

+ {detail.description ? ( +

+ {detail.description} +

+ ) : ( +

+ Keine Beschreibung hinterlegt. Ueber „Bearbeiten“ kannst du die Idee ausformulieren. +

+ )} +
+ + +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx new file mode 100644 index 0000000..c6afbdc --- /dev/null +++ b/src/components/GitPanel.tsx @@ -0,0 +1,476 @@ +"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("branches"); + const [syncing, setSyncing] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + + const snapshot = project.snapshot; + const branches = asArray(snapshot?.branches); + const tags = asArray(snapshot?.tags); + const commits = asArray(snapshot?.commits); + const releases = asArray(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 ( +
+ +

Kein Repository verknuepft

+

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

+
+ ); + } + + 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 ( +
+ {/* Kopf mit Sync */} +
+
+
+

+ {snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`} +

+ {snapshot?.visibility ? ( + + {snapshot.visibility === "private" ? ( + + ) : ( + + )} + {snapshot.visibility} + + ) : null} + {snapshot?.htmlUrl ? ( + + + + ) : null} +
+

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

+
+ + +
+ + {message ? ( +

+ {message.text} +

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

+ + {project.syncError} +

+ ) : null} + + {snapshot ? ( + <> + {/* Kennzahlen */} +
+ + + + + + 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`} + /> +
+ + {/* Letzter Commit + Version */} +
+
+

+ Letzter Commit +

+ {snapshot.lastCommitSha ? ( + <> +

+ {snapshot.lastCommitMessage} +

+

+ + {snapshot.lastCommitSha.slice(0, 7)} + + {snapshot.lastCommitAuthor ?? "unbekannt"} + {formatRelative(snapshot.lastCommitAt)} +

+ + ) : ( +

Keine Commit-Daten.

+ )} +
+ +
+

+ Aktuelle Version +

+
+ + {snapshot.latestTag ?? snapshot.latestRelease ?? "–"} + + {snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? ( + {snapshot.latestRelease} + ) : null} +
+

+ {snapshot.latestReleaseAt + ? `Release vom ${formatDateTime(snapshot.latestReleaseAt)}` + : `${snapshot.tagCount} Tag${snapshot.tagCount === 1 ? "" : "s"} im Repository`} +

+
+
+ + {/* Sprachen */} + {languageTotal > 0 ? ( +
+

+ Sprachen +

+
+ {topLanguages.map(([name, bytes], index) => ( + + ))} +
+
+ {topLanguages.map(([name, bytes], index) => ( + + + {name} + + {Math.round((bytes / languageTotal) * 100)}% + + + ))} +
+
+ ) : null} + + {/* Listen */} +
+
+ {TABS.map(({ key, label, icon: Icon }) => { + const count = + key === "branches" + ? branches.length + : key === "tags" + ? tags.length + : key === "commits" + ? commits.length + : releases.length; + return ( + + ); + })} +
+ +
+ {tab === "branches" ? : null} + {tab === "tags" ? : null} + {tab === "commits" ? : null} + {tab === "releases" ? : null} +
+
+ + ) : ( +
+ Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen. +
+ )} +
+ ); +} + +const LANGUAGE_COLORS = ["#a78bfa", "#22d3ee", "#34d399", "#fbbf24", "#f472b6"]; + +function Metric({ + icon: Icon, + label, + value, +}: { + icon: typeof Star; + label: string; + value: string; +}) { + return ( +
+

+ + {label} +

+

{value}

+
+ ); +} + +function Row({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Empty({ text }: { text: string }) { + return

{text}

; +} + +function BranchList({ branches }: { branches: BranchInfo[] }) { + if (branches.length === 0) return ; + return ( +
+ {branches.map((branch) => ( + +
+ + {branch.name} + {branch.isDefault ? ( + + default + + ) : null} + {branch.protected ? ( + + ) : null} +
+
+ {branch.lastCommitAt ? ( + {formatRelative(branch.lastCommitAt)} + ) : null} + {branch.sha ? ( + {branch.sha.slice(0, 7)} + ) : null} +
+
+ ))} +
+ ); +} + +function TagList({ tags }: { tags: TagInfo[] }) { + if (tags.length === 0) return ; + return ( +
+ {tags.map((tag) => ( + +
+ + {tag.name} + {tag.message ? ( + {tag.message} + ) : null} +
+
+ {tag.date ? {formatRelative(tag.date)} : null} + {tag.sha ? ( + {tag.sha.slice(0, 7)} + ) : null} +
+
+ ))} +
+ ); +} + +function CommitList({ commits }: { commits: CommitInfo[] }) { + if (commits.length === 0) return ; + return ( +
+ {commits.map((commit) => ( + +
+

{commit.message}

+

+ {commit.shortSha} + {commit.author ?? "unbekannt"} +

+
+
+ {formatRelative(commit.date)} + {commit.url ? ( + + + + ) : null} +
+
+ ))} +
+ ); +} + +function ReleaseList({ releases }: { releases: ReleaseInfo[] }) { + if (releases.length === 0) return ; + return ( +
+ {releases.map((release) => ( + +
+ + {release.name} + + {release.tag} + + {release.prerelease ? ( + + pre + + ) : null} + {release.draft ? ( + + draft + + ) : null} +
+
+ {formatRelative(release.publishedAt)} + {release.url ? ( + + + + ) : null} +
+
+ ))} +
+ ); +} diff --git a/src/components/NotesPanel.tsx b/src/components/NotesPanel.tsx new file mode 100644 index 0000000..7e53a3c --- /dev/null +++ b/src/components/NotesPanel.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, NotebookPen, Pencil, Pin, PinOff, Plus, Trash2, X } from "lucide-react"; +import type { Note } from "@prisma/client"; +import { cn, formatDateTime, formatRelative } from "@/lib/utils"; + +/** Nur als "bearbeitet" zaehlen, wenn wirklich spaeter geaendert wurde. */ +function wasEdited(note: Note): boolean { + const created = new Date(note.createdAt).getTime(); + const updated = new Date(note.updatedAt).getTime(); + return updated - created > 1500; +} + +export function NotesPanel({ projectId, notes }: { projectId: string; notes: Note[] }) { + const router = useRouter(); + const [composerOpen, setComposerOpen] = useState(false); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [busyId, setBusyId] = useState(null); + + function resetComposer() { + setTitle(""); + setContent(""); + setEditingId(null); + setComposerOpen(false); + setError(null); + } + + async function save(event: React.FormEvent) { + event.preventDefault(); + if (!content.trim()) return; + setSaving(true); + setError(null); + + try { + const response = await fetch( + editingId ? `/api/notes/${editingId}` : `/api/projects/${projectId}/notes`, + { + method: editingId ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, content }), + }, + ); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.error ?? "Speichern fehlgeschlagen."); + } + resetComposer(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setSaving(false); + } + } + + async function togglePin(note: Note) { + setBusyId(note.id); + await fetch(`/api/notes/${note.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pinned: !note.pinned }), + }).catch(() => undefined); + setBusyId(null); + router.refresh(); + } + + async function remove(note: Note) { + if (!window.confirm("Notiz wirklich loeschen?")) return; + setBusyId(note.id); + await fetch(`/api/notes/${note.id}`, { method: "DELETE" }).catch(() => undefined); + setBusyId(null); + router.refresh(); + } + + function startEdit(note: Note) { + setEditingId(note.id); + setTitle(note.title ?? ""); + setContent(note.content); + setComposerOpen(true); + } + + return ( +
+
+

+ + Notizen + + {notes.length} + +

+ {!composerOpen ? ( + + ) : null} +
+ + {composerOpen ? ( +
+ setTitle(event.target.value)} + maxLength={160} + /> +