Initial commit

This commit is contained in:
moeny-matt 2024-11-20 18:27:41 -05:00
commit 60ab832583
34 changed files with 15324 additions and 0 deletions

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# Windsurf Code Editor AI Demo
[Project setup and implementation](https://www.youtube.com/watch?v=824Fyh146_w) credits to [Jason Zhou](https://x.com/jasonzhou1993)
## 1. Project setup
```bash
npx shadcn@latest init -d
```
Yes to create package.json and then give the project a name like 'converter'
```bash
cd converter
npx shadcn@latest add input select card label button
```
Next, create a directory called `instructions` in the `converter` folder, and create the `instructions.md` file in said directory. This file will give `Cascade` instructions and guidelines on how to generate code for this project.
## 2. Project implementation
This section will outline the prompts, given to `Cascade`. Beginning with:
```
I want to build a next.js app for users to upload PDF files of bank statements or invoices, and we will extract data to convert them into excel files;
Implementation strictly based on @instructions.md ;
I've setup the next.js project already in 'converter' folder, now let's build ## 1. File Upload & Schema Definition
```
It will occasionally ask you for approval to run terminal commands, such as `npm install package-name`.
In order to see that the basic functionalities are there, run the project with the following command:
`npm run dev`
Once the project is running, you can navigate to the URL `http://localhost:3000` in your browser to see the app running.
If everything is satisfactory, proceed to give prompts one at a time to `Cascade` to generate code for the rest of the steps. Example:
```
Let's do ## 2. Text Extraction
```
You may need to fix errors and issues along the way by prompting `Cascade` to fix them. The biggest struggles seem to be with the implementation of the `OpenAI` API, as `Cascade` needs to be reminded to follow the provided docs on using the `gpt-4o` model and `zod`.

3
converter/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

39
converter/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env
.DS_Store

36
converter/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import * as XLSX from 'xlsx';
export async function POST(request: Request) {
try {
const { data } = await request.json();
// Create a new workbook
const wb = XLSX.utils.book_new();
// Process each document's data
data.forEach((doc: any, index: number) => {
// Create worksheet for the main data
const mainData = {
Company: doc.company,
Address: doc.address,
'Total Sum': doc.total_sum,
};
const mainWS = XLSX.utils.json_to_sheet([mainData]);
// Create worksheet for items
const itemsWS = XLSX.utils.json_to_sheet(doc.items);
// Add worksheets to workbook
XLSX.utils.book_append_sheet(wb, mainWS, `Doc${index + 1} Summary`);
XLSX.utils.book_append_sheet(wb, itemsWS, `Doc${index + 1} Items`);
});
// Generate Excel file buffer
const excelBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
// Return the Excel file as a response
return new NextResponse(excelBuffer, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="extracted_data.xlsx"',
},
});
} catch (error) {
console.error('Error generating Excel file:', error);
return NextResponse.json(
{ error: 'Failed to generate Excel file' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { LlamaParseReader } from 'llamaindex';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
const UPLOAD_DIR = join(process.cwd(), 'uploads');
// Ensure uploads directory exists
if (!existsSync(UPLOAD_DIR)) {
mkdirSync(UPLOAD_DIR);
}
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file uploaded' },
{ status: 400 }
);
}
// Save file temporarily
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filePath = join(UPLOAD_DIR, file.name);
await writeFile(filePath, buffer);
// Extract text using LlamaParser
const reader = new LlamaParseReader({ resultType: 'markdown' });
const documents = await reader.loadData(filePath);
// Combine all document chunks
const fullText = documents.map(doc => doc.text).join('\n\n');
// Clean up the temporary file
await writeFile(filePath, '');
return NextResponse.json({
fileName: file.name,
text: fullText
});
} catch (error) {
console.error('Error processing file:', error);
return NextResponse.json(
{ error: 'Error processing file' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import OpenAI from 'openai';
import { z } from 'zod';
import { zodResponseFormat } from 'openai/helpers/zod';
const openai = new OpenAI();
// Define the schema for individual items
const ItemSchema = z.object({
item: z.string().describe('name of item, transaction description, or entry'),
unit_price: z.number().describe('unit price, transaction amount, or individual cost'),
quantity: z.number().describe('quantity, count of items, or number of transactions (use 1 for single transactions)'),
sum: z.number().describe('total amount for this item, transaction total, or entry amount')
});
// Define the main extraction schema
const ExtractionSchema = z.object({
company: z.string().describe('name of company, bank, or organization'),
address: z.string().describe('address, location, or branch information'),
total_sum: z.number().describe('total amount, sum of transactions, total paid, or final balance'),
items: z.array(ItemSchema).describe('list of items, transactions, or entries')
});
export async function POST(request: NextRequest) {
try {
const { text, schema } = await request.json();
if (!text) {
return NextResponse.json(
{ error: 'No text provided' },
{ status: 400 }
);
}
const completion = await openai.beta.chat.completions.parse({
model: 'gpt-4o-2024-08-06',
messages: [
{
role: 'system',
content: `You are an expert at extracting structured data from documents.
You will be given text from a PDF document (likely an invoice or bank statement)
and should extract the required information into the given structure.
For bank statements:
- Treat transaction descriptions as items
- Use transaction amounts as unit prices
- Use 1 as quantity for single transactions
- Total amount can be total deposits, total checks, or final balance
For invoices:
- Extract traditional invoice fields
- Calculate missing values if possible (e.g., sum = unit_price * quantity)
For any document type:
- Look for organization names in headers or footers
- Find address or location information
- Identify itemized lists or transactions
- Look for total amounts or summaries
Be precise with numerical values, extract them as numbers, not strings.
If a value is missing but can be calculated, calculate it.
Ensure all required fields are filled with meaningful values.`
},
{
role: 'user',
content: text
}
],
response_format: zodResponseFormat(ExtractionSchema, 'document_extraction')
});
const extractedData = completion.choices[0].message.parsed;
return NextResponse.json(extractedData);
} catch (error) {
console.error('Error processing text:', error);
return NextResponse.json(
{ error: 'Error processing text' },
{ status: 500 }
);
}
}

BIN
converter/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

78
converter/app/globals.css Normal file
View File

@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

37
converter/app/layout.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster />
</body>
</html>
);
}

216
converter/app/page.tsx Normal file
View File

@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import { FileUpload } from '@/components/file-upload';
import { SchemaDefinition, type SchemaField } from '@/components/schema-definition';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
interface ExtractedData {
company: string;
address: string;
total_sum: number;
items: {
item: string;
unit_price: number;
quantity: number;
sum: number;
}[];
}
interface FileWithText {
file: File;
text?: string;
}
export default function Home() {
const [files, setFiles] = useState<FileWithText[]>([]);
const [schema, setSchema] = useState<SchemaField[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [processedData, setProcessedData] = useState<ExtractedData[]>([]);
const { toast } = useToast();
const handleFilesUploaded = (newFiles: File[]) => {
const filesWithText = newFiles.map(file => ({ file }));
setFiles(prev => [...prev, ...filesWithText]);
};
const handleTextExtracted = (fileName: string, text: string) => {
setFiles(prev =>
prev.map(f =>
f.file.name === fileName ? { ...f, text } : f
)
);
};
const handleSchemaChange = (newSchema: SchemaField[]) => {
setSchema(newSchema);
};
const handleStartExtraction = async () => {
setIsProcessing(true);
const results: ExtractedData[] = [];
try {
for (const { file, text } of files) {
if (!text) {
toast({
title: 'Error',
description: `Text not yet extracted for ${file.name}`,
variant: 'destructive',
});
continue;
}
const response = await fetch('/api/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text,
schema,
}),
});
if (!response.ok) {
throw new Error(`Failed to process ${file.name}`);
}
const data = await response.json();
results.push(data);
toast({
title: 'Success',
description: `Processed ${file.name}`,
});
}
setProcessedData(results);
} catch (error) {
console.error('Error during extraction:', error);
toast({
title: 'Error',
description: 'Failed to process files',
variant: 'destructive',
});
} finally {
setIsProcessing(false);
}
};
return (
<div className="min-h-screen p-8">
<div className="max-w-4xl mx-auto space-y-8">
<h1 className="text-3xl font-bold">PDF Data Extractor</h1>
<div className="space-y-8">
<section>
<h2 className="text-xl font-semibold mb-4">Upload PDF Files</h2>
<FileUpload
onFilesUploaded={handleFilesUploaded}
onTextExtracted={handleTextExtracted}
/>
</section>
<section>
<SchemaDefinition onSchemaChange={handleSchemaChange} />
</section>
<section className="flex justify-center space-x-4">
<Button
size="lg"
onClick={handleStartExtraction}
disabled={files.length === 0 || isProcessing}
>
{isProcessing ? 'Processing...' : 'Start Extraction'}
</Button>
{processedData.length > 0 && (
<Button
size="lg"
variant="outline"
onClick={async () => {
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ data: processedData }),
});
if (!response.ok) {
throw new Error('Failed to generate Excel file');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'extracted_data.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast({
title: 'Success',
description: 'Excel file downloaded successfully',
});
} catch (error) {
console.error('Error downloading file:', error);
toast({
title: 'Error',
description: 'Failed to download Excel file',
variant: 'destructive',
});
}
}}
>
Download Excel
</Button>
)}
</section>
{processedData.length > 0 && (
<section>
<h2 className="text-xl font-semibold mb-4">Extracted Data</h2>
<div className="space-y-4">
{processedData.map((data, index) => (
<div key={index} className="border rounded-lg p-4 space-y-2">
<h3 className="font-semibold">{data.company}</h3>
<p className="text-sm text-gray-600">{data.address}</p>
<p className="font-medium">Total: ${data.total_sum.toFixed(2)}</p>
<div className="mt-4">
<h4 className="font-medium mb-2">Items:</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Item</th>
<th className="text-right">Unit Price</th>
<th className="text-right">Quantity</th>
<th className="text-right">Sum</th>
</tr>
</thead>
<tbody>
{data.items.map((item, itemIndex) => (
<tr key={itemIndex} className="border-b">
<td className="py-2">{item.item}</td>
<td className="text-right">${item.unit_price.toFixed(2)}</td>
<td className="text-right">{item.quantity}</td>
<td className="text-right">${item.sum.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</section>
)}
</div>
</div>
</div>
);
}

21
converter/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,197 @@
'use client';
import { useState, useCallback } from 'react';
import { Upload, File as FileIcon, X, Eye } from 'lucide-react';
import { Button } from './ui/button';
import { Card } from './ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from './ui/dialog';
interface UploadedFile {
file: File;
text?: string;
}
interface FileUploadProps {
onFilesUploaded: (files: File[]) => void;
onTextExtracted?: (fileName: string, text: string) => void;
}
export function FileUpload({ onFilesUploaded, onTextExtracted }: FileUploadProps) {
const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<UploadedFile | null>(null);
const [isLoading, setIsLoading] = useState<{ [key: string]: boolean }>({});
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const processFile = async (file: File) => {
setIsLoading(prev => ({ ...prev, [file.name]: true }));
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/extract', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to extract text');
}
const data = await response.json();
if (onTextExtracted) {
onTextExtracted(file.name, data.text);
}
return data.text;
} catch (error) {
console.error('Error extracting text:', error);
return undefined;
} finally {
setIsLoading(prev => ({ ...prev, [file.name]: false }));
}
};
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter(
file => file.type === 'application/pdf'
);
if (droppedFiles.length > 0) {
const newFiles = droppedFiles.map(file => ({ file }));
setFiles(prev => [...prev, ...newFiles]);
onFilesUploaded(droppedFiles);
// Process each file
for (const file of droppedFiles) {
const text = await processFile(file);
setFiles(prev =>
prev.map(f =>
f.file.name === file.name ? { ...f, text } : f
)
);
}
}
}, [onFilesUploaded]);
const handleFileInput = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files).filter(
file => file.type === 'application/pdf'
);
if (selectedFiles.length > 0) {
const newFiles = selectedFiles.map(file => ({ file }));
setFiles(prev => [...prev, ...newFiles]);
onFilesUploaded(selectedFiles);
// Process each file
for (const file of selectedFiles) {
const text = await processFile(file);
setFiles(prev =>
prev.map(f =>
f.file.name === file.name ? { ...f, text } : f
)
);
}
}
}
}, [onFilesUploaded]);
const removeFile = useCallback((index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
}, []);
return (
<div className="w-full space-y-4">
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer ${
isDragging ? 'border-primary bg-primary/10' : 'border-gray-300'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('file-upload')?.click()}
>
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<span className="text-primary hover:text-primary/80">
Click to upload
</span>
<span className="text-gray-500"> or drag and drop</span>
<input
id="file-upload"
type="file"
className="hidden"
multiple
accept=".pdf"
onChange={handleFileInput}
/>
</div>
<p className="text-sm text-gray-500 mt-2">PDF files only</p>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((uploadedFile, index) => (
<Card key={index} className="p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
<FileIcon className="h-6 w-6 text-gray-400" />
<span className="text-sm">{uploadedFile.file.name}</span>
{isLoading[uploadedFile.file.name] && (
<span className="text-sm text-gray-500">Processing...</span>
)}
</div>
<div className="flex items-center space-x-2">
{uploadedFile.text && (
<Button
variant="outline"
size="icon"
onClick={() => setSelectedFile(uploadedFile)}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => removeFile(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
))}
</div>
)}
<Dialog open={!!selectedFile} onOpenChange={() => setSelectedFile(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{selectedFile?.file.name}</DialogTitle>
</DialogHeader>
<div className="mt-4 whitespace-pre-wrap font-mono text-sm">
{selectedFile?.text}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,284 @@
'use client';
import { useState } from 'react';
import { Plus, Minus, ChevronDown, ChevronRight } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Card } from './ui/card';
export interface SchemaField {
id: string;
name: string;
type: 'field' | 'group';
description: string;
fields?: SchemaField[];
}
const defaultSchema: SchemaField[] = [
{
id: '1',
name: 'company',
type: 'field',
description: 'name of company'
},
{
id: '2',
name: 'address',
type: 'field',
description: 'address of company'
},
{
id: '3',
name: 'total_sum',
type: 'field',
description: 'total amount we purchased'
},
{
id: '4',
name: 'items',
type: 'group',
description: 'list of items purchased',
fields: [
{
id: '4.1',
name: 'item',
type: 'field',
description: 'name of item'
},
{
id: '4.2',
name: 'unit_price',
type: 'field',
description: 'unit price of item'
},
{
id: '4.3',
name: 'quantity',
type: 'field',
description: 'quantity we purchased'
},
{
id: '4.4',
name: 'sum',
type: 'field',
description: 'total amount we purchased'
}
]
}
];
interface SchemaDefinitionProps {
onSchemaChange: (schema: SchemaField[]) => void;
}
export function SchemaDefinition({ onSchemaChange }: SchemaDefinitionProps) {
const [schema, setSchema] = useState<SchemaField[]>(defaultSchema);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['4']));
const toggleGroup = (id: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const addField = (parentId?: string) => {
const newField: SchemaField = {
id: Date.now().toString(),
name: '',
type: 'field',
description: ''
};
if (parentId) {
setSchema(prev => {
const updateFields = (fields: SchemaField[]): SchemaField[] => {
return fields.map(field => {
if (field.id === parentId) {
return {
...field,
fields: [...(field.fields || []), newField]
};
}
if (field.fields) {
return {
...field,
fields: updateFields(field.fields)
};
}
return field;
});
};
return updateFields(prev);
});
} else {
setSchema(prev => [...prev, newField]);
}
};
const addGroup = (parentId?: string) => {
const newGroup: SchemaField = {
id: Date.now().toString(),
name: '',
type: 'group',
description: '',
fields: []
};
if (parentId) {
setSchema(prev => {
const updateFields = (fields: SchemaField[]): SchemaField[] => {
return fields.map(field => {
if (field.id === parentId) {
return {
...field,
fields: [...(field.fields || []), newGroup]
};
}
if (field.fields) {
return {
...field,
fields: updateFields(field.fields)
};
}
return field;
});
};
return updateFields(prev);
});
} else {
setSchema(prev => [...prev, newGroup]);
}
setExpandedGroups(prev => new Set([...prev, newGroup.id]));
};
const removeField = (id: string) => {
const removeFieldFromArray = (fields: SchemaField[]): SchemaField[] => {
return fields.filter(field => {
if (field.id === id) return false;
if (field.fields) {
field.fields = removeFieldFromArray(field.fields);
}
return true;
});
};
setSchema(prev => removeFieldFromArray(prev));
};
const updateField = (id: string, updates: Partial<SchemaField>) => {
const updateFieldInArray = (fields: SchemaField[]): SchemaField[] => {
return fields.map(field => {
if (field.id === id) {
return { ...field, ...updates };
}
if (field.fields) {
return {
...field,
fields: updateFieldInArray(field.fields)
};
}
return field;
});
};
const updatedSchema = updateFieldInArray(schema);
setSchema(updatedSchema);
onSchemaChange(updatedSchema);
};
const renderField = (field: SchemaField, depth = 0) => {
const isExpanded = expandedGroups.has(field.id);
return (
<div key={field.id} className="space-y-2" style={{ marginLeft: `${depth * 20}px` }}>
<Card className="p-4">
<div className="flex items-center space-x-2">
{field.type === 'group' && (
<Button
variant="ghost"
size="icon"
onClick={() => toggleGroup(field.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
)}
<Input
placeholder="Field name"
value={field.name}
onChange={(e) => updateField(field.id, { name: e.target.value })}
className="flex-1"
/>
<Input
placeholder="Description"
value={field.description}
onChange={(e) => updateField(field.id, { description: e.target.value })}
className="flex-1"
/>
<Button
variant="destructive"
size="icon"
onClick={() => removeField(field.id)}
>
<Minus className="h-4 w-4" />
</Button>
</div>
</Card>
{field.type === 'group' && isExpanded && (
<div className="space-y-2">
{field.fields?.map(subField => renderField(subField, depth + 1))}
<div className="flex space-x-2" style={{ marginLeft: `${(depth + 1) * 20}px` }}>
<Button
variant="outline"
size="sm"
onClick={() => addField(field.id)}
>
<Plus className="h-4 w-4 mr-2" />
Add Field
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addGroup(field.id)}
>
<Plus className="h-4 w-4 mr-2" />
Add Group
</Button>
</div>
</div>
)}
</div>
);
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">Schema Definition</h2>
<div className="space-x-2">
<Button variant="outline" onClick={() => addField()}>
<Plus className="h-4 w-4 mr-2" />
Add Field
</Button>
<Button variant="outline" onClick={() => addGroup()}>
<Plus className="h-4 w-4 mr-2" />
Add Group
</Button>
</div>
</div>
<div className="space-y-2">
{schema.map(field => renderField(field))}
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -0,0 +1,193 @@
# Project overview
Your goal is to build a next.js app that allows users to upload PDF files and use OpenAI's structured output feature to extract information from the PDF file and convert it to an excel file.
You will be using NextJS 14, shadcn, tailwind, and Lucid icon
# Core functionality
## 1. File Upload & Schema Definition
- Users should be able to upload one or more PDF files
- Users should be able to define data points they want to extract:
- Individual fields (single value extractions)
- Groups (array of objects with consistent structure)
- For each group, users can define multiple fields inside, or even other groups
- There should be a button 'Start extraction'
- Set default schema to showcase how this works
- company: name of company
- address: address of company
- total sum: total amount we purchased
- items (group):
- item: name of item
- unit price: unit price of item
- quantity: quantity we purchased
- sum: total amount we purchased
- Server-side file processing
## 2. Text Extraction
- Use LlamaParser for PDF text extraction (server-side)
- For each file, combine all document chunks for complete text. Make sure to return full text of all documents, not just the first one documents[0]
- The llamaparser text extraction should happen immediately after user uploads files to UI, and not wait for a button click
- Strictly following ## 1. LlamaParser Documentation as code implementation example
- After each file is uploaded, it should be displayed as an item on the page, displaying the file name with a button to click to preview the full text extracted
- User can keep adding new files to the list, previously uploaded files should be displayed
- Server-side processing only
## 3. Data Processing
- After clicking on 'Start Extraction', the data should be sent to OpenAI for processing across all files
- Use OpenAI structured output for information extraction
- Strictly following ## 2. OpenAI Documentation as code implementation example
## 4. File Download
- Combine data processed from multiple PDFs into one excel file
- When there are nested structures like {'company': xxx, 'items': [{'item': xxx, 'unit price': xxx, 'quantity': xxx, 'sum': xxx}]}, it should be flattened when generating the excel file
- Implement proper error handling and type safety
- Enable excel file download
- Implement temporary file cleanup
# Doc
## 1. LlamaParser Documentation
First, get an api key. We recommend putting your key in a file called .env that looks like this:
LLAMA_CLOUD_API_KEY=llx-xxxxxx
Set up a new TypeScript project in a new folder, we use this:
npm init
npm install -D typescript @types/node
LlamaParse support is built-in to LlamaIndex for TypeScript, so you'll need to install LlamaIndex.TS:
npm install llamaindex dotenv
Let's create a parse.ts file and put our dependencies in it:
import {
LlamaParseReader,
// we'll add more here later
} from "llamaindex";
import 'dotenv/config'
Now let's create our main function, which will load in fun facts about Canada and parse them:
async function main() {
// save the file linked above as sf_budget.pdf, or change this to match
const path = "./canada.pdf";
// set up the llamaparse reader
const reader = new LlamaParseReader({ resultType: "markdown" });
// parse the document
const documents = await reader.loadData(path);
// print the parsed document
console.log(documents)
}
main().catch(console.error);
Now run the file:
npx tsx parse.ts
Congratulations! You've parsed the file, and should see output that looks like this:
[
Document {
id_: '02f5e252-9dca-47fa-80b2-abdd902b911a',
embedding: undefined,
metadata: { file_path: './canada.pdf' },
excludedEmbedMetadataKeys: [],
excludedLlmMetadataKeys: [],
relationships: {},
text: '# Fun Facts About Canada\n' +
'\n' +
'We may be known as the Great White North, but
...etc...
## 2. OpenAI Documentation
Make sure to use the gpt-4o model and zod for defining data structures.
```
import OpenAI from "openai";
import { z } from "zod";
import { zodResponseFormat } from "openai/helpers/zod";
const openai = new OpenAI();
const ResearchPaperExtraction = z.object({
title: z.string(),
authors: z.array(z.string()),
abstract: z.string(),
keywords: z.array(z.string()),
});
const completion = await openai.beta.chat.completions.parse({
model: "gpt-4o-2024-08-06",
messages: [
{ role: "system", content: "You are an expert at structured data extraction. You will be given unstructured text from a research paper and should convert it into the given structure." },
{ role: "user", content: "..." },
],
response_format: zodResponseFormat(ResearchPaperExtraction, "research_paper_extraction"),
});
const research_paper = completion.choices[0].message.parsed;
```
# Important Implementation Notes
## 0. Adding logs
- Always add server side logs to your code so we can debug any potential issues
## 1. Project setup
- All new components should go in /components at the root (not in the app folder) and be named like example-component.tsx unless otherwise specified
- All new pages go in /app
- Use the Next.js 14 app router
- All data fetching should be done in a server component and pass the data down as props
- Client components (useState, hooks, etc) require that 'use client' is set at the top of the file
## 2. Server-Side API Calls:
- All interactions with external APIs (e.g., Reddit, OpenAI) should be performed server-side.
- Create dedicated API routes in the `pages/api` directory for each external API interaction.
- Client-side components should fetch data through these API routes, not directly from external APIs.
## 3. Environment Variables
- Store all sensitive information (API keys, credentials) in environment variables.
- Use a `.env.local` file for local development and ensure it's listed in `.gitignore`.
- For production, set environment variables in the deployment platform (e.g., Vercel).
- Access environment variables only in server-side code or API routes.
## 4. Error Handling and Logging
- Implement comprehensive error handling in both client-side components and server-side API routes.
- Log errors on the server-side for debugging purposes.
- Display user-friendly error messages on the client-side.
## 5. Type Safety
- Use TypeScript interfaces for all data structures, especially API responses.
- Avoid using `any` type; instead, define proper types for all variables and function parameters.
## 6. API Client Initialization
- Initialize API clients (e.g., Snoowrap for Reddit, OpenAI) in server-side code only.
- Implement checks to ensure API clients are properly initialized before use.
## 7. Data Fetching in Components
- Use React hooks (e.g., `useEffect`) for data fetching in client-side components.
- Implement loading states and error handling for all data fetching operations.
## 8. Next.js Configuration
- Utilize `next.config.mjs` for environment-specific configurations.
- Use the `env` property in `next.config.mjs` to make environment variables available to the application.
## 9. CORS and API Routes
- Use Next.js API routes to avoid CORS issues when interacting with external APIs.
- Implement proper request validation in API routes.
## 10. Component Structure
- Separate concerns between client and server components.
- Use server components for initial data fetching and pass data as props to client components.
## 11. Security
- Never expose API keys or sensitive credentials on the client-side.
- Implement proper authentication and authorization for API routes if needed.
## 12. Special Syntax
- When using shadcn, use npx shadcn@latest add xxx, instead of shadcn-ui@latest, this is deprecated

6
converter/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

13027
converter/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
converter/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "converter",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dotenv": "^16.4.5",
"llamaindex": "^0.8.21",
"lucide-react": "^0.460.0",
"next": "14.2.16",
"openai": "^4.73.0",
"react": "^18",
"react-dom": "^18",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.16",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@ -0,0 +1,63 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;

26
converter/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File