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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user