Files
nebula/src/components/ProjectDialog.tsx
T
florian.wildeandClaude Opus 5 57c50cfb7f feat(projects): Projektvorlagen
Ein Projekt, das sich bewaehrt hat, laesst sich im Projektkopf als Vorlage
sichern: Status, Prioritaet, Farbe, Tags, Notizen und offene Aufgaben. Beim
Anlegen steht sie oben im Dialog zur Auswahl.

Benutzereigen statt fest eingebaut. Welche Tags und welche Startaufgaben
sinnvoll sind, weiss nur, wer damit arbeitet - eine mitgelieferte Liste
waere geraten.

Zwei Dinge gehen bewusst nicht mit. Erledigte Aufgaben nicht: eine Vorlage
beschreibt, was zu tun ist, nicht was einmal getan wurde. Und Repositories
nicht: eine Vorlage, die jedes neue Projekt an dasselbe Repository haengt,
waere eine Falle.

Die Stammdaten uebernimmt der Dialog sichtbar in die Felder, damit man sie
vor dem Speichern noch aendern kann; bereits Getipptes bleibt stehen.
Notizen und Aufgaben legt erst der Server an - im Dialog liessen sie sich
nicht darstellen, und wer die Vorlage wieder abwaehlt, soll sie nicht
mitgeschleppt bekommen.

templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus
einer Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00

