Claude sub-agents, PRD, MCP improvements (#359)
1. Added Claude Code sub-agents 2. Added PRD tool to MCP Server 3. Added MCP Server UI to Dev Tools 4. Improved MCP Server Database Tool 5. Updated dependencies
This commit is contained in:
committed by
GitHub
parent
02e2502dcc
commit
2b8572baaa
@@ -0,0 +1,13 @@
|
||||
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||
import { McpServerTabs } from './mcp-server-tabs';
|
||||
import { PRDManagerClient } from './prd-manager-client';
|
||||
|
||||
export async function McpServerInterface() {
|
||||
const initialPrds = await loadPRDs();
|
||||
|
||||
return (
|
||||
<McpServerTabs
|
||||
prdManagerContent={<PRDManagerClient initialPrds={initialPrds} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
apps/dev-tool/app/mcp-server/_components/mcp-server-tabs.tsx
Normal file
53
apps/dev-tool/app/mcp-server/_components/mcp-server-tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { DatabaseIcon, FileTextIcon } from 'lucide-react';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||
|
||||
interface McpServerTabsProps {
|
||||
prdManagerContent: React.ReactNode;
|
||||
databaseToolsContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function McpServerTabs({
|
||||
prdManagerContent,
|
||||
databaseToolsContent,
|
||||
}: McpServerTabsProps) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Tabs defaultValue="database-tools" className="flex h-full flex-col">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger
|
||||
value="database-tools"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<DatabaseIcon className="h-4 w-4" />
|
||||
Database Tools
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="prd-manager" className="flex items-center gap-2">
|
||||
<FileTextIcon className="h-4 w-4" />
|
||||
PRD Manager
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="database-tools" className="flex-1 space-y-4">
|
||||
{databaseToolsContent || (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<DatabaseIcon className="text-muted-foreground mx-auto h-12 w-12" />
|
||||
<h3 className="mt-4 text-lg font-semibold">Database Tools</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Explore database schemas, tables, functions, and enums
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="prd-manager" className="flex-1 space-y-4">
|
||||
{prdManagerContent}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
apps/dev-tool/app/mcp-server/_components/prd-manager-client.tsx
Normal file
188
apps/dev-tool/app/mcp-server/_components/prd-manager-client.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarIcon, FileTextIcon, PlusIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Progress } from '@kit/ui/progress';
|
||||
|
||||
import type { CreatePRDData } from '../_lib/schemas/create-prd.schema';
|
||||
import { createPRDAction } from '../_lib/server/prd-server-actions';
|
||||
import { CreatePRDForm } from './create-prd-form';
|
||||
|
||||
interface PRDSummary {
|
||||
filename: string;
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
progress: number;
|
||||
totalStories: number;
|
||||
completedStories: number;
|
||||
}
|
||||
|
||||
interface PRDManagerClientProps {
|
||||
initialPrds: PRDSummary[];
|
||||
}
|
||||
|
||||
export function PRDManagerClient({ initialPrds }: PRDManagerClientProps) {
|
||||
const [prds, setPrds] = useState<PRDSummary[]>(initialPrds);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
const handleCreatePRD = async (data: CreatePRDData) => {
|
||||
const result = await createPRDAction(data);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const newPRD: PRDSummary = {
|
||||
filename: result.data.filename,
|
||||
title: result.data.title,
|
||||
lastUpdated: result.data.lastUpdated,
|
||||
progress: result.data.progress,
|
||||
totalStories: result.data.totalStories,
|
||||
completedStories: result.data.completedStories,
|
||||
};
|
||||
|
||||
setPrds((prev) => [...prev, newPRD]);
|
||||
setShowCreateForm(false);
|
||||
|
||||
// Note: In a production app, you might want to trigger a router.refresh()
|
||||
// to reload the server component and get the most up-to-date data
|
||||
} else {
|
||||
// Error handling will be managed by the form component via the action result
|
||||
throw new Error(result.error || 'Failed to create PRD');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPrds = prds.filter(
|
||||
(prd) =>
|
||||
prd.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prd.filename.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with search and create button */}
|
||||
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
|
||||
<Input
|
||||
placeholder="Search PRDs by title or filename..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create New PRD
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PRD List */}
|
||||
{filteredPrds.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileTextIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm ? 'No PRDs match your search' : 'No PRDs found'}
|
||||
</p>
|
||||
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
Create your first PRD
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPrds.map((prd) => (
|
||||
<Link
|
||||
key={prd.filename}
|
||||
href={`/mcp-server/prds/${prd.filename}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start gap-2 text-sm">
|
||||
<FileTextIcon className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||
<span className="line-clamp-2">{prd.title}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{prd.completedStories}/{prd.totalStories} stories
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={prd.progress} className="h-2" />
|
||||
<div className="text-right text-xs font-medium">
|
||||
{prd.progress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
<span>Updated {prd.lastUpdated}</span>
|
||||
</div>
|
||||
|
||||
{/* Filename */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{prd.filename}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create PRD Form Modal */}
|
||||
{showCreateForm && (
|
||||
<Dialog open={showCreateForm} onOpenChange={setShowCreateForm}>
|
||||
<DialogContent className="max-w-4xl overflow-y-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New PRD</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className="overflow-y-auto p-0.5"
|
||||
style={{
|
||||
maxHeight: '800px',
|
||||
}}
|
||||
>
|
||||
<CreatePRDForm onSubmit={handleCreatePRD} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
apps/dev-tool/app/mcp-server/_components/user-story-display.tsx
Normal file
172
apps/dev-tool/app/mcp-server/_components/user-story-display.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
PlayIcon,
|
||||
XCircleIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface CustomPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
order: number;
|
||||
userStoryIds: string[];
|
||||
}
|
||||
|
||||
interface UserStory {
|
||||
id: string;
|
||||
title: string;
|
||||
userStory: string;
|
||||
businessValue: string;
|
||||
acceptanceCriteria: string[];
|
||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||
status:
|
||||
| 'not_started'
|
||||
| 'research'
|
||||
| 'in_progress'
|
||||
| 'review'
|
||||
| 'completed'
|
||||
| 'blocked';
|
||||
notes?: string;
|
||||
estimatedComplexity?: string;
|
||||
dependencies?: string[];
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
interface UserStoryDisplayReadOnlyProps {
|
||||
userStories: UserStory[];
|
||||
customPhases?: CustomPhase[];
|
||||
}
|
||||
|
||||
const priorityLabels = {
|
||||
P0: { label: 'Critical', color: 'destructive' as const },
|
||||
P1: { label: 'High', color: 'default' as const },
|
||||
P2: { label: 'Medium', color: 'secondary' as const },
|
||||
P3: { label: 'Low', color: 'outline' as const },
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
not_started: CircleIcon,
|
||||
research: EyeIcon,
|
||||
in_progress: PlayIcon,
|
||||
review: ClockIcon,
|
||||
completed: CheckCircleIcon,
|
||||
blocked: XCircleIcon,
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
not_started: 'Not Started',
|
||||
research: 'Research',
|
||||
in_progress: 'In Progress',
|
||||
review: 'Review',
|
||||
completed: 'Completed',
|
||||
blocked: 'Blocked',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
not_started: 'text-muted-foreground',
|
||||
research: 'text-blue-600',
|
||||
in_progress: 'text-yellow-600',
|
||||
review: 'text-purple-600',
|
||||
completed: 'text-green-600',
|
||||
blocked: 'text-red-600',
|
||||
};
|
||||
|
||||
export function UserStoryDisplay({
|
||||
userStories,
|
||||
}: UserStoryDisplayReadOnlyProps) {
|
||||
const renderUserStory = (story: UserStory) => {
|
||||
return (
|
||||
<Card key={story.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start gap-4 text-sm">
|
||||
<span className="line-clamp-2">
|
||||
{story.id} - {story.title}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={statusColors[story.status]} variant={'outline'}>
|
||||
{statusLabels[story.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||
{story.businessValue}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{story.acceptanceCriteria.length} criteria
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Acceptance Criteria */}
|
||||
{story.acceptanceCriteria.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<h5 className="text-xs font-medium">Acceptance Criteria:</h5>
|
||||
<ul className="space-y-1">
|
||||
{story.acceptanceCriteria.map((criterion, index) => (
|
||||
<li key={index} className="flex items-start gap-1 text-xs">
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="text-muted-foreground line-clamp-1">
|
||||
{criterion}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">User Stories</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
View user stories and track progress
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{userStories.map(renderUserStory)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{userStories.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<CircleIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2">
|
||||
No user stories yet
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreatePRDSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(200, 'Title must be less than 200 characters'),
|
||||
overview: z
|
||||
.string()
|
||||
.min(1, 'Overview is required')
|
||||
.max(1000, 'Overview must be less than 1000 characters'),
|
||||
problemStatement: z
|
||||
.string()
|
||||
.min(1, 'Problem statement is required')
|
||||
.max(1000, 'Problem statement must be less than 1000 characters'),
|
||||
marketOpportunity: z
|
||||
.string()
|
||||
.min(1, 'Market opportunity is required')
|
||||
.max(1000, 'Market opportunity must be less than 1000 characters'),
|
||||
targetUsers: z
|
||||
.array(z.string().min(1, 'Target user cannot be empty'))
|
||||
.min(1, 'At least one target user is required'),
|
||||
solutionDescription: z
|
||||
.string()
|
||||
.min(1, 'Solution description is required')
|
||||
.max(1000, 'Solution description must be less than 1000 characters'),
|
||||
keyFeatures: z
|
||||
.array(z.string().min(1, 'Feature cannot be empty'))
|
||||
.min(1, 'At least one key feature is required'),
|
||||
successMetrics: z
|
||||
.array(z.string().min(1, 'Metric cannot be empty'))
|
||||
.min(1, 'At least one success metric is required'),
|
||||
});
|
||||
|
||||
export type CreatePRDData = z.infer<typeof CreatePRDSchema>;
|
||||
48
apps/dev-tool/app/mcp-server/_lib/server/prd-loader.ts
Normal file
48
apps/dev-tool/app/mcp-server/_lib/server/prd-loader.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { relative } from 'node:path';
|
||||
|
||||
import { PRDManager } from '@kit/mcp-server/prd-manager';
|
||||
|
||||
interface PRDSummary {
|
||||
filename: string;
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
progress: number;
|
||||
totalStories: number;
|
||||
completedStories: number;
|
||||
}
|
||||
|
||||
export async function loadPRDs(): Promise<PRDSummary[]> {
|
||||
try {
|
||||
PRDManager.setRootPath(relative(process.cwd(), '../..'));
|
||||
|
||||
// Use the actual PRDManager to list PRDs
|
||||
const prdFiles = await PRDManager.listPRDs();
|
||||
|
||||
const prdSummaries: PRDSummary[] = [];
|
||||
|
||||
// Load each PRD to get its details
|
||||
for (const filename of prdFiles) {
|
||||
try {
|
||||
const content = await PRDManager.getPRDContent(filename);
|
||||
const prd = JSON.parse(content);
|
||||
|
||||
prdSummaries.push({
|
||||
filename,
|
||||
title: prd.introduction.title,
|
||||
lastUpdated: prd.metadata.lastUpdated,
|
||||
progress: prd.progress.overall,
|
||||
totalStories: prd.progress.total,
|
||||
completedStories: prd.progress.completed,
|
||||
});
|
||||
} catch (prdError) {
|
||||
console.error(`Failed to load PRD ${filename}:`, prdError);
|
||||
// Continue with other PRDs even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return prdSummaries;
|
||||
} catch (error) {
|
||||
console.error('Failed to load PRDs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
78
apps/dev-tool/app/mcp-server/_lib/server/prd-page.loader.ts
Normal file
78
apps/dev-tool/app/mcp-server/_lib/server/prd-page.loader.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'server-only';
|
||||
|
||||
import { relative } from 'node:path';
|
||||
|
||||
import { PRDManager } from '@kit/mcp-server/prd-manager';
|
||||
|
||||
export interface CustomPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
order: number;
|
||||
userStoryIds: string[];
|
||||
}
|
||||
|
||||
export interface PRDData {
|
||||
introduction: {
|
||||
title: string;
|
||||
overview: string;
|
||||
lastUpdated: string;
|
||||
};
|
||||
problemStatement: {
|
||||
problem: string;
|
||||
marketOpportunity: string;
|
||||
targetUsers: string[];
|
||||
};
|
||||
solutionOverview: {
|
||||
description: string;
|
||||
keyFeatures: string[];
|
||||
successMetrics: string[];
|
||||
};
|
||||
userStories: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
userStory: string;
|
||||
businessValue: string;
|
||||
acceptanceCriteria: string[];
|
||||
priority: 'P0' | 'P1' | 'P2' | 'P3';
|
||||
status:
|
||||
| 'not_started'
|
||||
| 'research'
|
||||
| 'in_progress'
|
||||
| 'review'
|
||||
| 'completed'
|
||||
| 'blocked';
|
||||
notes?: string;
|
||||
estimatedComplexity?: string;
|
||||
dependencies?: string[];
|
||||
completedAt?: string;
|
||||
}>;
|
||||
customPhases?: CustomPhase[];
|
||||
metadata: {
|
||||
version: string;
|
||||
created: string;
|
||||
lastUpdated: string;
|
||||
approver: string;
|
||||
};
|
||||
progress: {
|
||||
overall: number;
|
||||
completed: number;
|
||||
total: number;
|
||||
blocked: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPRDPageData(filename: string): Promise<PRDData> {
|
||||
try {
|
||||
PRDManager.setRootPath(relative(process.cwd(), '../..'));
|
||||
|
||||
const content = await PRDManager.getPRDContent(filename);
|
||||
|
||||
return JSON.parse(content) as PRDData;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load PRD ${filename}:`, error);
|
||||
|
||||
throw new Error(`PRD not found: ${filename}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
DatabaseIcon,
|
||||
FunctionSquareIcon,
|
||||
LayersIcon,
|
||||
ListIcon,
|
||||
SearchIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||
|
||||
import type {
|
||||
DatabaseEnum,
|
||||
DatabaseFunction,
|
||||
DatabaseTable,
|
||||
SchemaFile,
|
||||
} from '../_lib/server/database-tools.loader';
|
||||
import { EnumBrowser } from './enum-browser';
|
||||
import { FunctionBrowser } from './function-browser';
|
||||
import { SchemaExplorer } from './schema-explorer';
|
||||
import { TableBrowser } from './table-browser';
|
||||
|
||||
interface DatabaseToolsData {
|
||||
schemaFiles: SchemaFile[];
|
||||
tables: DatabaseTable[];
|
||||
functions: DatabaseFunction[];
|
||||
enums: DatabaseEnum[];
|
||||
}
|
||||
|
||||
interface DatabaseToolsInterfaceProps {
|
||||
searchTerm: string;
|
||||
databaseData: DatabaseToolsData;
|
||||
}
|
||||
|
||||
export function DatabaseToolsInterface({
|
||||
searchTerm: initialSearchTerm,
|
||||
databaseData,
|
||||
}: DatabaseToolsInterfaceProps) {
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DatabaseIcon className="h-6 w-6" />
|
||||
<h1 className="text-2xl font-bold">Database Tools</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Explore database schemas, tables, functions, and enums
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search tables, functions, schemas..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Database Tools Tabs */}
|
||||
<Tabs defaultValue="schemas" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="schemas" className="flex items-center gap-2">
|
||||
<LayersIcon className="h-4 w-4" />
|
||||
Schemas
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tables" className="flex items-center gap-2">
|
||||
<DatabaseIcon className="h-4 w-4" />
|
||||
Tables
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="functions" className="flex items-center gap-2">
|
||||
<FunctionSquareIcon className="h-4 w-4" />
|
||||
Functions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="enums" className="flex items-center gap-2">
|
||||
<ListIcon className="h-4 w-4" />
|
||||
Enums
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="schemas" className="space-y-4">
|
||||
<SchemaExplorer
|
||||
searchTerm={searchTerm}
|
||||
schemaFiles={databaseData.schemaFiles}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tables" className="space-y-4">
|
||||
<TableBrowser searchTerm={searchTerm} tables={databaseData.tables} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="functions" className="space-y-4">
|
||||
<FunctionBrowser
|
||||
searchTerm={searchTerm}
|
||||
functions={databaseData.functions}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="enums" className="space-y-4">
|
||||
<EnumBrowser searchTerm={searchTerm} enums={databaseData.enums} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
CheckCircleIcon,
|
||||
CircleDollarSignIcon,
|
||||
CreditCardIcon,
|
||||
DatabaseIcon,
|
||||
ListIcon,
|
||||
ShieldIcon,
|
||||
TagIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
|
||||
interface DatabaseEnum {
|
||||
name: string;
|
||||
values: string[];
|
||||
sourceFile: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface EnumBrowserProps {
|
||||
searchTerm: string;
|
||||
enums: DatabaseEnum[];
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
'Security & Permissions': 'bg-red-100 text-red-800',
|
||||
'Billing & Payments': 'bg-green-100 text-green-800',
|
||||
Notifications: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, React.ComponentType<any>> = {
|
||||
'Security & Permissions': ShieldIcon,
|
||||
'Billing & Payments': CreditCardIcon,
|
||||
Notifications: BellIcon,
|
||||
};
|
||||
|
||||
const valueColors: Record<string, string> = {
|
||||
// Permission colors
|
||||
'roles.manage': 'bg-purple-100 text-purple-800',
|
||||
'billing.manage': 'bg-green-100 text-green-800',
|
||||
'settings.manage': 'bg-blue-100 text-blue-800',
|
||||
'members.manage': 'bg-orange-100 text-orange-800',
|
||||
'invites.manage': 'bg-teal-100 text-teal-800',
|
||||
|
||||
// Payment provider colors
|
||||
stripe: 'bg-purple-100 text-purple-800',
|
||||
'lemon-squeezy': 'bg-yellow-100 text-yellow-800',
|
||||
paddle: 'bg-blue-100 text-blue-800',
|
||||
|
||||
// Notification channel colors
|
||||
in_app: 'bg-blue-100 text-blue-800',
|
||||
email: 'bg-green-100 text-green-800',
|
||||
|
||||
// Notification type colors
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
|
||||
// Payment status colors
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
succeeded: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
|
||||
// Subscription item type colors
|
||||
flat: 'bg-gray-100 text-gray-800',
|
||||
per_seat: 'bg-orange-100 text-orange-800',
|
||||
metered: 'bg-purple-100 text-purple-800',
|
||||
|
||||
// Subscription status colors
|
||||
active: 'bg-green-100 text-green-800',
|
||||
trialing: 'bg-blue-100 text-blue-800',
|
||||
past_due: 'bg-yellow-100 text-yellow-800',
|
||||
canceled: 'bg-red-100 text-red-800',
|
||||
unpaid: 'bg-red-100 text-red-800',
|
||||
incomplete: 'bg-gray-100 text-gray-800',
|
||||
incomplete_expired: 'bg-gray-100 text-gray-800',
|
||||
paused: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export function EnumBrowser({ searchTerm, enums }: EnumBrowserProps) {
|
||||
const [selectedEnum, setSelectedEnum] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Filter enums based on search term
|
||||
const filteredEnums = enums.filter(
|
||||
(enumItem) =>
|
||||
enumItem.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
enumItem.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
enumItem.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
enumItem.values.some((value) =>
|
||||
value.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
// Group enums by category
|
||||
const enumsByCategory = filteredEnums.reduce(
|
||||
(acc, enumItem) => {
|
||||
if (!acc[enumItem.category]) {
|
||||
acc[enumItem.category] = [];
|
||||
}
|
||||
acc[enumItem.category].push(enumItem);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, DatabaseEnum[]>,
|
||||
);
|
||||
|
||||
const handleEnumClick = (enumName: string) => {
|
||||
setSelectedEnum(enumName);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setSelectedEnum(null);
|
||||
};
|
||||
|
||||
const getValueBadgeColor = (value: string): string => {
|
||||
return valueColors[value] || 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Total Enums
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{filteredEnums.length}</p>
|
||||
</div>
|
||||
<ListIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Categories
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{Object.keys(enumsByCategory).length}
|
||||
</p>
|
||||
</div>
|
||||
<TagIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Total Values
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{filteredEnums.reduce(
|
||||
(acc, enumItem) => acc + enumItem.values.length,
|
||||
0,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Enums by Category */}
|
||||
{Object.entries(enumsByCategory).map(([category, categoryEnums]) => {
|
||||
const IconComponent = categoryIcons[category] || ListIcon;
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={
|
||||
categoryColors[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
>
|
||||
<IconComponent className="mr-1 h-3 w-3" />
|
||||
{category}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{categoryEnums.length} enum
|
||||
{categoryEnums.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||
{categoryEnums.map((enumItem) => (
|
||||
<Card
|
||||
key={enumItem.name}
|
||||
className="cursor-pointer transition-all hover:shadow-md"
|
||||
onClick={() => handleEnumClick(enumItem.name)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<ListIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="font-mono">{enumItem.name}</span>
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{enumItem.values.length} value
|
||||
{enumItem.values.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||
{enumItem.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{enumItem.values.slice(0, 4).map((value) => (
|
||||
<Badge
|
||||
key={value}
|
||||
variant="outline"
|
||||
className={`text-xs ${getValueBadgeColor(value)}`}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
))}
|
||||
{enumItem.values.length > 4 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{enumItem.values.length - 4} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{enumItem.sourceFile}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Click for details
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Enum Details Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ListIcon className="h-5 w-5" />
|
||||
Enum Details: {selectedEnum}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const enumItem = enums.find((e) => e.name === selectedEnum);
|
||||
if (!enumItem) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Description</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{enumItem.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 font-medium">
|
||||
Values ({enumItem.values.length})
|
||||
</h4>
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
{enumItem.values.map((value, index) => (
|
||||
<div
|
||||
key={value}
|
||||
className="flex items-center gap-2 rounded border p-2"
|
||||
>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-sm ${getValueBadgeColor(value)}`}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Category</h4>
|
||||
<Badge
|
||||
className={
|
||||
categoryColors[enumItem.category] ||
|
||||
'bg-gray-100 text-gray-800'
|
||||
}
|
||||
>
|
||||
<TagIcon className="mr-1 h-3 w-3" />
|
||||
{enumItem.category}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Source File</h4>
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
{enumItem.sourceFile}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Usage Examples</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted rounded p-3">
|
||||
<code className="text-muted-foreground text-sm">
|
||||
CREATE TABLE example_table (
|
||||
</code>
|
||||
<br />
|
||||
<code className="text-muted-foreground ml-4 text-sm">
|
||||
id uuid PRIMARY KEY,
|
||||
</code>
|
||||
<br />
|
||||
<code className="text-muted-foreground ml-4 text-sm">
|
||||
status {enumItem.name} NOT NULL
|
||||
</code>
|
||||
<br />
|
||||
<code className="text-muted-foreground text-sm">
|
||||
);
|
||||
</code>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-3">
|
||||
<code className="text-muted-foreground text-sm">
|
||||
SELECT * FROM table_name WHERE status = '
|
||||
{enumItem.values[0]}';
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{filteredEnums.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ListIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm ? 'No enums match your search' : 'No enums found'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
DatabaseIcon,
|
||||
FunctionSquareIcon,
|
||||
KeyIcon,
|
||||
ShieldIcon,
|
||||
UserIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
|
||||
interface DatabaseFunction {
|
||||
name: string;
|
||||
signature: string;
|
||||
returnType: string;
|
||||
purpose: string;
|
||||
source: string;
|
||||
isSecurityDefiner: boolean;
|
||||
isTrigger: boolean;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface FunctionBrowserProps {
|
||||
searchTerm: string;
|
||||
functions: DatabaseFunction[];
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
Triggers: 'bg-red-100 text-red-800',
|
||||
Authentication: 'bg-indigo-100 text-indigo-800',
|
||||
Accounts: 'bg-green-100 text-green-800',
|
||||
Permissions: 'bg-orange-100 text-orange-800',
|
||||
Invitations: 'bg-teal-100 text-teal-800',
|
||||
Billing: 'bg-yellow-100 text-yellow-800',
|
||||
Utilities: 'bg-blue-100 text-blue-800',
|
||||
'Text Processing': 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, React.ComponentType<any>> = {
|
||||
Triggers: ZapIcon,
|
||||
Authentication: ShieldIcon,
|
||||
Accounts: UserIcon,
|
||||
Permissions: KeyIcon,
|
||||
Invitations: UserIcon,
|
||||
Billing: DatabaseIcon,
|
||||
Utilities: FunctionSquareIcon,
|
||||
'Text Processing': FunctionSquareIcon,
|
||||
};
|
||||
|
||||
export function FunctionBrowser({
|
||||
searchTerm,
|
||||
functions,
|
||||
}: FunctionBrowserProps) {
|
||||
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Filter functions based on search term
|
||||
const filteredFunctions = functions.filter(
|
||||
(func) =>
|
||||
func.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
func.purpose.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
func.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
func.source.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// Group functions by category
|
||||
const functionsByCategory = filteredFunctions.reduce(
|
||||
(acc, func) => {
|
||||
if (!acc[func.category]) {
|
||||
acc[func.category] = [];
|
||||
}
|
||||
acc[func.category].push(func);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, DatabaseFunction[]>,
|
||||
);
|
||||
|
||||
const handleFunctionClick = (functionName: string) => {
|
||||
setSelectedFunction(functionName);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const getReturnTypeColor = (returnType: string): string => {
|
||||
if (returnType === 'trigger') return 'bg-red-100 text-red-800';
|
||||
if (returnType === 'boolean') return 'bg-green-100 text-green-800';
|
||||
if (returnType === 'uuid') return 'bg-purple-100 text-purple-800';
|
||||
if (returnType === 'text') return 'bg-blue-100 text-blue-800';
|
||||
if (returnType === 'json' || returnType === 'jsonb')
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
if (returnType === 'TABLE') return 'bg-orange-100 text-orange-800';
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Total Functions
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{filteredFunctions.length}</p>
|
||||
</div>
|
||||
<FunctionSquareIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Categories
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{Object.keys(functionsByCategory).length}
|
||||
</p>
|
||||
</div>
|
||||
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Security Definer
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{filteredFunctions.filter((f) => f.isSecurityDefiner).length}
|
||||
</p>
|
||||
</div>
|
||||
<ShieldIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Functions by Category */}
|
||||
{Object.entries(functionsByCategory).map(
|
||||
([category, categoryFunctions]) => {
|
||||
const IconComponent = categoryIcons[category] || FunctionSquareIcon;
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={
|
||||
categoryColors[category] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
>
|
||||
<IconComponent className="mr-1 h-3 w-3" />
|
||||
{category}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{categoryFunctions.length} function
|
||||
{categoryFunctions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
|
||||
{categoryFunctions.map((func) => (
|
||||
<Card
|
||||
key={func.name}
|
||||
className="cursor-pointer transition-all hover:shadow-md"
|
||||
onClick={() => handleFunctionClick(func.name)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<FunctionSquareIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="font-mono">{func.name}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{func.isSecurityDefiner && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<ShieldIcon className="mr-1 h-3 w-3" />
|
||||
DEFINER
|
||||
</Badge>
|
||||
)}
|
||||
{func.isTrigger && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<ZapIcon className="mr-1 h-3 w-3" />
|
||||
TRIGGER
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRightIcon className="text-muted-foreground h-3 w-3" />
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${getReturnTypeColor(func.returnType)}`}
|
||||
>
|
||||
{func.returnType}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||
{func.purpose}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{func.source}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Click for details
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{/* Function Details Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FunctionSquareIcon className="h-5 w-5" />
|
||||
Function Details: {selectedFunction}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const func = functions.find((f) => f.name === selectedFunction);
|
||||
if (!func) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Signature</h4>
|
||||
<code className="text-muted-foreground bg-muted block rounded p-3 text-sm">
|
||||
{func.signature}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Purpose</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{func.purpose}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Return Type</h4>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={getReturnTypeColor(func.returnType)}
|
||||
>
|
||||
{func.returnType}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Source File</h4>
|
||||
<span className="text-muted-foreground font-mono text-sm">
|
||||
{func.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Properties</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
<DatabaseIcon className="mr-1 h-3 w-3" />
|
||||
{func.category}
|
||||
</Badge>
|
||||
{func.isSecurityDefiner && (
|
||||
<Badge variant="default">
|
||||
<ShieldIcon className="mr-1 h-3 w-3" />
|
||||
Security Definer
|
||||
</Badge>
|
||||
)}
|
||||
{func.isTrigger && (
|
||||
<Badge variant="secondary">
|
||||
<ZapIcon className="mr-1 h-3 w-3" />
|
||||
Trigger Function
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{filteredFunctions.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FunctionSquareIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm
|
||||
? 'No functions match your search'
|
||||
: 'No functions found'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FileTextIcon, LayersIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
|
||||
interface SchemaFile {
|
||||
filename: string;
|
||||
topic: string;
|
||||
description: string;
|
||||
section?: string;
|
||||
tables?: string[];
|
||||
functions?: string[];
|
||||
}
|
||||
|
||||
interface SchemaExplorerProps {
|
||||
searchTerm: string;
|
||||
schemaFiles: SchemaFile[];
|
||||
}
|
||||
|
||||
const topicColors: Record<string, string> = {
|
||||
security: 'bg-red-100 text-red-800',
|
||||
types: 'bg-purple-100 text-purple-800',
|
||||
configuration: 'bg-blue-100 text-blue-800',
|
||||
accounts: 'bg-green-100 text-green-800',
|
||||
permissions: 'bg-orange-100 text-orange-800',
|
||||
teams: 'bg-teal-100 text-teal-800',
|
||||
billing: 'bg-yellow-100 text-yellow-800',
|
||||
notifications: 'bg-pink-100 text-pink-800',
|
||||
auth: 'bg-indigo-100 text-indigo-800',
|
||||
admin: 'bg-gray-100 text-gray-800',
|
||||
storage: 'bg-cyan-100 text-cyan-800',
|
||||
};
|
||||
|
||||
export function SchemaExplorer({
|
||||
searchTerm,
|
||||
schemaFiles,
|
||||
}: SchemaExplorerProps) {
|
||||
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Filter schemas based on search term
|
||||
const filteredSchemas = schemaFiles.filter(
|
||||
(schema) =>
|
||||
schema.filename.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
schema.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
schema.topic.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
schema.section?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// Group schemas by topic for better organization
|
||||
const schemasByTopic = filteredSchemas.reduce(
|
||||
(acc, schema) => {
|
||||
if (!acc[schema.topic]) {
|
||||
acc[schema.topic] = [];
|
||||
}
|
||||
acc[schema.topic].push(schema);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SchemaFile[]>,
|
||||
);
|
||||
|
||||
const handleSchemaClick = (schema: SchemaFile) => {
|
||||
setSelectedSchema(schema);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setSelectedSchema(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Total Schemas
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{filteredSchemas.length}</p>
|
||||
</div>
|
||||
<LayersIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Topics
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{Object.keys(schemasByTopic).length}
|
||||
</p>
|
||||
</div>
|
||||
<FileTextIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Total Tables
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{filteredSchemas.reduce(
|
||||
(acc, schema) => acc + (schema.tables?.length || 0),
|
||||
0,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<LayersIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Schema Files by Topic */}
|
||||
{Object.entries(schemasByTopic).map(([topic, schemas]) => (
|
||||
<div key={topic} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={topicColors[topic] || 'bg-gray-100 text-gray-800'}
|
||||
>
|
||||
{topic.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{schemas.length} file{schemas.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{schemas.map((schema) => (
|
||||
<Card
|
||||
key={schema.filename}
|
||||
className="cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={() => handleSchemaClick(schema)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||
<span className="line-clamp-1 flex-1">
|
||||
{schema.filename}
|
||||
</span>
|
||||
<FileTextIcon className="text-muted-foreground h-4 w-4" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-muted-foreground line-clamp-2 text-sm">
|
||||
{schema.description}
|
||||
</p>
|
||||
|
||||
{schema.section && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Section:
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{schema.section}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(schema.tables || schema.functions) && (
|
||||
<div className="space-y-2 text-xs">
|
||||
{schema.tables && schema.tables.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">Tables: </span>
|
||||
<span className="text-muted-foreground">
|
||||
{schema.tables.slice(0, 3).join(', ')}
|
||||
{schema.tables.length > 3 &&
|
||||
` +${schema.tables.length - 3} more`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schema.functions && schema.functions.length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">Functions: </span>
|
||||
<span className="text-muted-foreground">
|
||||
{schema.functions.slice(0, 2).join(', ')}
|
||||
{schema.functions.length > 2 &&
|
||||
` +${schema.functions.length - 2} more`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Schema Details Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileTextIcon className="h-5 w-5" />
|
||||
{selectedSchema?.filename}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{selectedSchema && (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Description</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{selectedSchema.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedSchema.section && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Section</h4>
|
||||
<Badge variant="outline">{selectedSchema.section}</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedSchema.tables && selectedSchema.tables.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">
|
||||
Tables ({selectedSchema.tables.length})
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedSchema.tables.map((table) => (
|
||||
<Badge
|
||||
key={table}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{table}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedSchema.functions &&
|
||||
selectedSchema.functions.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">
|
||||
Functions ({selectedSchema.functions.length})
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedSchema.functions.map((func) => (
|
||||
<Badge
|
||||
key={func}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{func}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{filteredSchemas.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<LayersIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm
|
||||
? 'No schemas match your search'
|
||||
: 'No schemas found'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { DatabaseIcon, KeyIcon, LinkIcon, TableIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
|
||||
import { getTableDetailsAction } from '../_lib/server/table-server-actions';
|
||||
|
||||
interface TableColumn {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
defaultValue: string | null;
|
||||
isPrimaryKey: boolean;
|
||||
isForeignKey: boolean;
|
||||
referencedTable: string | null;
|
||||
referencedColumn: string | null;
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
sourceFile: string;
|
||||
topic: string;
|
||||
columns: TableColumn[];
|
||||
foreignKeys: Array<{
|
||||
name: string;
|
||||
columns: string[];
|
||||
referencedTable: string;
|
||||
referencedColumns: string[];
|
||||
onDelete: string;
|
||||
onUpdate: string;
|
||||
}>;
|
||||
indexes: Array<{
|
||||
name: string;
|
||||
columns: string[];
|
||||
unique: boolean;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TableSummary {
|
||||
name: string;
|
||||
schema: string;
|
||||
sourceFile: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
interface TableBrowserProps {
|
||||
searchTerm: string;
|
||||
tables: TableSummary[];
|
||||
}
|
||||
|
||||
const topicColors: Record<string, string> = {
|
||||
accounts: 'bg-green-100 text-green-800',
|
||||
teams: 'bg-teal-100 text-teal-800',
|
||||
billing: 'bg-yellow-100 text-yellow-800',
|
||||
configuration: 'bg-blue-100 text-blue-800',
|
||||
auth: 'bg-indigo-100 text-indigo-800',
|
||||
notifications: 'bg-pink-100 text-pink-800',
|
||||
permissions: 'bg-orange-100 text-orange-800',
|
||||
general: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
uuid: 'bg-purple-100 text-purple-800',
|
||||
text: 'bg-blue-100 text-blue-800',
|
||||
'character varying': 'bg-blue-100 text-blue-800',
|
||||
boolean: 'bg-green-100 text-green-800',
|
||||
integer: 'bg-orange-100 text-orange-800',
|
||||
'timestamp with time zone': 'bg-gray-100 text-gray-800',
|
||||
jsonb: 'bg-yellow-100 text-yellow-800',
|
||||
'USER-DEFINED': 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export function TableBrowser({ searchTerm, tables }: TableBrowserProps) {
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
const [tableDetails, setTableDetails] = useState<TableInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
// Filter tables based on search term
|
||||
const filteredTables = tables.filter(
|
||||
(table) =>
|
||||
table.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.topic.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.sourceFile.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// Group tables by topic
|
||||
const tablesByTopic = filteredTables.reduce(
|
||||
(acc, table) => {
|
||||
if (!acc[table.topic]) {
|
||||
acc[table.topic] = [];
|
||||
}
|
||||
acc[table.topic].push(table);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TableSummary[]>,
|
||||
);
|
||||
|
||||
const handleTableClick = async (tableName: string) => {
|
||||
setSelectedTable(tableName);
|
||||
setIsDialogOpen(true);
|
||||
setLoading(true);
|
||||
setTableDetails(null);
|
||||
|
||||
try {
|
||||
const result = await getTableDetailsAction(tableName, 'public');
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTableDetails(result.data);
|
||||
} else {
|
||||
console.error('Error fetching table details:', result.error);
|
||||
setTableDetails(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching table details:', error);
|
||||
setTableDetails(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setIsDialogOpen(false);
|
||||
setSelectedTable(null);
|
||||
setTableDetails(null);
|
||||
};
|
||||
|
||||
const getColumnTypeDisplay = (type: string) => {
|
||||
const cleanType = type.replace('character varying', 'varchar');
|
||||
return cleanType;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Total Tables
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{filteredTables.length}</p>
|
||||
</div>
|
||||
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Topics
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{Object.keys(tablesByTopic).length}
|
||||
</p>
|
||||
</div>
|
||||
<TableIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Schema
|
||||
</p>
|
||||
<p className="text-2xl font-bold">public</p>
|
||||
</div>
|
||||
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tables by Topic */}
|
||||
{Object.entries(tablesByTopic).map(([topic, topicTables]) => (
|
||||
<div key={topic} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={topicColors[topic] || 'bg-gray-100 text-gray-800'}
|
||||
>
|
||||
{topic.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{topicTables.length} table{topicTables.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{topicTables.map((table) => (
|
||||
<Card
|
||||
key={table.name}
|
||||
className="cursor-pointer transition-all hover:shadow-md"
|
||||
onClick={() => handleTableClick(table.name)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start justify-between gap-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<TableIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="font-mono">{table.name}</span>
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Schema:
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{table.schema}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Source:
|
||||
</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{table.sourceFile}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Click for details
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Table Details Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TableIcon className="h-5 w-5" />
|
||||
Table Details: {selectedTable}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
{loading && (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Loading table details...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedTable && tableDetails && (
|
||||
<>
|
||||
{/* Columns */}
|
||||
<div>
|
||||
<h4 className="mb-3 font-medium">
|
||||
Columns ({tableDetails.columns.length})
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 text-left">Name</th>
|
||||
<th className="py-2 text-left">Type</th>
|
||||
<th className="py-2 text-left">Nullable</th>
|
||||
<th className="py-2 text-left">Default</th>
|
||||
<th className="py-2 text-left">Constraints</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableDetails.columns.map((column) => (
|
||||
<tr key={column.name} className="border-b">
|
||||
<td className="py-2 font-mono">{column.name}</td>
|
||||
<td className="py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
typeColors[column.type] ||
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{getColumnTypeDisplay(column.type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Badge
|
||||
variant={
|
||||
column.nullable ? 'secondary' : 'outline'
|
||||
}
|
||||
>
|
||||
{column.nullable ? 'YES' : 'NO'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground py-2 font-mono text-xs">
|
||||
{column.defaultValue || '—'}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex gap-1">
|
||||
{column.isPrimaryKey && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<KeyIcon className="mr-1 h-3 w-3" />
|
||||
PK
|
||||
</Badge>
|
||||
)}
|
||||
{column.isForeignKey && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<LinkIcon className="mr-1 h-3 w-3" />
|
||||
FK
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Foreign Keys */}
|
||||
{tableDetails.foreignKeys.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="mb-3 font-medium">
|
||||
Foreign Keys ({tableDetails.foreignKeys.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{tableDetails.foreignKeys.map((fk) => (
|
||||
<div
|
||||
key={fk.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<LinkIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="font-mono">
|
||||
{fk.columns.join(', ')}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="font-mono">
|
||||
{fk.referencedTable}.
|
||||
{fk.referencedColumns.join(', ')}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
ON DELETE {fk.onDelete}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Indexes */}
|
||||
{tableDetails.indexes.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="mb-3 font-medium">
|
||||
Indexes ({tableDetails.indexes.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{tableDetails.indexes.map((index) => (
|
||||
<div
|
||||
key={index.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">
|
||||
{index.name}
|
||||
</span>
|
||||
{index.unique && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
UNIQUE
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{index.type.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-muted-foreground">on</span>
|
||||
<span className="font-mono text-xs">
|
||||
{index.columns.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{selectedTable && !tableDetails && !loading && (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No detailed information available for this table.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{filteredTables.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<DatabaseIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm ? 'No tables match your search' : 'No tables found'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'server-only';
|
||||
|
||||
import { relative } from 'path';
|
||||
|
||||
import { DatabaseTool } from '@kit/mcp-server/database';
|
||||
|
||||
export interface DatabaseTable {
|
||||
name: string;
|
||||
schema: string;
|
||||
sourceFile: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface DatabaseFunction {
|
||||
name: string;
|
||||
signature: string;
|
||||
returnType: string;
|
||||
purpose: string;
|
||||
source: string;
|
||||
isSecurityDefiner: boolean;
|
||||
isTrigger: boolean;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface DatabaseEnum {
|
||||
name: string;
|
||||
values: string[];
|
||||
sourceFile: string;
|
||||
category: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SchemaFile {
|
||||
filename: string;
|
||||
topic: string;
|
||||
description: string;
|
||||
section?: string;
|
||||
tables?: string[];
|
||||
functions?: string[];
|
||||
}
|
||||
|
||||
export async function loadDatabaseToolsData() {
|
||||
DatabaseTool.ROOT_PATH = relative(process.cwd(), '../..');
|
||||
|
||||
try {
|
||||
const [
|
||||
schemaFilesResponse,
|
||||
tablesResponse,
|
||||
functionsResponse,
|
||||
enumsResponse,
|
||||
] = await Promise.all([
|
||||
DatabaseTool.getSchemaFiles(),
|
||||
DatabaseTool.getAllProjectTables(),
|
||||
DatabaseTool.getFunctions(),
|
||||
DatabaseTool.getAllEnums(),
|
||||
]);
|
||||
|
||||
// Process schema files
|
||||
const schemaFiles: SchemaFile[] = schemaFilesResponse.map((file) => ({
|
||||
filename: file.name,
|
||||
topic: file.topic || 'general',
|
||||
description: file.description || 'Database schema file',
|
||||
section: file.section,
|
||||
tables: file.tables,
|
||||
functions: file.functions,
|
||||
}));
|
||||
|
||||
// Process tables
|
||||
const tables: DatabaseTable[] = tablesResponse.map((table) => ({
|
||||
name: table.name,
|
||||
schema: table.schema || 'public',
|
||||
sourceFile: table.sourceFile || 'unknown',
|
||||
topic: table.topic || 'general',
|
||||
}));
|
||||
|
||||
// Process functions - parse the structured function data
|
||||
const functions: DatabaseFunction[] = functionsResponse.map((func: any) => {
|
||||
// Determine category based on function name and purpose
|
||||
let category = 'Utilities';
|
||||
if (func.returnType === 'trigger') {
|
||||
category = 'Triggers';
|
||||
} else if (
|
||||
func.name.includes('nonce') ||
|
||||
func.name.includes('mfa') ||
|
||||
func.name.includes('auth') ||
|
||||
func.name.includes('aal2')
|
||||
) {
|
||||
category = 'Authentication';
|
||||
} else if (
|
||||
func.name.includes('account') ||
|
||||
func.name.includes('team') ||
|
||||
func.name.includes('user')
|
||||
) {
|
||||
category = 'Accounts';
|
||||
} else if (
|
||||
func.name.includes('permission') ||
|
||||
func.name.includes('role') ||
|
||||
func.name.includes('member')
|
||||
) {
|
||||
category = 'Permissions';
|
||||
} else if (
|
||||
func.name.includes('invitation') ||
|
||||
func.name.includes('invite')
|
||||
) {
|
||||
category = 'Invitations';
|
||||
} else if (
|
||||
func.name.includes('billing') ||
|
||||
func.name.includes('subscription') ||
|
||||
func.name.includes('payment') ||
|
||||
func.name.includes('order')
|
||||
) {
|
||||
category = 'Billing';
|
||||
}
|
||||
|
||||
return {
|
||||
name: func.name,
|
||||
signature: func.signature || func.name,
|
||||
returnType: func.returnType || 'unknown',
|
||||
purpose: func.purpose || 'Database function',
|
||||
source: func.source || 'unknown',
|
||||
isSecurityDefiner: func.isSecurityDefiner || false,
|
||||
isTrigger: func.returnType === 'trigger',
|
||||
category,
|
||||
};
|
||||
});
|
||||
|
||||
// Process enums
|
||||
const enums: DatabaseEnum[] = Object.entries(enumsResponse).map(
|
||||
([name, data]: [string, any]) => {
|
||||
let category = 'General';
|
||||
let description = `Database enum type: ${name}`;
|
||||
|
||||
// Categorize enums based on name
|
||||
if (name.includes('permission')) {
|
||||
category = 'Security & Permissions';
|
||||
description =
|
||||
'Application-level permissions that can be assigned to roles for granular access control';
|
||||
} else if (
|
||||
name.includes('billing') ||
|
||||
name.includes('payment') ||
|
||||
name.includes('subscription')
|
||||
) {
|
||||
category = 'Billing & Payments';
|
||||
if (name === 'billing_provider') {
|
||||
description =
|
||||
'Supported payment processing providers for handling subscriptions and transactions';
|
||||
} else if (name === 'payment_status') {
|
||||
description =
|
||||
'Status values for tracking the state of payment transactions';
|
||||
} else if (name === 'subscription_status') {
|
||||
description =
|
||||
'Comprehensive status tracking for subscription lifecycle management';
|
||||
} else if (name === 'subscription_item_type') {
|
||||
description =
|
||||
'Different pricing models for subscription line items and billing calculations';
|
||||
}
|
||||
} else if (name.includes('notification')) {
|
||||
category = 'Notifications';
|
||||
if (name === 'notification_channel') {
|
||||
description =
|
||||
'Available channels for delivering notifications to users';
|
||||
} else if (name === 'notification_type') {
|
||||
description =
|
||||
'Classification types for different notification severity levels';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
values: data.values || [],
|
||||
sourceFile: data.sourceFile || 'database',
|
||||
category,
|
||||
description,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
schemaFiles,
|
||||
tables,
|
||||
functions,
|
||||
enums,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error loading database tools data:', error);
|
||||
|
||||
// Return empty data structures on error
|
||||
return {
|
||||
schemaFiles: [],
|
||||
tables: [],
|
||||
functions: [],
|
||||
enums: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'use server';
|
||||
|
||||
import { relative } from 'path';
|
||||
|
||||
import { DatabaseTool } from '@kit/mcp-server/database';
|
||||
|
||||
export async function getTableDetailsAction(
|
||||
tableName: string,
|
||||
schema = 'public',
|
||||
) {
|
||||
try {
|
||||
DatabaseTool.ROOT_PATH = relative(process.cwd(), '../..');
|
||||
|
||||
console.log('Fetching table info for:', { tableName, schema });
|
||||
|
||||
const tableInfo = await DatabaseTool.getTableInfo(schema, tableName);
|
||||
|
||||
console.log('Successfully fetched table info:', tableInfo);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tableInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching table info:', error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to fetch table information: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
apps/dev-tool/app/mcp-server/database/page.tsx
Normal file
32
apps/dev-tool/app/mcp-server/database/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { DatabaseToolsInterface } from './_components/database-tools-interface';
|
||||
import { loadDatabaseToolsData } from './_lib/server/database-tools.loader';
|
||||
|
||||
interface DatabasePageProps {
|
||||
searchParams: {
|
||||
search?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Database Tools - MCP Server',
|
||||
description:
|
||||
'Explore database schemas, tables, functions, and enums through the MCP Server interface',
|
||||
};
|
||||
|
||||
async function DatabasePage({ searchParams }: DatabasePageProps) {
|
||||
const searchTerm = searchParams.search || '';
|
||||
|
||||
// Load all database data server-side
|
||||
const databaseData = await loadDatabaseToolsData();
|
||||
|
||||
return (
|
||||
<DatabaseToolsInterface
|
||||
searchTerm={searchTerm}
|
||||
databaseData={databaseData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatabasePage;
|
||||
106
apps/dev-tool/app/mcp-server/page.tsx
Normal file
106
apps/dev-tool/app/mcp-server/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { withI18n } from '@/lib/i18n/with-i18n';
|
||||
import { DatabaseIcon, FileTextIcon, ServerIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Page, PageBody, PageHeader } from '@kit/ui/page';
|
||||
|
||||
export const metadata = {
|
||||
title: 'MCP Server',
|
||||
description:
|
||||
'MCP Server development interface for database exploration and PRD management',
|
||||
};
|
||||
|
||||
function McpServerPage() {
|
||||
return (
|
||||
<Page style={'custom'}>
|
||||
<div className={'flex h-screen flex-col overflow-hidden'}>
|
||||
<PageHeader
|
||||
displaySidebarTrigger={false}
|
||||
title={'MCP Server'}
|
||||
description={
|
||||
'Access MCP Server tools for database exploration and PRD management.'
|
||||
}
|
||||
/>
|
||||
|
||||
<PageBody className={'overflow-hidden'}>
|
||||
<div className={'flex h-full flex-1 flex-col p-6'}>
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="text-center">
|
||||
<ServerIcon className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
|
||||
<h2 className="mb-2 text-2xl font-bold">
|
||||
Welcome to MCP Server Tools
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl">
|
||||
Choose from the tools below to explore your database schema or
|
||||
manage your Product Requirements Documents. Use the sidebar
|
||||
navigation for quick access to specific tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tool Cards */}
|
||||
<div className="mx-auto grid max-w-4xl gap-6 md:grid-cols-2">
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<DatabaseIcon className="h-6 w-6" />
|
||||
Database Tools
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Explore database schemas, tables, functions, and enums
|
||||
through an intuitive interface.
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• Browse database schemas and their structure</li>
|
||||
<li>• Explore tables with columns and relationships</li>
|
||||
<li>
|
||||
• Discover database functions and their parameters
|
||||
</li>
|
||||
<li>• View enum types and their values</li>
|
||||
</ul>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/mcp-server/database">
|
||||
Open Database Tools
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<FileTextIcon className="h-6 w-6" />
|
||||
PRD Manager
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Create and manage Product Requirements Documents with user
|
||||
stories and progress tracking.
|
||||
</p>
|
||||
<ul className="text-muted-foreground space-y-1 text-sm">
|
||||
<li>• Create and edit PRDs with structured templates</li>
|
||||
<li>• Manage user stories with priority tracking</li>
|
||||
<li>• Track progress and project status</li>
|
||||
<li>• Export PRDs to markdown format</li>
|
||||
</ul>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/mcp-server/prd">Open PRD Manager</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(McpServerPage);
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarIcon, FileTextIcon, UsersIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Progress } from '@kit/ui/progress';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
|
||||
|
||||
import { UserStoryDisplay } from '../../../_components/user-story-display';
|
||||
import type { PRDData } from '../../../_lib/server/prd-page.loader';
|
||||
|
||||
interface PRDDetailViewProps {
|
||||
filename: string;
|
||||
prd: PRDData;
|
||||
}
|
||||
|
||||
export function PRDDetailView({ filename, prd }: PRDDetailViewProps) {
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileTextIcon className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">{prd.introduction.title}</h1>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
Updated {prd.metadata.lastUpdated}
|
||||
</span>
|
||||
<span>{filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Overview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
Progress Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Overall Progress</span>
|
||||
<span className="text-sm font-medium">{prd.progress.overall}%</span>
|
||||
</div>
|
||||
<Progress value={prd.progress.overall} className="h-3" />
|
||||
<div className="text-muted-foreground flex justify-between text-sm">
|
||||
<span>
|
||||
{prd.progress.completed} of {prd.progress.total} user stories
|
||||
completed
|
||||
</span>
|
||||
<span>{prd.progress.total - prd.progress.completed} remaining</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="requirements">Requirements</TabsTrigger>
|
||||
<TabsTrigger value="user-stories">
|
||||
User Stories ({prd.userStories.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Description</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{prd.introduction.overview}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Problem Statement</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{prd.problemStatement.problem}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Market Opportunity</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{prd.problemStatement.marketOpportunity}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requirements" className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
Target Users
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{prd.problemStatement.targetUsers?.map(
|
||||
(user, index: number) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span>{user}</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Features</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{prd.solutionOverview.keyFeatures?.map((feature, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Solution Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{prd.solutionOverview.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Success Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{prd.solutionOverview.successMetrics?.map((metric, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span>{metric}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="user-stories">
|
||||
<UserStoryDisplay userStories={prd.userStories} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-1 font-medium">Last Updated</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{prd.metadata.lastUpdated}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 font-medium">Version</h4>
|
||||
<p className="text-muted-foreground">
|
||||
{prd.metadata.version}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 font-medium">Filename</h4>
|
||||
<p className="text-muted-foreground">{filename}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 font-medium">Total User Stories</h4>
|
||||
<p className="text-muted-foreground">{prd.progress.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Progress Breakdown</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Completed Stories:</span>
|
||||
<Badge variant="default">{prd.progress.completed}</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Remaining Stories:</span>
|
||||
<Badge variant="secondary">
|
||||
{prd.progress.total - prd.progress.completed}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Overall Progress:</span>
|
||||
<Badge variant="outline">{prd.progress.overall}%</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
apps/dev-tool/app/mcp-server/prds/[filename]/page.tsx
Normal file
44
apps/dev-tool/app/mcp-server/prds/[filename]/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { loadPRDPageData } from '../../_lib/server/prd-page.loader';
|
||||
import { PRDDetailView } from './_components/prd-detail-view';
|
||||
|
||||
interface PRDPageProps {
|
||||
params: Promise<{
|
||||
filename: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PRDPageProps): Promise<Metadata> {
|
||||
const { filename } = await params;
|
||||
|
||||
try {
|
||||
const prd = await loadPRDPageData(filename);
|
||||
|
||||
return {
|
||||
title: `${prd.introduction.title} - PRD`,
|
||||
description: prd.introduction.overview,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: 'PRD Not Found',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PRDPage({ params }: PRDPageProps) {
|
||||
const { filename } = await params;
|
||||
|
||||
try {
|
||||
const prd = await loadPRDPageData(filename);
|
||||
|
||||
return <PRDDetailView filename={filename} prd={prd} />;
|
||||
} catch (error) {
|
||||
console.error('Failed to load PRD:', error);
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CalendarIcon, FileTextIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Progress } from '@kit/ui/progress';
|
||||
|
||||
interface PRDSummary {
|
||||
filename: string;
|
||||
title: string;
|
||||
lastUpdated: string;
|
||||
progress: number;
|
||||
totalStories: number;
|
||||
completedStories: number;
|
||||
}
|
||||
|
||||
interface PRDsListInterfaceProps {
|
||||
initialPrds: PRDSummary[];
|
||||
}
|
||||
|
||||
export function PRDsListInterface({ initialPrds }: PRDsListInterfaceProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const filteredPrds = initialPrds.filter(
|
||||
(prd) =>
|
||||
prd.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
prd.filename.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileTextIcon className="h-6 w-6" />
|
||||
<h1 className="text-2xl font-bold">
|
||||
Product Requirements Documents
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Browse and view all PRDs in your project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="relative max-w-md">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search PRDs by title or filename..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PRD Grid */}
|
||||
{filteredPrds.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex h-32 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileTextIcon className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{searchTerm ? 'No PRDs match your search' : 'No PRDs found'}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredPrds.map((prd) => (
|
||||
<Link
|
||||
key={prd.filename}
|
||||
href={`/mcp-server/prds/${prd.filename}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-start gap-2 text-sm">
|
||||
<FileTextIcon className="text-muted-foreground mt-0.5 h-4 w-4" />
|
||||
<span className="line-clamp-2">{prd.title}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{prd.completedStories}/{prd.totalStories} stories
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={prd.progress} className="h-2" />
|
||||
<div className="text-right text-xs font-medium">
|
||||
{prd.progress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
<span>Updated {prd.lastUpdated}</span>
|
||||
</div>
|
||||
|
||||
{/* Filename */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{prd.filename}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/dev-tool/app/mcp-server/prds/page.tsx
Normal file
16
apps/dev-tool/app/mcp-server/prds/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { loadPRDs } from '../_lib/server/prd-loader';
|
||||
import { PRDsListInterface } from './_components/prds-list-interface';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PRDs - MCP Server',
|
||||
description: 'Browse and view all Product Requirements Documents',
|
||||
};
|
||||
|
||||
export default async function PRDsPage() {
|
||||
// Load PRDs on the server side
|
||||
const initialPrds = await loadPRDs();
|
||||
|
||||
return <PRDsListInterface initialPrds={initialPrds} />;
|
||||
}
|
||||
Reference in New Issue
Block a user