Spezifikation v0.2 als lebendes Dokument + Ansicht in der App
Deploy App / deploy (push) Successful in 3m10s
Deploy App / deploy (push) Successful in 3m10s
Neue Funktionale/Technische Spezifikation (SPEZIFIKATION.md) mit Aenderungshistorie; ersetzt die bisherigen FDD/TDD-Dokumente v1-v5. In der App unter dem Menueintrag "SPEZIFIKATION" (Sidebar, unter den Plaenen) lesbar: /api/spec liefert das Markdown, SpecView rendert es. Styling ueber die bestehenden Farb-Tokens, folgt damit allen drei Schemata. Die MD-Datei war ueber .dockerignore (*.md) vom Image ausgeschlossen -> Ausnahme ergaenzt und Kopie in die Runner-Stage aufgenommen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,5 @@ npm-debug.log*
|
|||||||
Info Dateien
|
Info Dateien
|
||||||
.claude
|
.claude
|
||||||
*.md
|
*.md
|
||||||
|
# Die Spezifikation wird in der App unter /spezifikation ausgeliefert und muss ins Image.
|
||||||
|
!SPEZIFIKATION.md
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ COPY --from=builder /app/package.json ./package.json
|
|||||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
# Wird zur Laufzeit von /api/spec gelesen und in der App gerendert.
|
||||||
|
COPY --from=builder /app/SPEZIFIKATION.md ./SPEZIFIKATION.md
|
||||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
RUN chmod +x ./docker-entrypoint.sh
|
RUN chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
+1688
File diff suppressed because it is too large
Load Diff
Generated
+1519
-3
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,10 @@
|
|||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.9.2",
|
"recharts": "^3.9.2",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentUserId } from "@/lib/session";
|
||||||
|
|
||||||
|
// Liefert die Spezifikation (SPEZIFIKATION.md aus dem Projekt-Root) als Rohtext an die App.
|
||||||
|
// Die Datei liegt im Docker-Image neben package.json (siehe Dockerfile).
|
||||||
|
export async function GET() {
|
||||||
|
const userId = await getCurrentUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const markdown = await readFile(path.join(process.cwd(), "SPEZIFIKATION.md"), "utf8");
|
||||||
|
return NextResponse.json({ markdown });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Spezifikation nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,3 +128,102 @@ body {
|
|||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
Dokument-Darstellung fuer gerenderten Markdown (Spezifikation, /spezifikation).
|
||||||
|
Bewusst ueber die semantischen Tokens, damit alle drei Farbschemata greifen.
|
||||||
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.md-doc {
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
max-width: 62rem;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-doc > *:first-child { margin-top: 0; }
|
||||||
|
.md-doc > *:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.md-doc h1,
|
||||||
|
.md-doc h2,
|
||||||
|
.md-doc h3,
|
||||||
|
.md-doc h4 {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
scroll-margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.md-doc h1 { font-size: 1.6rem; margin: 2.4rem 0 1rem; }
|
||||||
|
.md-doc h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 2.2rem 0 0.85rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.md-doc h3 { font-size: 1.05rem; margin: 1.7rem 0 0.6rem; }
|
||||||
|
.md-doc h4 { font-size: 0.95rem; margin: 1.3rem 0 0.5rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.md-doc p { margin: 0.75rem 0; }
|
||||||
|
.md-doc strong { font-weight: 600; color: var(--fg); }
|
||||||
|
.md-doc em { font-style: italic; }
|
||||||
|
|
||||||
|
.md-doc a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.md-doc a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
.md-doc ul,
|
||||||
|
.md-doc ol { margin: 0.75rem 0; padding-left: 1.4rem; }
|
||||||
|
.md-doc ul { list-style: disc; }
|
||||||
|
.md-doc ol { list-style: decimal; }
|
||||||
|
.md-doc li { margin: 0.3rem 0; }
|
||||||
|
.md-doc li::marker { color: var(--faint); }
|
||||||
|
|
||||||
|
.md-doc code {
|
||||||
|
font-family: var(--font-mono), monospace;
|
||||||
|
font-size: 0.84em;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
}
|
||||||
|
.md-doc pre {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.md-doc pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breite Tabellen scrollen in ihrem eigenen Container, die Seite bleibt ruhig. */
|
||||||
|
.md-doc .md-table-wrap { overflow-x: auto; margin: 1rem 0; }
|
||||||
|
.md-doc table { border-collapse: collapse; width: 100%; font-size: 0.82rem; }
|
||||||
|
.md-doc th,
|
||||||
|
.md-doc td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.md-doc th { background: var(--surface-2); font-weight: 600; white-space: nowrap; }
|
||||||
|
.md-doc tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface-2) 45%, transparent); }
|
||||||
|
|
||||||
|
.md-doc blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-soft-fg);
|
||||||
|
border-radius: 0 0.4rem 0.4rem 0;
|
||||||
|
}
|
||||||
|
.md-doc blockquote p { margin: 0.3rem 0; }
|
||||||
|
|
||||||
|
.md-doc hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
FileText,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PlanView } from "@/components/PlanView";
|
import { PlanView } from "@/components/PlanView";
|
||||||
import { Dashboard } from "@/components/Dashboard";
|
import { Dashboard } from "@/components/Dashboard";
|
||||||
|
import { SpecView } from "@/components/SpecView";
|
||||||
import { ProfileMenu } from "@/components/ProfileMenu";
|
import { ProfileMenu } from "@/components/ProfileMenu";
|
||||||
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
import { PlanProfileFields, emptyProfileDraft, type ProfileDraft } from "@/components/PlanProfileFields";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
@@ -34,6 +36,8 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showNewPlan, setShowNewPlan] = useState(false);
|
const [showNewPlan, setShowNewPlan] = useState(false);
|
||||||
const [showScenario, setShowScenario] = useState(false);
|
const [showScenario, setShowScenario] = useState(false);
|
||||||
|
// Die Spezifikation ist eine eigene Ansicht neben Uebersicht und Plan (schliessen sich aus).
|
||||||
|
const [showSpec, setShowSpec] = useState(false);
|
||||||
|
|
||||||
const loadPlans = useCallback(async (preferId?: string) => {
|
const loadPlans = useCallback(async (preferId?: string) => {
|
||||||
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
const data = await api.get<{ plans: PlanListItem[] }>("/api/plans");
|
||||||
@@ -95,10 +99,13 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPlanId(null);
|
setSelectedPlanId(null);
|
||||||
|
setShowSpec(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium ${
|
||||||
selectedPlanId === null ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
selectedPlanId === null && !showSpec
|
||||||
|
? "bg-accent-soft text-accent-soft-fg"
|
||||||
|
: "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-4 w-4" />
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
@@ -123,10 +130,13 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedPlanId(p.id);
|
setSelectedPlanId(p.id);
|
||||||
|
setShowSpec(false);
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${
|
||||||
selectedPlanId === p.id ? "bg-accent-soft font-medium text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
selectedPlanId === p.id && !showSpec
|
||||||
|
? "bg-accent-soft font-medium text-accent-soft-fg"
|
||||||
|
: "text-muted hover:bg-surface-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
<FolderKanban className="h-4 w-4 shrink-0" />
|
||||||
@@ -134,6 +144,23 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<span className="text-[11px] text-faint">{p.phases.length}</span>
|
<span className="text-[11px] text-faint">{p.phases.length}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSpec(true);
|
||||||
|
setSelectedPlanId(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium ${
|
||||||
|
showSpec ? "bg-accent-soft text-accent-soft-fg" : "text-muted hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
|
SPEZIFIKATION
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -173,25 +200,30 @@ export function AppShell({ username }: { username: string }) {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
<h1 className="min-w-0 flex-1 truncate text-base font-semibold text-fg">
|
||||||
{activePlan ? activePlan.name : "Uebersicht"}
|
{showSpec ? "Spezifikation" : activePlan ? activePlan.name : "Uebersicht"}
|
||||||
</h1>
|
</h1>
|
||||||
<ProfileMenu username={username} />
|
<ProfileMenu username={username} />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 px-4 py-6 lg:px-8">
|
<main className="flex-1 px-4 py-6 lg:px-8">
|
||||||
{loading && <p className="text-sm text-muted">Laedt…</p>}
|
{showSpec && <SpecView />}
|
||||||
|
|
||||||
{!loading && selectedPlanId === null && (
|
{!showSpec && loading && <p className="text-sm text-muted">Laedt…</p>}
|
||||||
|
|
||||||
|
{!showSpec && !loading && selectedPlanId === null && (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
username={username}
|
username={username}
|
||||||
plans={plans}
|
plans={plans}
|
||||||
onSelect={setSelectedPlanId}
|
onSelect={(id) => {
|
||||||
|
setSelectedPlanId(id);
|
||||||
|
setShowSpec(false);
|
||||||
|
}}
|
||||||
onCreate={() => setShowNewPlan(true)}
|
onCreate={() => setShowNewPlan(true)}
|
||||||
onDelete={handleDeletePlan}
|
onDelete={handleDeletePlan}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && detail && selectedPlanId && (
|
{!showSpec && !loading && detail && selectedPlanId && (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{detail.plan.phases.length > 0 && (
|
{detail.plan.phases.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api-client";
|
||||||
|
|
||||||
|
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
|
||||||
|
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
|
||||||
|
export function SpecView() {
|
||||||
|
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.get<{ markdown: string }>("/api/spec")
|
||||||
|
.then((data) => setMarkdown(data.markdown))
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen."));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||||
|
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-5 w-5 text-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-fg">Spezifikation</h2>
|
||||||
|
</div>
|
||||||
|
<article className="md-doc rounded-xl border border-border bg-surface p-6 shadow-sm lg:p-8">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeSlug]}
|
||||||
|
components={{
|
||||||
|
// Breite Tabellen scrollen in ihrem eigenen Container statt die Seite zu dehnen.
|
||||||
|
table: ({ children, ...props }) => (
|
||||||
|
<div className="md-table-wrap">
|
||||||
|
<table {...props}>{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user