From 62b5c5bfa6a702c0bda9c78c57782feff5c86410 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 19:34:29 +0200 Subject: [PATCH] feat(ui): Dashboard mit Board, Filtern und direktem Statuswechsel - Fuenf Kennzahlen ueber dem Board: Projektzahl mit Durchschnittsfortschritt, Ideen, aktive Entwicklungen, fertige Projekte, verknuepfte Repos - Volltextsuche ueber Name, Beschreibung, Tags und Repo-Pfad - Statusfilter als Chips mit Trefferzaehler, mehrfach kombinierbar - Sortierung nach Aenderung, Anlage, Name, Fortschritt oder Prioritaet; Favoriten stehen immer oben - Zwei Ansichten: freies Raster oder nach Status gruppiert - Archiv standardmaessig ausgeblendet, per Schalter sichtbar Die Projektkarte zeigt Fortschritt, Provider, Default-Branch, letzten Tag, Alter des letzten Commits, Notizzahl und Sync-Fehler. Status und Favorit sind direkt auf der Karte aenderbar - beides optimistisch, mit Rollback wenn der Server ablehnt. Die Karte ist ein "stretched link", damit die Schaltflaechen darin nicht in einem Anker verschachtelt sind. Der Anlege-Dialog verlangt nur einen Namen; neue Projekte starten als Idee. Ist eine Git-Verbindung gewaehlt, laesst sich das Repository aus einer durchsuchbaren Liste uebernehmen. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/page.tsx | 94 ++++++ src/components/ProjectBoard.tsx | 309 +++++++++++++++++++ src/components/ProjectCard.tsx | 196 ++++++++++++ src/components/ProjectDialog.tsx | 493 +++++++++++++++++++++++++++++++ src/components/StatCard.tsx | 36 +++ src/components/StatusBadge.tsx | 28 ++ src/components/StatusSelect.tsx | 141 +++++++++ 7 files changed, 1297 insertions(+) create mode 100644 src/app/page.tsx create mode 100644 src/components/ProjectBoard.tsx create mode 100644 src/components/ProjectCard.tsx create mode 100644 src/components/ProjectDialog.tsx create mode 100644 src/components/StatCard.tsx create mode 100644 src/components/StatusBadge.tsx create mode 100644 src/components/StatusSelect.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..6565f73 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,94 @@ +import { CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react"; +import { ProjectBoard } from "@/components/ProjectBoard"; +import { StatCard } from "@/components/StatCard"; +import { prisma } from "@/lib/db"; +import { connectionSafeSelect, projectListInclude } from "@/lib/projects"; +import { STATUS_META } from "@/lib/status"; +import type { ConnectionSafe, ProjectListItem } from "@/types"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const [projects, connections] = await Promise.all([ + prisma.project.findMany({ + include: projectListInclude, + orderBy: [{ favorite: "desc" }, { updatedAt: "desc" }], + }), + prisma.gitConnection.findMany({ + select: connectionSafeSelect, + orderBy: { name: "asc" }, + }), + ]); + + const active = projects.filter((project) => project.status !== "ARCHIVED"); + const ideas = active.filter((project) => project.status === "IDEA").length; + const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length; + const done = active.filter((project) => project.status === "DONE").length; + const linked = active.filter((project) => Boolean(project.repoOwner && project.repoName)).length; + + const averageProgress = + active.length > 0 + ? Math.round(active.reduce((sum, project) => sum + project.progress, 0) / active.length) + : 0; + + return ( +
+
+

+ Vibe Coding Kontrollzentrum +

+

+ Deine Projekte auf{" "} + einen Blick +

+

+ Ideen festhalten, Status pflegen, Notizen sammeln – und live sehen, was in den + verknuepften Repositories bei GitHub, GitLab und Gitea passiert. +

+
+ +
+ + + + + +
+ + +
+ ); +} diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx new file mode 100644 index 0000000..b073845 --- /dev/null +++ b/src/components/ProjectBoard.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react"; +import type { ProjectStatus } from "@prisma/client"; +import { ProjectCard } from "@/components/ProjectCard"; +import { ProjectDialog } from "@/components/ProjectDialog"; +import { STATUS_META, STATUS_ORDER } from "@/lib/status"; +import { cn } from "@/lib/utils"; +import type { ConnectionSafe, ProjectListItem } from "@/types"; + +type SortKey = "updated" | "created" | "name" | "progress" | "priority"; +type ViewMode = "grid" | "grouped"; + +const SORT_LABEL: Record = { + updated: "Zuletzt geaendert", + created: "Neueste zuerst", + name: "Name (A–Z)", + progress: "Fortschritt", + priority: "Prioritaet", +}; + +export function ProjectBoard({ + projects, + connections, +}: { + projects: ProjectListItem[]; + connections: ConnectionSafe[]; +}) { + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState([]); + const [sort, setSort] = useState("updated"); + const [view, setView] = useState("grid"); + const [showArchived, setShowArchived] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + + const counts = useMemo(() => { + const map = new Map(); + for (const project of projects) { + map.set(project.status, (map.get(project.status) ?? 0) + 1); + } + return map; + }, [projects]); + + const visible = useMemo(() => { + const needle = query.toLowerCase().trim(); + + const filtered = projects.filter((project) => { + if (!showArchived && project.status === "ARCHIVED" && !statusFilter.includes("ARCHIVED")) { + return false; + } + if (statusFilter.length > 0 && !statusFilter.includes(project.status)) return false; + if (!needle) return true; + return ( + project.name.toLowerCase().includes(needle) || + (project.summary ?? "").toLowerCase().includes(needle) || + (project.description ?? "").toLowerCase().includes(needle) || + project.tags.some((tag) => tag.includes(needle)) || + (project.snapshot?.fullName ?? "").toLowerCase().includes(needle) + ); + }); + + const sorted = [...filtered].sort((a, b) => { + if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; + switch (sort) { + case "name": + return a.name.localeCompare(b.name, "de"); + case "progress": + return b.progress - a.progress; + case "priority": + return b.priority - a.priority; + case "created": + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + default: + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + } + }); + + return sorted; + }, [projects, query, statusFilter, sort, showArchived]); + + const grouped = useMemo(() => { + return STATUS_ORDER.map((status) => ({ + status, + items: visible.filter((project) => project.status === status), + })).filter((group) => group.items.length > 0); + }, [visible]); + + function toggleStatus(status: ProjectStatus) { + setStatusFilter((current) => + current.includes(status) + ? current.filter((entry) => entry !== status) + : [...current, status], + ); + } + + const hasFilters = query.length > 0 || statusFilter.length > 0; + + return ( +
+ {/* Steuerleiste */} +
+
+
+ + setQuery(event.target.value)} + /> + {query ? ( + + ) : null} +
+ +
+ + + +
+ + +
+ + +
+
+ +
+ + + Status + + {STATUS_ORDER.map((status) => { + const meta = STATUS_META[status]; + const active = statusFilter.includes(status); + const count = counts.get(status) ?? 0; + return ( + + ); + })} + + + + {hasFilters ? ( + + ) : null} +
+
+ + {/* Inhalt */} + {visible.length === 0 ? ( + setDialogOpen(true)} /> + ) : view === "grid" ? ( +
+ {visible.map((project, index) => ( + + ))} +
+ ) : ( +
+ {grouped.map((group) => { + const meta = STATUS_META[group.status]; + return ( +
+
+ +

+ {meta.label} +

+ + {group.items.length} + + +
+
+ {group.items.map((project, index) => ( + + ))} +
+
+ ); + })} +
+ )} + + setDialogOpen(false)} + connections={connections} + /> +
+ ); +} + +function EmptyState({ + hasFilters, + onCreate, +}: { + hasFilters: boolean; + onCreate: () => void; +}) { + return ( +
+ + + +
+

+ {hasFilters ? "Keine Treffer" : "Noch keine Projekte"} +

+

+ {hasFilters + ? "Kein Projekt passt zu Suche und Filtern. Filter anpassen oder zuruecksetzen." + : "Leg deine erste Idee an – Beschreibung, Repo und Status kannst du jederzeit nachziehen."} +

+
+ {!hasFilters ? ( + + ) : null} +
+ ); +} diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx new file mode 100644 index 0000000..fc68549 --- /dev/null +++ b/src/components/ProjectCard.tsx @@ -0,0 +1,196 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { + AlertTriangle, + GitBranch, + GitCommitHorizontal, + NotebookPen, + Star, + Tag, +} from "lucide-react"; +import { StatusSelect } from "@/components/StatusSelect"; +import { PRIORITY_META, accentOf } from "@/lib/status"; +import { cn, formatRelative, truncate } from "@/lib/utils"; +import type { ProjectListItem } from "@/types"; + +const PROVIDER_LABEL: Record = { + 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 [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 ( +
+
+ ); +} diff --git a/src/components/ProjectDialog.tsx b/src/components/ProjectDialog.tsx new file mode 100644 index 0000000..fc5d915 --- /dev/null +++ b/src/components/ProjectDialog.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, RefreshCw, Search } from "lucide-react"; +import type { ProjectStatus } from "@prisma/client"; +import { Modal } from "@/components/ui/Modal"; +import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status"; +import { cn, parseTags } from "@/lib/utils"; +import type { ConnectionSafe, ProjectListItem, RepoListEntry } from "@/types"; + +interface ProjectDialogProps { + open: boolean; + onClose: () => void; + connections: ConnectionSafe[]; + /** Vorhandenes Projekt = Bearbeiten, sonst Anlegen. */ + project?: ProjectListItem | null; +} + +interface FormState { + name: string; + summary: string; + description: string; + status: ProjectStatus; + priority: number; + progress: number; + accent: string; + tags: string; + favorite: boolean; + connectionId: string; + repoUrl: string; + repoOwner: string; + repoName: string; +} + +const EMPTY: FormState = { + name: "", + summary: "", + description: "", + status: "IDEA", + priority: 2, + progress: 0, + accent: "violet", + tags: "", + favorite: false, + connectionId: "", + repoUrl: "", + repoOwner: "", + repoName: "", +}; + +export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { + const router = useRouter(); + const [form, setForm] = useState(EMPTY); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + + const [repoPickerOpen, setRepoPickerOpen] = useState(false); + const [repos, setRepos] = useState([]); + const [reposLoading, setReposLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); + const [repoError, setRepoError] = useState(null); + + const isEdit = Boolean(project); + + useEffect(() => { + if (!open) return; + setError(null); + setFieldErrors({}); + setRepoPickerOpen(false); + setRepos([]); + setRepoError(null); + setForm( + project + ? { + name: project.name, + summary: project.summary ?? "", + description: project.description ?? "", + status: project.status, + priority: project.priority, + progress: project.progress, + accent: project.accent, + tags: project.tags.join(", "), + favorite: project.favorite, + connectionId: project.connectionId ?? "", + repoUrl: project.repoUrl ?? "", + repoOwner: project.repoOwner ?? "", + repoName: project.repoName ?? "", + } + : EMPTY, + ); + }, [open, project]); + + const filteredRepos = useMemo(() => { + const needle = repoFilter.toLowerCase().trim(); + if (!needle) return repos.slice(0, 60); + return repos.filter((repo) => repo.fullPath.toLowerCase().includes(needle)).slice(0, 60); + }, [repos, repoFilter]); + + function update(key: K, value: FormState[K]) { + setForm((previous) => ({ ...previous, [key]: value })); + } + + async function loadRepos() { + if (!form.connectionId) { + setRepoError("Bitte zuerst eine Git-Verbindung auswaehlen."); + return; + } + setReposLoading(true); + setRepoError(null); + try { + const response = await fetch(`/api/connections/${form.connectionId}/repos`); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error ?? "Repos konnten nicht geladen werden."); + setRepos(payload.repos ?? []); + setRepoPickerOpen(true); + } catch (caught) { + setRepoError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setReposLoading(false); + } + } + + function pickRepo(repo: RepoListEntry) { + setForm((previous) => ({ + ...previous, + repoUrl: repo.htmlUrl, + repoOwner: repo.owner, + repoName: repo.name, + name: previous.name || repo.name, + summary: previous.summary || (repo.description ?? ""), + })); + setRepoPickerOpen(false); + } + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setSaving(true); + setError(null); + setFieldErrors({}); + + const body = { + name: form.name, + summary: form.summary, + description: form.description, + status: form.status, + priority: Number(form.priority), + progress: Number(form.progress), + accent: form.accent, + tags: parseTags(form.tags), + favorite: form.favorite, + connectionId: form.connectionId || null, + repoUrl: form.repoUrl, + repoOwner: form.repoOwner, + repoName: form.repoName, + }; + + try { + const response = await fetch( + isEdit ? `/api/projects/${project?.id}` : "/api/projects", + { + method: isEdit ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + setFieldErrors(payload.fields ?? {}); + throw new Error(payload.error ?? "Speichern fehlgeschlagen."); + } + onClose(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setSaving(false); + } + } + + return ( + +
+
+
+ + update("name", event.target.value)} + placeholder="z. B. Pixelgarten" + required + maxLength={120} + autoFocus + /> + {fieldErrors.name ? ( +

{fieldErrors.name}

+ ) : null} +
+ +
+ + update("summary", event.target.value)} + placeholder="Ein Satz, der auf die Karte passt" + maxLength={280} + /> +
+ +
+ +