Files
nebula/src/components/ProjectHeader.tsx
T
florian.wildeandClaude Opus 5 b882c870c3 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>
2026-07-26 13:24:08 +02:00

290 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
CalendarDays,
CheckSquare,
Download,
ExternalLink,
FileJson,
FileText,
Loader2,
Pencil,
Star,
Trash2,
} from "lucide-react";
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";
export function ProjectHeader({
project,
connections,
}: {
project: ProjectDetail;
connections: ConnectionSafe[];
}) {
const router = useRouter();
const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const exportButtonRef = useRef<HTMLButtonElement>(null);
const [favorite, setFavorite] = useState(project.favorite);
const [progress, setProgress] = useState(project.progress);
const [deleting, setDeleting] = useState(false);
async function patch(payload: Record<string, unknown>) {
await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => undefined);
router.refresh();
}
async function remove() {
if (
!window.confirm(
`„${project.name}“ mit allen Notizen unwiderruflich loeschen?`,
)
) {
return;
}
setDeleting(true);
const response = await fetch(`/api/projects/${project.id}`, { method: "DELETE" });
if (response.ok) {
router.push("/");
router.refresh();
} else {
setDeleting(false);
}
}
return (
<header
className="glass relative overflow-hidden p-6"
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
<span className="accent-bar absolute inset-x-0 top-0 h-[3px]" aria-hidden="true" />
<div
className="pointer-events-none absolute -right-20 -top-24 h-64 w-64 rounded-full opacity-20 blur-3xl"
style={{ background: `radial-gradient(circle, ${accent.from}, transparent 70%)` }}
aria-hidden="true"
/>
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurueck zum Dashboard
</Link>
<div className="mt-3 flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{project.name}
</h1>
<button
type="button"
onClick={() => {
const next = !favorite;
setFavorite(next);
void patch({ favorite: next });
}}
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={cn(
"rounded-lg p-1.5 transition-colors",
favorite
? "text-amber-300 hover:bg-amber-400/10"
: "text-slate-600 hover:bg-white/8 hover:text-amber-300",
)}
>
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
</button>
</div>
<p className="mt-1 font-mono text-xs text-slate-600">/{project.slug}</p>
{project.summary ? (
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
{project.summary}
</p>
) : null}
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-500">
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5" />
angelegt {formatDate(project.createdAt)}
</span>
<span style={{ color: priority.color }}>Prioritaet: {priority.label}</span>
{/* 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
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"
>
{project.repositories.length === 1 ? "Repository" : repoTitle(repository)}
<ExternalLink className="h-3 w-3" />
</a>
))}
</div>
{project.tags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{project.tags.map((tag) => (
<span
key={tag}
className="rounded-md bg-white/5 px-2 py-0.5 text-[11px] text-slate-400"
>
#{tag}
</span>
))}
</div>
) : null}
</div>
<div className="flex flex-col items-end gap-3">
<StatusSelect projectId={project.id} status={project.status} />
<div className="flex gap-2">
<button
ref={exportButtonRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setExportOpen((value) => !value)}
aria-haspopup="menu"
aria-expanded={exportOpen}
title="Exportieren"
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={remove}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Loeschen
</button>
</div>
</div>
</div>
{/* Fortschritt direkt regelbar */}
<div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1.5">
Fortschritt
{project.progressFromTasks ? (
<span className="inline-flex items-center gap-1 rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
<CheckSquare className="h-2.5 w-2.5" />
aus Aufgaben
</span>
) : null}
</span>
<span className="tabular-nums text-slate-300">{progress}%</span>
</div>
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
<input
type="range"
min={0}
max={100}
step={5}
value={progress}
onChange={(event) => setProgress(Number(event.target.value))}
onMouseUp={() => patch({ progress })}
onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen"
// Wird der Wert aus Aufgaben abgeleitet, waere ein Zug hier
// wirkungslos – er wuerde beim naechsten Aufgabenwechsel wieder
// ueberschrieben.
disabled={project.progressFromTasks}
className={cn("range", project.progressFromTasks && "cursor-not-allowed opacity-50")}
style={{ "--fill": `${progress}%` } as React.CSSProperties}
/>
</div>
<Popover
anchorRef={exportButtonRef}
open={exportOpen}
onClose={() => setExportOpen(false)}
width={250}
>
<a
href={`/api/projects/${project.id}/export`}
download
role="menuitem"
onClick={() => setExportOpen(false)}
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<FileJson className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
<span>
Als JSON
<span className="mt-0.5 block text-[11px] text-slate-500">
Zum Wiedereinlesen, mit Notizen
</span>
</span>
</a>
<a
href={`/api/projects/${project.id}/export?format=md`}
download
role="menuitem"
onClick={() => setExportOpen(false)}
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
<span>
Als Markdown
<span className="mt-0.5 block text-[11px] text-slate-500">
Lesbar, inklusive Git-Stand
</span>
</span>
</a>
</Popover>
<ProjectDialog
open={editOpen}
onClose={() => setEditOpen(false)}
connections={connections}
project={project}
/>
</header>
);
}