675 lines
24 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 { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search, Trash2 } 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;
}
/** Eine Vorlage, so wie sie /api/templates liefert. */
export interface ProjectTemplateEntry {
id: string;
name: string;
description: string | null;
summary: string | null;
projectDescription: string | null;
status: ProjectStatus;
priority: number;
accent: string;
tags: string[];
progressFromTasks: boolean;
notes: unknown[];
tasks: unknown[];
}
/** "3 Notizen, 5 Aufgaben" – oder was davon zutrifft. */
function beschreibeVorlage(vorlage: ProjectTemplateEntry): string {
const teile: string[] = [];
const notizen = Array.isArray(vorlage.notes) ? vorlage.notes.length : 0;
const aufgaben = Array.isArray(vorlage.tasks) ? vorlage.tasks.length : 0;
if (notizen > 0) teile.push(`${notizen} ${notizen === 1 ? "Notiz" : "Notizen"}`);
if (aufgaben > 0) teile.push(`${aufgaben} ${aufgaben === 1 ? "Aufgabe" : "Aufgaben"}`);
if (vorlage.tags.length > 0) teile.push(vorlage.tags.map((tag) => `#${tag}`).join(" "));
return teile.length > 0 ? teile.join(" · ") : "nur Stammdaten";
}
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;
autoSync: boolean;
progressFromTasks: boolean;
/** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */
templateId: string;
}
const EMPTY: FormState = {
name: "",
summary: "",
description: "",
status: "IDEA",
priority: 2,
progress: 0,
accent: "violet",
tags: "",
favorite: false,
connectionId: "",
repoUrl: "",
repoOwner: "",
repoName: "",
autoSync: true,
progressFromTasks: false,
templateId: "",
};
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
const router = useRouter();
const [form, setForm] = useState<FormState>(EMPTY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(project);
const [templates, setTemplates] = useState<ProjectTemplateEntry[]>([]);
const gewaehlteVorlage = templates.find((entry) => entry.id === form.templateId) ?? null;
/**
* Die Vorlagen werden erst geladen, wenn der Dialog aufgeht – und nur zum
* Anlegen. Beim Bearbeiten waeren sie nur Ballast.
*/
useEffect(() => {
if (!open || isEdit) return;
let abgebrochen = false;
fetch("/api/templates")
.then((response) => (response.ok ? response.json() : { templates: [] }))
.then((payload) => {
if (!abgebrochen) setTemplates(payload.templates ?? []);
})
.catch(() => undefined);
return () => {
abgebrochen = true;
};
}, [open, isEdit]);
/**
* Uebernimmt die Stammdaten sichtbar in die Felder. Notizen und Aufgaben
* bleiben der Serverseite ueberlassen: sie liessen sich hier nicht
* darstellen, und wer die Vorlage wieder abwaehlt, soll sie auch nicht
* mitgeschleppt bekommen.
*
* Bereits Getipptes wird nicht ueberschrieben – nur leere Felder werden
* gefuellt.
*/
function applyTemplate(templateId: string) {
const vorlage = templates.find((entry) => entry.id === templateId);
if (!vorlage) {
update("templateId", "");
return;
}
setForm((previous) => ({
...previous,
templateId,
name: previous.name || vorlage.name,
summary: previous.summary || (vorlage.summary ?? ""),
description: previous.description || (vorlage.projectDescription ?? ""),
status: vorlage.status,
priority: vorlage.priority,
accent: vorlage.accent,
tags: previous.tags || vorlage.tags.join(", "),
progressFromTasks: vorlage.progressFromTasks,
}));
}
async function removeTemplate(vorlage: ProjectTemplateEntry) {
if (!window.confirm(`Vorlage „${vorlage.name}“ loeschen?`)) return;
await fetch(`/api/templates/${vorlage.id}`, { method: "DELETE" }).catch(() => undefined);
setTemplates((current) => current.filter((entry) => entry.id !== vorlage.id));
update("templateId", "");
}
const [repoPickerOpen, setRepoPickerOpen] = useState(false);
const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [reposLoading, setReposLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const [repoError, setRepoError] = useState<string | null>(null);
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,
// Der Dialog bearbeitet das fuehrende Repository. Weitere
// verwaltet das Git-Panel – hier wuerde eine Liste den Dialog
// sprengen, in dem es eigentlich um das Projekt geht.
connectionId: project.repositories[0]?.connectionId ?? "",
repoUrl: project.repositories[0]?.repoUrl ?? "",
repoOwner: project.repositories[0]?.repoOwner ?? "",
repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks,
templateId: "",
}
: 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<K extends keyof FormState>(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,
autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
// Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht.
...(isEdit ? {} : { templateId: form.templateId || null }),
};
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 (
<Modal
open={open}
onClose={onClose}
width="xl"
title={isEdit ? "Projekt bearbeiten" : "Neues Projekt"}
description={
isEdit
? "Aenderungen wirken sofort im Board."
: "Eine reine Idee reicht – Repo und Details lassen sich jederzeit nachtragen."
}
>
<form onSubmit={submit} className="space-y-5">
{/* Nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer
Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. */}
{!isEdit && templates.length > 0 ? (
<div className="glass-soft p-4">
<label className="field-label" htmlFor="project-template">
Aus Vorlage
</label>
<div className="flex flex-wrap items-center gap-2">
<select
id="project-template"
className="field w-auto min-w-[14rem] flex-1"
value={form.templateId}
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– ohne Vorlage –</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
{gewaehlteVorlage ? (
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={() => removeTemplate(gewaehlteVorlage)}
title="Diese Vorlage loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{gewaehlteVorlage ? (
<p className="mt-1.5 text-xs text-slate-500">
{gewaehlteVorlage.description ? `${gewaehlteVorlage.description} · ` : ""}
{beschreibeVorlage(gewaehlteVorlage)}
</p>
) : (
<p className="mt-1.5 text-xs text-slate-500">
Vorlagen entstehen aus einem bestehenden Projekt – im Projektkopf unter
„Als Vorlage sichern“.
</p>
)}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-name">
Name *
</label>
<input
id="project-name"
className="field"
value={form.name}
onChange={(event) => update("name", event.target.value)}
placeholder="z. B. Pixelgarten"
required
maxLength={120}
autoFocus
/>
{fieldErrors.name ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.name}</p>
) : null}
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-summary">
Kurzbeschreibung
</label>
<input
id="project-summary"
className="field"
value={form.summary}
onChange={(event) => update("summary", event.target.value)}
placeholder="Ein Satz, der auf die Karte passt"
maxLength={280}
/>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-description">
Beschreibung / Ideenskizze
</label>
<textarea
id="project-description"
className="field min-h-28 resize-y"
value={form.description}
onChange={(event) => update("description", event.target.value)}
placeholder="Worum geht es? Was ist der Reiz? Welcher Stack?"
/>
</div>
<div>
<label className="field-label" htmlFor="project-status">
Status
</label>
<select
id="project-status"
className="field"
value={form.status}
onChange={(event) => update("status", event.target.value as ProjectStatus)}
>
{STATUS_ORDER.map((status) => (
<option key={status} value={status}>
{STATUS_META[status].label}
</option>
))}
</select>
</div>
<div>
<label className="field-label" htmlFor="project-priority">
Prioritaet
</label>
<select
id="project-priority"
className="field"
value={form.priority}
onChange={(event) => update("priority", Number(event.target.value))}
>
{Object.entries(PRIORITY_META).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-progress">
Fortschritt: {form.progress}%
</label>
<input
id="project-progress"
type="range"
min={0}
max={100}
step={5}
value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))}
disabled={form.progressFromTasks}
className={cn("range", form.progressFromTasks && "cursor-not-allowed opacity-40")}
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
/>
<label className="mt-2 flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.progressFromTasks}
onChange={(event) => update("progressFromTasks", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aus Aufgaben berechnen
<span className="mt-0.5 block text-[11px] text-slate-600">
Der Fortschritt ergibt sich dann aus dem Anteil erledigter Aufgaben und
laesst sich nicht mehr von Hand schieben.
</span>
</span>
</label>
</div>
<div className="sm:col-span-2">
<span className="field-label">Akzentfarbe</span>
<div className="flex flex-wrap gap-2">
{Object.entries(ACCENTS).map(([key, accent]) => (
<button
key={key}
type="button"
onClick={() => update("accent", key)}
aria-label={accent.label}
title={accent.label}
className={cn(
"h-8 w-8 rounded-lg transition-transform",
form.accent === key
? "scale-110 ring-2 ring-white/70"
: "opacity-70 hover:scale-105 hover:opacity-100",
)}
style={{
backgroundImage: `linear-gradient(135deg, ${accent.from}, ${accent.to})`,
}}
/>
))}
</div>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-tags">
Tags (kommagetrennt)
</label>
<input
id="project-tags"
className="field"
value={form.tags}
onChange={(event) => update("tags", event.target.value)}
placeholder="next.js, spiel, experiment"
/>
</div>
</div>
{/* ── Git ────────────────────────────────────────────── */}
<div className="glass-soft space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-200">Git-Anbindung (optional)</h3>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={loadRepos}
disabled={reposLoading || !form.connectionId}
>
{reposLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Repos laden
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="project-connection">
Verbindung
</label>
<select
id="project-connection"
className="field"
value={form.connectionId}
onChange={(event) => update("connectionId", event.target.value)}
>
<option value="">– keine –</option>
{connections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.name} ({connection.provider})
</option>
))}
</select>
{connections.length === 0 ? (
<p className="mt-1 text-xs text-slate-500">
Noch keine Verbindung angelegt – unter „Git-Verbindungen“ hinzufuegen.
</p>
) : null}
</div>
<div>
<label className="field-label" htmlFor="project-repo-url">
Repository-URL
</label>
<input
id="project-repo-url"
className="field"
value={form.repoUrl}
onChange={(event) => update("repoUrl", event.target.value)}
placeholder="https://github.com/user/repo"
/>
</div>
<div>
<label className="field-label" htmlFor="project-repo-owner">
Owner / Gruppe
</label>
<input
id="project-repo-owner"
className="field font-mono text-xs"
value={form.repoOwner}
onChange={(event) => update("repoOwner", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
<div>
<label className="field-label" htmlFor="project-repo-name">
Repository
</label>
<input
id="project-repo-name"
className="field font-mono text-xs"
value={form.repoName}
onChange={(event) => update("repoName", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.autoSync}
onChange={(event) => update("autoSync", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Am automatischen Abgleich teilnehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
synchronisieren geht unabhaengig davon immer.
</span>
</span>
</label>
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
{repoPickerOpen ? (
<div className="rounded-xl border border-white/8 bg-[#080a18]/70">
<div className="flex items-center gap-2 border-b border-white/6 px-3 py-2">
<Search className="h-3.5 w-3.5 text-slate-500" />
<input
className="w-full bg-transparent text-sm text-slate-200 outline-none placeholder:text-slate-600"
placeholder={`${repos.length} Repositories durchsuchen…`}
value={repoFilter}
onChange={(event) => setRepoFilter(event.target.value)}
/>
</div>
<ul className="max-h-56 overflow-y-auto p-1">
{filteredRepos.map((repo) => (
<li key={repo.fullPath}>
<button
type="button"
onClick={() => pickRepo(repo)}
className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left hover:bg-white/6"
>
<span className="min-w-0">
<span className="block truncate font-mono text-xs text-slate-200">
{repo.fullPath}
</span>
{repo.description ? (
<span className="block truncate text-[11px] text-slate-500">
{repo.description}
</span>
) : null}
</span>
<span className="shrink-0 text-[10px] uppercase text-slate-600">
{repo.visibility}
</span>
</button>
</li>
))}
{filteredRepos.length === 0 ? (
<li className="px-3 py-4 text-center text-xs text-slate-500">
Keine Treffer.
</li>
) : null}
</ul>
</div>
) : null}
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex items-center justify-between gap-3 pt-1">
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
<input
type="checkbox"
checked={form.favorite}
onChange={(event) => update("favorite", event.target.checked)}
className="h-4 w-4 accent-violet-500"
/>
Als Favorit anpinnen
</label>
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Projekt anlegen"}
</button>
</div>
</div>
</form>
</Modal>
);
}