Vier Spalten, Karten per Ziehen verschiebbar - zwischen den Spalten und innerhalb einer Spalte. Waehrend des Ziehens fuehrt der oertliche Zustand, danach wird die neue Ordnung als Ganzes gespeichert; scheitert das, springt das Brett auf den Serverstand zurueck. Details: - Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den Dialog oeffnet. - Der Spaltenwechsel passiert schon beim Darueberziehen, nicht erst beim Ablegen - sonst wirkt das Brett traege. - Der Serverstand wird nur uebernommen, wenn gerade nicht gezogen wird, sonst spraenge die Karte mitten in der Bewegung zurueck. - Faelligkeiten faerben sich: ueberfaellig rot, heute gelb, demnaechst gedaempft. Ist "Fortschritt aus Aufgaben" eingeschaltet, wird der Regler gesperrt statt einen Wert anzubieten, der beim naechsten Aufgabenwechsel ueberschrieben wuerde - im Dialog wie im Kopfbereich. Die Projektkarte zeigt das Verhaeltnis als "3/8". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
220 lines
7.9 KiB
TypeScript
220 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
import {
|
|
AlertTriangle,
|
|
CheckSquare,
|
|
GitBranch,
|
|
GitCommitHorizontal,
|
|
NotebookPen,
|
|
Star,
|
|
Tag,
|
|
} from "lucide-react";
|
|
import { CiBadge } from "@/components/CiBadge";
|
|
import { Sparkline } from "@/components/Sparkline";
|
|
import { StatusSelect } from "@/components/StatusSelect";
|
|
import { PRIORITY_META, accentOf } from "@/lib/status";
|
|
import { cn, formatRelative, truncate } from "@/lib/utils";
|
|
import { asArray, type ProjectListItem } from "@/types";
|
|
|
|
const PROVIDER_LABEL: Record<string, string> = {
|
|
GITHUB: "GitHub",
|
|
GITLAB: "GitLab",
|
|
GITEA: "Gitea",
|
|
};
|
|
|
|
/**
|
|
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
|
|
* interaktive Elemente liegen darueber und bekommen pointer-events zurueck.
|
|
*/
|
|
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;
|
|
const activity = asArray<number>(snapshot?.commitActivity);
|
|
const [favorite, setFavorite] = useState(project.favorite);
|
|
|
|
async function toggleFavorite() {
|
|
const next = !favorite;
|
|
setFavorite(next);
|
|
try {
|
|
const response = await fetch(`/api/projects/${project.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ favorite: next }),
|
|
});
|
|
if (!response.ok) throw new Error("failed");
|
|
} catch {
|
|
setFavorite(!next);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<article
|
|
className="glass card-glow fade-up group relative overflow-hidden p-5"
|
|
style={
|
|
{
|
|
"--accent-from": accent.from,
|
|
"--accent-to": accent.to,
|
|
animationDelay: `${Math.min(index, 12) * 40}ms`,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
|
|
|
|
<Link
|
|
href={`/projects/${project.id}`}
|
|
className="absolute inset-0 z-0 rounded-[var(--radius-card)] focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60"
|
|
aria-label={`Projekt ${project.name} oeffnen`}
|
|
/>
|
|
|
|
<div className="pointer-events-none relative z-10">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200">
|
|
{project.name}
|
|
</h3>
|
|
{favorite ? (
|
|
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-300 text-amber-300" />
|
|
) : null}
|
|
</div>
|
|
<p className="mt-1 line-clamp-2 min-h-[2.4rem] text-sm leading-snug text-slate-400">
|
|
{project.summary ? truncate(project.summary, 140) : "Noch keine Kurzbeschreibung."}
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={toggleFavorite}
|
|
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
|
className={cn(
|
|
"pointer-events-auto shrink-0 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 sm:opacity-0 sm:group-hover:opacity-100",
|
|
)}
|
|
>
|
|
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Fortschritt */}
|
|
<div className="mt-4">
|
|
<div className="mb-1.5 flex items-center justify-between text-[11px] text-slate-500">
|
|
<span>Fortschritt</span>
|
|
<span className="tabular-nums text-slate-400">{project.progress}%</span>
|
|
</div>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/6">
|
|
<div
|
|
className="accent-bar h-full rounded-full transition-[width] duration-700"
|
|
style={{ width: `${Math.max(2, project.progress)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Commit-Aktivitaet */}
|
|
{activity.some((value) => value > 0) ? (
|
|
<div className="mt-3">
|
|
<Sparkline values={activity} height={20} />
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Git-Kurzinfo */}
|
|
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
|
|
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact />
|
|
|
|
{project.connection ? (
|
|
<span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" />
|
|
{PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider}
|
|
</span>
|
|
) : null}
|
|
|
|
{snapshot?.defaultBranch ? (
|
|
<span className="inline-flex items-center gap-1.5 font-mono">
|
|
<GitBranch className="h-3.5 w-3.5 text-slate-500" />
|
|
{snapshot.defaultBranch}
|
|
{snapshot.branchCount > 1 ? (
|
|
<span className="text-slate-600">+{snapshot.branchCount - 1}</span>
|
|
) : null}
|
|
</span>
|
|
) : null}
|
|
|
|
{snapshot?.latestTag ? (
|
|
<span className="inline-flex items-center gap-1.5 font-mono">
|
|
<Tag className="h-3.5 w-3.5 text-slate-500" />
|
|
{snapshot.latestTag}
|
|
</span>
|
|
) : null}
|
|
|
|
{snapshot?.lastCommitAt ? (
|
|
<span className="inline-flex items-center gap-1.5" suppressHydrationWarning>
|
|
<GitCommitHorizontal className="h-3.5 w-3.5 text-slate-500" />
|
|
{formatRelative(snapshot.lastCommitAt)}
|
|
</span>
|
|
) : null}
|
|
|
|
{project._count.tasks > 0 ? (
|
|
<span
|
|
className="inline-flex items-center gap-1.5"
|
|
title={`${project.tasks.length} von ${project._count.tasks} Aufgaben erledigt`}
|
|
>
|
|
<CheckSquare className="h-3.5 w-3.5 text-slate-500" />
|
|
{project.tasks.length}/{project._count.tasks}
|
|
</span>
|
|
) : null}
|
|
|
|
{project._count.notes > 0 ? (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<NotebookPen className="h-3.5 w-3.5 text-slate-500" />
|
|
{project._count.notes}
|
|
</span>
|
|
) : null}
|
|
|
|
{project.syncError ? (
|
|
<span
|
|
className="inline-flex items-center gap-1.5 text-rose-300/80"
|
|
title={project.syncError}
|
|
>
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
Sync-Fehler
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
{project.tags.length > 0 ? (
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
{project.tags.slice(0, 4).map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="rounded-md bg-white/5 px-1.5 py-0.5 text-[10px] font-medium text-slate-400"
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{project.tags.length > 4 ? (
|
|
<span className="px-1 text-[10px] text-slate-600">+{project.tags.length - 4}</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-4 flex items-center justify-between gap-2 border-t border-white/6 pt-3">
|
|
<div className="pointer-events-auto">
|
|
<StatusSelect projectId={project.id} status={project.status} variant="compact" />
|
|
</div>
|
|
<span
|
|
className="text-[10px] uppercase tracking-wider"
|
|
style={{ color: priority.color }}
|
|
title={`Prioritaet: ${priority.label}`}
|
|
>
|
|
{priority.label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|