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) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 19:34:40 +02:00
co-authored by Claude Opus 5
parent 62b5c5bfa6
commit 826706aeca
4 changed files with 990 additions and 0 deletions
+73
View File
@@ -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<Metadata> {
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 (
<div className="space-y-6 pt-4">
<ProjectHeader
project={detail}
connections={connections as unknown as ConnectionSafe[]}
/>
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5">
<section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<FileText className="h-4 w-4 text-cyan-300" />
Beschreibung
</h2>
{detail.description ? (
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
{detail.description}
</p>
) : (
<p className="mt-3 text-sm text-slate-500">
Keine Beschreibung hinterlegt. Ueber „Bearbeiten“ kannst du die Idee ausformulieren.
</p>
)}
</section>
<NotesPanel projectId={detail.id} notes={detail.notes} />
</div>
<div className="lg:col-span-7">
<GitPanel project={detail} />
</div>
</div>
</div>
);
}
+476
View File
@@ -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<TabKey>("branches");
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const snapshot = project.snapshot;
const branches = asArray<BranchInfo>(snapshot?.branches);
const tags = asArray<TagInfo>(snapshot?.tags);
const commits = asArray<CommitInfo>(snapshot?.commits);
const releases = asArray<ReleaseInfo>(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 (
<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.
</p>
</div>
);
}
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 (
<div className="space-y-4">
{/* 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}`}
</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">
{snapshot.visibility === "private" ? (
<Lock className="h-2.5 w-2.5" />
) : (
<ShieldCheck className="h-2.5 w-2.5" />
)}
{snapshot.visibility}
</span>
) : null}
{snapshot?.htmlUrl ? (
<a
href={snapshot.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="text-slate-500 transition-colors hover:text-violet-300"
aria-label="Repository oeffnen"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt
? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}`
: "Noch nie synchronisiert."}
</p>
</div>
<button type="button" className="btn btn-primary" onClick={sync} disabled={syncing}>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Jetzt synchronisieren
</button>
</div>
{message ? (
<p
className={cn(
"rounded-xl border px-3 py-2 text-sm",
message.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{message.text}
</p>
) : null}
{project.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>
</p>
) : null}
{snapshot ? (
<>
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric icon={Star} label="Stars" value={formatNumber(snapshot.stars)} />
<Metric icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} />
<Metric
icon={GitPullRequest}
label="Offene PRs/MRs"
value={formatNumber(snapshot.openPullRequests)}
/>
<Metric
icon={AlertTriangle}
label="Offene Issues"
value={formatNumber(snapshot.openIssues)}
/>
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} />
<Metric
icon={HardDrive}
label="Groesse"
value={snapshot.sizeKb > 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`}
/>
</div>
{/* Letzter Commit + Version */}
<div className="grid gap-3 lg:grid-cols-2">
<div className="glass p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
Letzter Commit
</p>
{snapshot.lastCommitSha ? (
<>
<p className="mt-2 line-clamp-2 text-sm text-slate-200">
{snapshot.lastCommitMessage}
</p>
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded bg-white/6 px-1.5 py-0.5 font-mono text-[11px] text-violet-200">
{snapshot.lastCommitSha.slice(0, 7)}
</span>
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span>
</p>
</>
) : (
<p className="mt-2 text-sm text-slate-500">Keine Commit-Daten.</p>
)}
</div>
<div className="glass p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
Aktuelle Version
</p>
<div className="mt-2 flex flex-wrap items-baseline gap-3">
<span className="font-mono text-2xl text-white">
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
</span>
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? (
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span>
) : null}
</div>
<p className="mt-1 text-xs text-slate-500">
{snapshot.latestReleaseAt
? `Release vom ${formatDateTime(snapshot.latestReleaseAt)}`
: `${snapshot.tagCount} Tag${snapshot.tagCount === 1 ? "" : "s"} im Repository`}
</p>
</div>
</div>
{/* Sprachen */}
{languageTotal > 0 ? (
<div className="glass p-4">
<p className="mb-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
Sprachen
</p>
<div className="flex h-2 overflow-hidden rounded-full bg-white/6">
{topLanguages.map(([name, bytes], index) => (
<span
key={name}
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
style={{
width: `${(bytes / languageTotal) * 100}%`,
backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
}}
/>
))}
</div>
<div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-slate-400">
{topLanguages.map(([name, bytes], index) => (
<span key={name} className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }}
/>
{name}
<span className="text-slate-600">
{Math.round((bytes / languageTotal) * 100)}%
</span>
</span>
))}
</div>
</div>
) : null}
{/* Listen */}
<div className="glass overflow-hidden">
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
{TABS.map(({ key, label, icon: Icon }) => {
const count =
key === "branches"
? branches.length
: key === "tags"
? tags.length
: key === "commits"
? commits.length
: releases.length;
return (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs transition-colors",
tab === key
? "bg-white/9 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200",
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
<span className="tabular-nums text-slate-600">{count}</span>
</button>
);
})}
</div>
<div className="max-h-[26rem] overflow-y-auto">
{tab === "branches" ? <BranchList branches={branches} /> : null}
{tab === "tags" ? <TagList tags={tags} /> : null}
{tab === "commits" ? <CommitList commits={commits} /> : null}
{tab === "releases" ? <ReleaseList releases={releases} /> : null}
</div>
</div>
</>
) : (
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
</div>
)}
</div>
);
}
const LANGUAGE_COLORS = ["#a78bfa", "#22d3ee", "#34d399", "#fbbf24", "#f472b6"];
function Metric({
icon: Icon,
label,
value,
}: {
icon: typeof Star;
label: string;
value: string;
}) {
return (
<div className="glass-soft px-3 py-2.5">
<p className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-slate-500">
<Icon className="h-3 w-3" />
{label}
</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
</div>
);
}
function Row({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-white/4 px-4 py-2.5 last:border-b-0 hover:bg-white/3">
{children}
</div>
);
}
function Empty({ text }: { text: string }) {
return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>;
}
function BranchList({ branches }: { branches: BranchInfo[] }) {
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
return (
<div>
{branches.map((branch) => (
<Row key={branch.name}>
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{branch.name}</span>
{branch.isDefault ? (
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
default
</span>
) : null}
{branch.protected ? (
<Lock className="h-3 w-3 shrink-0 text-amber-300/70" />
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{branch.lastCommitAt ? (
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span>
) : null}
{branch.sha ? (
<span className="font-mono text-slate-600">{branch.sha.slice(0, 7)}</span>
) : null}
</div>
</Row>
))}
</div>
);
}
function TagList({ tags }: { tags: TagInfo[] }) {
if (tags.length === 0) return <Empty text="Keine Tags vorhanden." />;
return (
<div>
{tags.map((tag) => (
<Row key={tag.name}>
<div className="flex min-w-0 items-center gap-2">
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{tag.name}</span>
{tag.message ? (
<span className="truncate text-[11px] text-slate-500">{tag.message}</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{tag.date ? <span suppressHydrationWarning>{formatRelative(tag.date)}</span> : null}
{tag.sha ? (
<span className="font-mono text-slate-600">{tag.sha.slice(0, 7)}</span>
) : null}
</div>
</Row>
))}
</div>
);
}
function CommitList({ commits }: { commits: CommitInfo[] }) {
if (commits.length === 0) return <Empty text="Keine Commits geladen." />;
return (
<div>
{commits.map((commit) => (
<Row key={commit.sha}>
<div className="min-w-0">
<p className="truncate text-xs text-slate-200">{commit.message}</p>
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono text-violet-300/80">{commit.shortSha}</span>
<span>{commit.author ?? "unbekannt"}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(commit.date)}</span>
{commit.url ? (
<a
href={commit.url}
target="_blank"
rel="noreferrer noopener"
className="text-slate-600 transition-colors hover:text-violet-300"
aria-label="Commit oeffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Row>
))}
</div>
);
}
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
if (releases.length === 0) return <Empty text="Keine Releases veroeffentlicht." />;
return (
<div>
{releases.map((release) => (
<Row key={`${release.tag}-${release.name}`}>
<div className="flex min-w-0 items-center gap-2">
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate text-xs text-slate-200">{release.name}</span>
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
{release.tag}
</span>
{release.prerelease ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-200">
pre
</span>
) : null}
{release.draft ? (
<span className="shrink-0 rounded bg-slate-500/15 px-1.5 py-0.5 text-[10px] text-slate-300">
draft
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(release.publishedAt)}</span>
{release.url ? (
<a
href={release.url}
target="_blank"
rel="noreferrer noopener"
className="text-slate-600 transition-colors hover:text-violet-300"
aria-label="Release oeffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Row>
))}
</div>
);
}
+214
View File
@@ -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<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<NotebookPen className="h-4 w-4 text-violet-300" />
Notizen
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{notes.length}
</span>
</h2>
{!composerOpen ? (
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setComposerOpen(true)}
>
<Plus className="h-3.5 w-3.5" />
Notiz
</button>
) : null}
</div>
{composerOpen ? (
<form onSubmit={save} className="glass fade-up space-y-3 p-4">
<input
className="field"
placeholder="Titel (optional)"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={160}
/>
<textarea
className="field min-h-32 resize-y font-mono text-[13px] leading-relaxed"
placeholder="Gedanken, Todos, Snippets… Zeilenumbrueche bleiben erhalten."
value={content}
onChange={(event) => setContent(event.target.value)}
autoFocus
required
/>
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={resetComposer}>
<X className="h-4 w-4" />
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving || !content.trim()}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{editingId ? "Aktualisieren" : "Speichern"}
</button>
</div>
</form>
) : null}
{notes.length === 0 && !composerOpen ? (
<div className="glass-soft px-4 py-10 text-center text-sm text-slate-500">
Noch keine Notizen. Halte fest, was dir zu diesem Projekt durch den Kopf geht.
</div>
) : null}
<div className="space-y-3">
{notes.map((note) => (
<article
key={note.id}
className={cn(
"glass group relative p-4 transition-colors",
note.pinned && "ring-1 ring-inset ring-amber-400/25",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{note.title ? (
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-100">
{note.pinned ? <Pin className="h-3.5 w-3.5 text-amber-300" /> : null}
{note.title}
</h3>
) : null}
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
{note.content}
</p>
<p className="mt-2.5 text-[11px] text-slate-600" suppressHydrationWarning>
{formatDateTime(note.createdAt)}
{wasEdited(note) ? ` · bearbeitet ${formatRelative(note.updatedAt)}` : null}
</p>
</div>
<div className="flex shrink-0 items-center gap-1 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100">
{busyId === note.id ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
) : (
<>
<button
type="button"
onClick={() => togglePin(note)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-amber-300"
aria-label={note.pinned ? "Loesen" : "Anpinnen"}
title={note.pinned ? "Loesen" : "Anpinnen"}
>
{note.pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => startEdit(note)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
aria-label="Bearbeiten"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => remove(note)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
aria-label="Loeschen"
title="Loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</article>
))}
</div>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
CalendarDays,
ExternalLink,
Loader2,
Pencil,
Star,
Trash2,
} from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog";
import { StatusSelect } from "@/components/StatusSelect";
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 [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>
{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 ? (
<a
href={project.repoUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200"
>
Repository
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</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
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>Fortschritt</span>
<span className="tabular-nums text-slate-300">{progress}%</span>
</div>
<div className="relative">
<div className="h-2 w-full overflow-hidden rounded-full bg-white/6">
<div
className="accent-bar h-full rounded-full transition-[width] duration-300"
style={{ width: `${Math.max(1, progress)}%` }}
/>
</div>
<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"
className="absolute inset-0 h-2 w-full cursor-pointer opacity-0"
/>
</div>
</div>
<ProjectDialog
open={editOpen}
onClose={() => setEditOpen(false)}
connections={connections}
project={project}
/>
</header>
);
}