Structured Editor
Section-based document editor where documents are composed of named, typed sections (Summary, User Stories, Acceptance Criteria, Edge Cases, Error States, Dependencies, Open Questions) that render as collapsible React components with inline editing, status badges, and annotation support.
A document is a collection of typed sections. Each section has: title, type, content (JSON), status, and sort order. Annotations are comments attached to sections from users or AI agents.
Prerequisites
- Next.js app with
src/directory and App Router add-shadcnskill installed (shadcn/ui components available)dbskill installed (Drizzle ORM at@/lib/db, Postgres running)authskill installed (withAuthavailable at@/lib/auth-guard)
Installation
bunx shadcn@latest add collapsible badge dropdown-menu textarea
What Gets Created
src/
├── lib/
│ └── db/
│ └── schema/
│ └── document.ts # specDocument, specSection, specAnnotation tables
├── app/
│ └── api/
│ └── documents/
│ ├── route.ts # GET (list), POST (create)
│ ├── [documentId]/
│ │ ├── route.ts # GET, PATCH, DELETE
│ │ └── sections/
│ │ ├── route.ts # GET (list sections), POST (add section)
│ │ └── [sectionId]/
│ │ ├── route.ts # PATCH, DELETE section
│ │ └── annotations/
│ │ └── route.ts # GET, POST annotations
└── components/
└── editor/
├── document-editor.tsx # Main editor — renders all sections
├── section-card.tsx # Single section: collapse, edit, status, annotations
├── section-content.tsx # Content renderer by section type
├── section-toolbar.tsx # Status badge, annotation count, actions
├── annotation-list.tsx # Inline annotation thread on a section
└── add-section-button.tsx # Button to add new section with type picker
What Gets Modified
src/
└── lib/
└── db/
└── schema/
└── index.ts # Add document schema export
Database
After applying this skill, push the schema to create the spec_document, spec_section, and spec_annotation tables:
bunx drizzle-kit push
Section Types and Content Shapes
Each section type has a specific content JSON shape:
| Type | Content Shape |
|---|---|
summary |
{ text: string } |
user_stories |
{ items: Array<{ id: string; persona: string; action: string; benefit: string }> } |
acceptance_criteria |
{ items: Array<{ id: string; criterion: string; checked: boolean }> } |
edge_cases |
{ items: Array<{ id: string; scenario: string; expectedBehavior: string }> } |
error_states |
{ items: Array<{ id: string; trigger: string; userMessage: string; recovery: string }> } |
dependencies |
{ items: Array<{ id: string; name: string; type: "api" | "service" | "feature" | "data"; status: "ready" | "in_progress" | "blocked" }> } |
open_questions |
{ items: Array<{ id: string; question: string; answer: string | null; resolved: boolean }> } |
custom |
{ text: string } |
Setup Steps
Step 1: Create src/lib/db/schema/document.ts
import {
pgTable,
text,
timestamp,
uuid,
json,
integer,
boolean,
} from "drizzle-orm/pg-core";
// --- Section content types ---
type SummaryContent = { text: string };
type UserStoryItem = {
id: string;
persona: string;
action: string;
benefit: string;
};
type UserStoriesContent = { items: UserStoryItem[] };
type AcceptanceCriterionItem = {
id: string;
criterion: string;
checked: boolean;
};
type AcceptanceCriteriaContent = { items: AcceptanceCriterionItem[] };
type EdgeCaseItem = {
id: string;
scenario: string;
expectedBehavior: string;
};
type EdgeCasesContent = { items: EdgeCaseItem[] };
type ErrorStateItem = {
id: string;
trigger: string;
userMessage: string;
recovery: string;
};
type ErrorStatesContent = { items: ErrorStateItem[] };
type DependencyItem = {
id: string;
name: string;
type: "api" | "service" | "feature" | "data";
status: "ready" | "in_progress" | "blocked";
};
type DependenciesContent = { items: DependencyItem[] };
type OpenQuestionItem = {
id: string;
question: string;
answer: string | null;
resolved: boolean;
};
type OpenQuestionsContent = { items: OpenQuestionItem[] };
type CustomContent = { text: string };
export type SectionContent =
| SummaryContent
| UserStoriesContent
| AcceptanceCriteriaContent
| EdgeCasesContent
| ErrorStatesContent
| DependenciesContent
| OpenQuestionsContent
| CustomContent;
export type SectionType =
| "summary"
| "user_stories"
| "acceptance_criteria"
| "edge_cases"
| "error_states"
| "dependencies"
| "open_questions"
| "custom";
export type SectionStatus = "draft" | "reviewed" | "approved";
export type DocumentStatus = "draft" | "review" | "approved" | "archived";
export {
type SummaryContent,
type UserStoriesContent,
type UserStoryItem,
type AcceptanceCriteriaContent,
type AcceptanceCriterionItem,
type EdgeCasesContent,
type EdgeCaseItem,
type ErrorStatesContent,
type ErrorStateItem,
type DependenciesContent,
type DependencyItem,
type OpenQuestionsContent,
type OpenQuestionItem,
type CustomContent,
};
// --- Tables ---
export const specDocument = pgTable("spec_document", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull(),
title: text("title").notNull(),
description: text("description"),
status: text("status", {
enum: ["draft", "review", "approved", "archived"],
})
.notNull()
.default("draft"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const specSection = pgTable("spec_section", {
id: uuid("id").defaultRandom().primaryKey(),
documentId: uuid("document_id")
.notNull()
.references(() => specDocument.id, { onDelete: "cascade" }),
type: text("type", {
enum: [
"summary",
"user_stories",
"acceptance_criteria",
"edge_cases",
"error_states",
"dependencies",
"open_questions",
"custom",
],
}).notNull(),
title: text("title").notNull(),
content: json("content").$type<SectionContent>().notNull().default({ text: "" }),
status: text("status", {
enum: ["draft", "reviewed", "approved"],
})
.notNull()
.default("draft"),
sortOrder: integer("sort_order").notNull().default(0),
collapsed: boolean("collapsed").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
export const specAnnotation = pgTable("spec_annotation", {
id: uuid("id").defaultRandom().primaryKey(),
sectionId: uuid("section_id")
.notNull()
.references(() => specSection.id, { onDelete: "cascade" }),
authorId: text("author_id"),
authorName: text("author_name").notNull(),
authorRole: text("author_role").default("user"),
content: text("content").notNull(),
color: text("color"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
});
Step 2: Add document schema to barrel export
Add the document schema export to src/lib/db/schema/index.ts:
export * from "./document";
Step 3: Create src/app/api/documents/route.ts
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import { specDocument } from "@/lib/db/schema/document";
import { eq, desc } from "drizzle-orm";
type DocumentListItem = {
id: string;
title: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
};
/** GET /api/documents — list all documents for the current user */
export const GET = withAuth(async (_request, { user }) => {
const documents = await db
.select({
id: specDocument.id,
title: specDocument.title,
description: specDocument.description,
status: specDocument.status,
createdAt: specDocument.createdAt,
updatedAt: specDocument.updatedAt,
})
.from(specDocument)
.where(eq(specDocument.userId, user.id))
.orderBy(desc(specDocument.updatedAt));
return NextResponse.json<DocumentListItem[]>(documents);
});
type CreateDocumentBody = {
title: string;
description?: string;
};
/** POST /api/documents — create a new document */
export const POST = withAuth(async (request, { user }) => {
const body: CreateDocumentBody = await request.json();
const [document] = await db
.insert(specDocument)
.values({
userId: user.id,
title: body.title,
description: body.description ?? null,
})
.returning();
return NextResponse.json(document, { status: 201 });
});
Step 4: Create src/app/api/documents/[documentId]/route.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import {
specDocument,
specSection,
specAnnotation,
type DocumentStatus,
} from "@/lib/db/schema/document";
import { eq, and, asc } from "drizzle-orm";
type RouteContext = { params: Promise<{ documentId: string }> };
/** GET /api/documents/[documentId] — get document with all sections and annotations */
export const GET = withAuth(async (request: NextRequest, { user }) => {
const pathParts = request.nextUrl.pathname.split("/");
const documentId = pathParts[pathParts.length - 1];
const documents = await db
.select()
.from(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.limit(1);
if (documents.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const sections = await db
.select()
.from(specSection)
.where(eq(specSection.documentId, documentId))
.orderBy(asc(specSection.sortOrder));
const sectionIds = sections.map((s) => s.id);
const annotations =
sectionIds.length > 0
? await db
.select()
.from(specAnnotation)
.where(
eq(
specAnnotation.sectionId,
sectionIds[0] // initial fetch — client fetches per-section as needed
)
)
: [];
// Fetch all annotations for all sections
const allAnnotations: Record<string, typeof annotations> = {};
for (const section of sections) {
const sectionAnnotations = await db
.select()
.from(specAnnotation)
.where(eq(specAnnotation.sectionId, section.id))
.orderBy(asc(specAnnotation.createdAt));
allAnnotations[section.id] = sectionAnnotations;
}
return NextResponse.json({
document: documents[0],
sections,
annotations: allAnnotations,
});
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
type UpdateDocumentBody = {
title?: string;
description?: string;
status?: DocumentStatus;
};
/** PATCH /api/documents/[documentId] — update document */
export const PATCH = withAuth(async (request: NextRequest, { user }) => {
const pathParts = request.nextUrl.pathname.split("/");
const documentId = pathParts[pathParts.length - 1];
const body: UpdateDocumentBody = await request.json();
const result = await db
.update(specDocument)
.set({
...(body.title !== undefined && { title: body.title }),
...(body.description !== undefined && { description: body.description }),
...(body.status !== undefined && { status: body.status }),
updatedAt: new Date(),
})
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.returning();
if (result.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
return NextResponse.json(result[0]);
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
/** DELETE /api/documents/[documentId] — delete document (cascades to sections and annotations) */
export const DELETE = withAuth(async (request: NextRequest, { user }) => {
const pathParts = request.nextUrl.pathname.split("/");
const documentId = pathParts[pathParts.length - 1];
const result = await db
.delete(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.returning({ id: specDocument.id });
if (result.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
Step 5: Create src/app/api/documents/[documentId]/sections/route.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import {
specDocument,
specSection,
type SectionType,
type SectionContent,
} from "@/lib/db/schema/document";
import { eq, and, asc } from "drizzle-orm";
type RouteContext = { params: Promise<{ documentId: string }> };
/** GET /api/documents/[documentId]/sections — list sections for a document */
export const GET = withAuth(async (request: NextRequest, { user }) => {
const pathParts = request.nextUrl.pathname.split("/");
// URL: /api/documents/[documentId]/sections
const documentId = pathParts[pathParts.length - 2];
// Verify document ownership
const documents = await db
.select({ id: specDocument.id })
.from(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.limit(1);
if (documents.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const sections = await db
.select()
.from(specSection)
.where(eq(specSection.documentId, documentId))
.orderBy(asc(specSection.sortOrder));
return NextResponse.json(sections);
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
type CreateSectionBody = {
type: SectionType;
title: string;
content?: SectionContent;
sortOrder?: number;
};
/** POST /api/documents/[documentId]/sections — add a section to the document */
export const POST = withAuth(async (request: NextRequest, { user }) => {
const pathParts = request.nextUrl.pathname.split("/");
const documentId = pathParts[pathParts.length - 2];
// Verify document ownership
const documents = await db
.select({ id: specDocument.id })
.from(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.limit(1);
if (documents.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const body: CreateSectionBody = await request.json();
const defaultContent = getDefaultContent(body.type);
const [section] = await db
.insert(specSection)
.values({
documentId,
type: body.type,
title: body.title,
content: body.content ?? defaultContent,
sortOrder: body.sortOrder ?? 0,
})
.returning();
// Update document timestamp
await db
.update(specDocument)
.set({ updatedAt: new Date() })
.where(eq(specDocument.id, documentId));
return NextResponse.json(section, { status: 201 });
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
function getDefaultContent(type: SectionType): SectionContent {
switch (type) {
case "summary":
return { text: "" };
case "user_stories":
return { items: [] };
case "acceptance_criteria":
return { items: [] };
case "edge_cases":
return { items: [] };
case "error_states":
return { items: [] };
case "dependencies":
return { items: [] };
case "open_questions":
return { items: [] };
case "custom":
return { text: "" };
}
}
Step 6: Create src/app/api/documents/[documentId]/sections/[sectionId]/route.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import {
specDocument,
specSection,
type SectionContent,
type SectionStatus,
} from "@/lib/db/schema/document";
import { eq, and } from "drizzle-orm";
type RouteContext = {
params: Promise<{ documentId: string; sectionId: string }>;
};
/** Helper to parse documentId and sectionId from the URL path */
function parseIds(pathname: string): {
documentId: string;
sectionId: string;
} {
// URL: /api/documents/[documentId]/sections/[sectionId]
const parts = pathname.split("/");
const sectionsIndex = parts.indexOf("sections");
return {
documentId: parts[sectionsIndex - 1],
sectionId: parts[sectionsIndex + 1],
};
}
type UpdateSectionBody = {
title?: string;
content?: SectionContent;
status?: SectionStatus;
sortOrder?: number;
collapsed?: boolean;
};
/** PATCH /api/documents/[documentId]/sections/[sectionId] — update a section */
export const PATCH = withAuth(async (request: NextRequest, { user }) => {
const { documentId, sectionId } = parseIds(request.nextUrl.pathname);
// Verify document ownership
const documents = await db
.select({ id: specDocument.id })
.from(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.limit(1);
if (documents.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const body: UpdateSectionBody = await request.json();
const result = await db
.update(specSection)
.set({
...(body.title !== undefined && { title: body.title }),
...(body.content !== undefined && { content: body.content }),
...(body.status !== undefined && { status: body.status }),
...(body.sortOrder !== undefined && { sortOrder: body.sortOrder }),
...(body.collapsed !== undefined && { collapsed: body.collapsed }),
updatedAt: new Date(),
})
.where(
and(
eq(specSection.id, sectionId),
eq(specSection.documentId, documentId)
)
)
.returning();
if (result.length === 0) {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
// Update document timestamp
await db
.update(specDocument)
.set({ updatedAt: new Date() })
.where(eq(specDocument.id, documentId));
return NextResponse.json(result[0]);
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
/** DELETE /api/documents/[documentId]/sections/[sectionId] — delete a section */
export const DELETE = withAuth(async (request: NextRequest, { user }) => {
const { documentId, sectionId } = parseIds(request.nextUrl.pathname);
// Verify document ownership
const documents = await db
.select({ id: specDocument.id })
.from(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.limit(1);
if (documents.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
const result = await db
.delete(specSection)
.where(
and(
eq(specSection.id, sectionId),
eq(specSection.documentId, documentId)
)
)
.returning({ id: specSection.id });
if (result.length === 0) {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
// Update document timestamp
await db
.update(specDocument)
.set({ updatedAt: new Date() })
.where(eq(specDocument.id, documentId));
return NextResponse.json({ success: true });
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
Step 7: Create src/app/api/documents/[documentId]/sections/[sectionId]/annotations/route.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { db } from "@/lib/db";
import {
specDocument,
specSection,
specAnnotation,
} from "@/lib/db/schema/document";
import { eq, and, asc } from "drizzle-orm";
type RouteContext = {
params: Promise<{ documentId: string; sectionId: string }>;
};
/** Helper to parse documentId and sectionId from the URL path */
function parseIds(pathname: string): {
documentId: string;
sectionId: string;
} {
// URL: /api/documents/[documentId]/sections/[sectionId]/annotations
const parts = pathname.split("/");
const sectionsIndex = parts.indexOf("sections");
return {
documentId: parts[sectionsIndex - 1],
sectionId: parts[sectionsIndex + 1],
};
}
/** GET /api/documents/[documentId]/sections/[sectionId]/annotations — list annotations */
export const GET = withAuth(async (request: NextRequest, { user }) => {
const { documentId, sectionId } = parseIds(request.nextUrl.pathname);
// Verify document ownership
const documents = await db
.select({ id: specDocument.id })
.from(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.limit(1);
if (documents.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// Verify section belongs to document
const sections = await db
.select({ id: specSection.id })
.from(specSection)
.where(
and(
eq(specSection.id, sectionId),
eq(specSection.documentId, documentId)
)
)
.limit(1);
if (sections.length === 0) {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
const annotations = await db
.select()
.from(specAnnotation)
.where(eq(specAnnotation.sectionId, sectionId))
.orderBy(asc(specAnnotation.createdAt));
return NextResponse.json(annotations);
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
type CreateAnnotationBody = {
content: string;
authorName?: string;
authorRole?: string;
color?: string;
};
/** POST /api/documents/[documentId]/sections/[sectionId]/annotations — add annotation */
export const POST = withAuth(async (request: NextRequest, { user }) => {
const { documentId, sectionId } = parseIds(request.nextUrl.pathname);
// Verify document ownership
const documents = await db
.select({ id: specDocument.id })
.from(specDocument)
.where(
and(
eq(specDocument.id, documentId),
eq(specDocument.userId, user.id)
)
)
.limit(1);
if (documents.length === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// Verify section belongs to document
const sections = await db
.select({ id: specSection.id })
.from(specSection)
.where(
and(
eq(specSection.id, sectionId),
eq(specSection.documentId, documentId)
)
)
.limit(1);
if (sections.length === 0) {
return NextResponse.json({ error: "Section not found" }, { status: 404 });
}
const body: CreateAnnotationBody = await request.json();
const [annotation] = await db
.insert(specAnnotation)
.values({
sectionId,
authorId: user.id,
authorName: body.authorName ?? user.name ?? "Unknown",
authorRole: body.authorRole ?? "user",
content: body.content,
color: body.color ?? null,
})
.returning();
return NextResponse.json(annotation, { status: 201 });
}) as (request: NextRequest, context: RouteContext) => Promise<NextResponse>;
Step 8: Create src/components/editor/section-toolbar.tsx
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
PencilSimple,
Trash,
ArrowUp,
ArrowDown,
ChatCircle,
} from "@phosphor-icons/react";
import type { SectionStatus } from "@/lib/db/schema/document";
type SectionToolbarProps = {
status: SectionStatus;
annotationCount: number;
onStatusCycle: () => void;
onEdit: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
};
const statusConfig: Record<
SectionStatus,
{ label: string; variant: "secondary" | "outline" | "default" }
> = {
draft: { label: "Draft", variant: "secondary" },
reviewed: { label: "Reviewed", variant: "outline" },
approved: { label: "Approved", variant: "default" },
};
const statusOrder: SectionStatus[] = ["draft", "reviewed", "approved"];
export function SectionToolbar({
status,
annotationCount,
onStatusCycle,
onEdit,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: SectionToolbarProps) {
const config = statusConfig[status];
return (
<div className="flex items-center gap-1">
<button type="button" className="cursor-pointer">
<Badge variant={config.variant}>{config.label}</Badge>
</button>
{annotationCount > 0 && (
<Badge variant="outline" className="gap-1">
<ChatCircle className="h-3 w-3" />
{annotationCount}
</Badge>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={isFirst}
title="Move up"
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={isLast}
title="Move down"
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Edit section"
>
<PencilSimple className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
title="Delete section"
>
<Trash className="h-3.5 w-3.5" />
</Button>
</div>
);
}
export function getNextStatus(current: SectionStatus): SectionStatus {
const currentIndex = statusOrder.indexOf(current);
return statusOrder[(currentIndex + 1) % statusOrder.length];
}
Step 9: Create src/components/editor/section-content.tsx
"use client";
import { useId, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash } from "@phosphor-icons/react";
import type {
SectionType,
SectionContent as SectionContentType,
SummaryContent,
UserStoriesContent,
UserStoryItem,
AcceptanceCriteriaContent,
AcceptanceCriterionItem,
EdgeCasesContent,
EdgeCaseItem,
ErrorStatesContent,
ErrorStateItem,
DependenciesContent,
DependencyItem,
OpenQuestionsContent,
OpenQuestionItem,
CustomContent,
} from "@/lib/db/schema/document";
type SectionContentProps = {
type: SectionType;
content: SectionContentType;
isEditing: boolean;
onContentChange: (content: SectionContentType) => void;
};
export function SectionContent({
type,
content,
isEditing,
onContentChange,
}: SectionContentProps) {
switch (type) {
case "summary":
return (
<SummaryRenderer
content={content as SummaryContent}
isEditing={isEditing}
/>
);
case "user_stories":
return (
<UserStoriesRenderer
content={content as UserStoriesContent}
isEditing={isEditing}
/>
);
case "acceptance_criteria":
return (
<AcceptanceCriteriaRenderer
content={content as AcceptanceCriteriaContent}
isEditing={isEditing}
/>
);
case "edge_cases":
return (
<EdgeCasesRenderer
content={content as EdgeCasesContent}
isEditing={isEditing}
/>
);
case "error_states":
return (
<ErrorStatesRenderer
content={content as ErrorStatesContent}
isEditing={isEditing}
/>
);
case "dependencies":
return (
<DependenciesRenderer
content={content as DependenciesContent}
isEditing={isEditing}
/>
);
case "open_questions":
return (
<OpenQuestionsRenderer
content={content as OpenQuestionsContent}
isEditing={isEditing}
/>
);
case "custom":
return (
<CustomRenderer
content={content as CustomContent}
isEditing={isEditing}
/>
);
}
}
// --- Summary ---
type SummaryRendererProps = {
content: SummaryContent;
isEditing: boolean;
onChange: (content: SummaryContent) => void;
};
function SummaryRenderer({ content, isEditing, onChange }: SummaryRendererProps) {
if (isEditing) {
return (
<Textarea
value={content.text}
=> onChange({ text: e.target.value })}
placeholder="Enter summary..."
rows={4}
/>
);
}
return (
<div className="prose dark:prose-invert max-w-none whitespace-pre-wrap">
{content.text || <span className="text-muted-foreground">No summary yet.</span>}
</div>
);
}
// --- User Stories ---
type UserStoriesRendererProps = {
content: UserStoriesContent;
isEditing: boolean;
onChange: (content: UserStoriesContent) => void;
};
function UserStoriesRenderer({
content,
isEditing,
onChange,
}: UserStoriesRendererProps) {
const rowId = useId();
const addItem = useCallback(() => {
onChange({
items: [
...content.items,
{
id: crypto.randomUUID(),
persona: "",
action: "",
benefit: "",
},
],
});
}, [content, onChange]);
const updateItem = useCallback(
(itemId: string, field: keyof UserStoryItem, value: string) => {
onChange({
items: content.items.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
});
},
[content, onChange]
);
const removeItem = useCallback(
(itemId: string) => {
onChange({
items: content.items.filter((item) => item.id !== itemId),
});
},
[content, onChange]
);
if (!isEditing) {
if (content.items.length === 0) {
return <p className="text-muted-foreground">No user stories yet.</p>;
}
return (
<div className="space-y-2">
{content.items.map((item) => (
<p key={`${rowId}-${item.id}`} className="text-sm">
As a <strong>{item.persona || "..."}</strong>, I want{" "}
<strong>{item.action || "..."}</strong> so that{" "}
<strong>{item.benefit || "..."}</strong>
</p>
))}
</div>
);
}
return (
<div className="space-y-3">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="p-2 text-left font-medium">Persona</th>
<th className="p-2 text-left font-medium">Action</th>
<th className="p-2 text-left font-medium">Benefit</th>
<th className="w-10 p-2" />
</tr>
</thead>
<tbody>
{content.items.map((item) => (
<tr key={`${rowId}-edit-${item.id}`} className="border-b">
<td className="p-1">
<input
type="text"
value={item.persona}
=>
updateItem(item.id, "persona", e.target.value)
}
placeholder="user"
className="w-full rounded border bg-background px-2 py-1 text-sm"
/>
</td>
<td className="p-1">
<input
type="text"
value={item.action}
=>
updateItem(item.id, "action", e.target.value)
}
placeholder="to do something"
className="w-full rounded border bg-background px-2 py-1 text-sm"
/>
</td>
<td className="p-1">
<input
type="text"
value={item.benefit}
=>
updateItem(item.id, "benefit", e.target.value)
}
placeholder="I can achieve a goal"
className="w-full rounded border bg-background px-2 py-1 text-sm"
/>
</td>
<td className="p-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
=> removeItem(item.id)}
>
<Trash className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<Button variant="outline" size="sm"
<Plus className="mr-1 h-3.5 w-3.5" />
Add story
</Button>
</div>
);
}
// --- Acceptance Criteria ---
type AcceptanceCriteriaRendererProps = {
content: AcceptanceCriteriaContent;
isEditing: boolean;
onChange: (content: AcceptanceCriteriaContent) => void;
};
function AcceptanceCriteriaRenderer({
content,
isEditing,
onChange,
}: AcceptanceCriteriaRendererProps) {
const itemId = useId();
const addItem = useCallback(() => {
onChange({
items: [
...content.items,
{ id: crypto.randomUUID(), criterion: "", checked: false },
],
});
}, [content, onChange]);
const toggleItem = useCallback(
(criterionId: string) => {
onChange({
items: content.items.map((item) =>
item.id === criterionId
? { ...item, checked: !item.checked }
: item
),
});
},
[content, onChange]
);
const updateItem = useCallback(
(criterionId: string, value: string) => {
onChange({
items: content.items.map((item) =>
item.id === criterionId ? { ...item, criterion: value } : item
),
});
},
[content, onChange]
);
const removeItem = useCallback(
(criterionId: string) => {
onChange({
items: content.items.filter((item) => item.id !== criterionId),
});
},
[content, onChange]
);
if (!isEditing) {
if (content.items.length === 0) {
return (
<p className="text-muted-foreground">No acceptance criteria yet.</p>
);
}
return (
<ul className="space-y-1">
{content.items.map((item) => (
<li
key={`${itemId}-${item.id}`}
className="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={item.checked}
=> toggleItem(item.id)}
className="h-4 w-4 rounded border"
/>
<span className={item.checked ? "line-through opacity-60" : ""}>
{item.criterion || "..."}
</span>
</li>
))}
</ul>
);
}
return (
<div className="space-y-2">
{content.items.map((item) => (
<div
key={`${itemId}-edit-${item.id}`}
className="flex items-center gap-2"
>
<input
type="checkbox"
checked={item.checked}
=> toggleItem(item.id)}
className="h-4 w-4 rounded border"
/>
<input
type="text"
value={item.criterion}
=> updateItem(item.id, e.target.value)}
placeholder="Criterion..."
className="flex-1 rounded border bg-background px-2 py-1 text-sm"
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
=> removeItem(item.id)}
>
<Trash className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button variant="outline" size="sm"
<Plus className="mr-1 h-3.5 w-3.5" />
Add criterion
</Button>
</div>
);
}
// --- Edge Cases ---
type EdgeCasesRendererProps = {
content: EdgeCasesContent;
isEditing: boolean;
onChange: (content: EdgeCasesContent) => void;
};
function EdgeCasesRenderer({
content,
isEditing,
onChange,
}: EdgeCasesRendererProps) {
const rowId = useId();
const addItem = useCallback(() => {
onChange({
items: [
...content.items,
{ id: crypto.randomUUID(), scenario: "", expectedBehavior: "" },
],
});
}, [content, onChange]);
const updateItem = useCallback(
(itemId: string, field: keyof EdgeCaseItem, value: string) => {
onChange({
items: content.items.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
});
},
[content, onChange]
);
const removeItem = useCallback(
(itemId: string) => {
onChange({
items: content.items.filter((item) => item.id !== itemId),
});
},
[content, onChange]
);
if (!isEditing) {
if (content.items.length === 0) {
return <p className="text-muted-foreground">No edge cases yet.</p>;
}
return (
<div className="space-y-2">
{content.items.map((item) => (
<div
key={`${rowId}-${item.id}`}
className="rounded border p-2 text-sm"
>
<p>
<strong>Scenario:</strong> {item.scenario || "..."}
</p>
<p>
<strong>Expected:</strong> {item.expectedBehavior || "..."}
</p>
</div>
))}
</div>
);
}
return (
<div className="space-y-3">
{content.items.map((item) => (
<div
key={`${rowId}-edit-${item.id}`}
className="flex gap-2 rounded border p-2"
>
<div className="flex-1 space-y-1">
<input
type="text"
value={item.scenario}
=>
updateItem(item.id, "scenario", e.target.value)
}
placeholder="Scenario..."
className="w-full rounded border bg-background px-2 py-1 text-sm"
/>
<input
type="text"
value={item.expectedBehavior}
=>
updateItem(item.id, "expectedBehavior", e.target.value)
}
placeholder="Expected behavior..."
className="w-full rounded border bg-background px-2 py-1 text-sm"
/>
</div>
<Button
variant="ghost"
size="icon"
…(truncated)