diff --git a/.env.example b/.env.example index 9cec549..79159de 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,11 @@ NEXT_PUBLIC_APP_URL=localhost:3000 OPENAI_API_KEY=OPENAI_API_KEY -GITHUB_TOKEN=GITHUB_TOKEN +# GitHub Personal Access Token with 'repo' scope (for creating repositories) +GITHUB_TOKEN=your_github_personal_access_token_here GITHUB_OWNER=GITHUB_OWNER GITHUB_TEMPLATE_REPO=TEMPLATE_REPO VERCEL_TOKEN=VERCEL_TOKEN -DATABASE_URL= \ No newline at end of file +DATABASE_URL= +V0_API_KEY=your_v0_api_key_here \ No newline at end of file diff --git a/app/actions/v0/v0Actions.test.ts b/app/actions/v0/v0Actions.test.ts new file mode 100644 index 0000000..06f08e2 --- /dev/null +++ b/app/actions/v0/v0Actions.test.ts @@ -0,0 +1,409 @@ +import { + generateV0Text, + createProjectFromV0Output, + createGithubRepoAndPush, +} from "./v0Actions"; +import { generateText } from "ai"; +import { vercel } from "@ai-sdk/vercel"; +import { scrapeAndAnalyzeWebsite } from "app/actions/scraper/scraperActions"; +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; +import { Octokit } from "octokit"; + +// Mock external modules +jest.mock("@ai-sdk/vercel", () => ({ + vercel: jest.fn(() => "mocked-vercel-model"), +})); + +jest.mock("ai", () => ({ + generateText: jest.fn(), +})); + +jest.mock("app/actions/scraper/scraperActions", () => ({ + scrapeAndAnalyzeWebsite: jest.fn(), +})); + +jest.mock("fs"); +jest.mock("path"); +jest.mock("child_process"); +jest.mock("octokit"); + +// Copied parseV0Output function from v0Actions.ts for testing, as it's not exported. +function parseV0Output( + v0ApiOutput: string +): { filePath: string; content: string }[] { + const files: { filePath: string; content: string }[] = []; + const regex = /```(\w+)?\s*file="([^"]+)"\s*\n([\s\S]*?)\n```/g; + let match; + while ((match = regex.exec(v0ApiOutput)) !== null) { + if (match[2] && match[3]) { + files.push({ + filePath: match[2].trim(), + content: match[3].trim(), + }); + } + } + return files; +} + +describe("generateV0Text", () => { + const originalApiKey = process.env.V0_API_KEY; + const mockProjectDescription = "A cool new project"; + const mockUrl = "https://example.com"; + const mockWorkspaceId = "ws-123"; + const mockSiteConfig = { + title: "Mock Site", + description: "This is a mocked site configuration.", + theme: { primaryColor: "blue" }, + }; + const mockGeneratedText = "Generated project code text"; + + beforeEach(() => { + (generateText as jest.Mock).mockReset(); + (scrapeAndAnalyzeWebsite as jest.Mock).mockReset(); + (vercel as jest.Mock).mockClear(); // Clear any previous calls to vercel + process.env.V0_API_KEY = "test-api-key"; + }); + + afterAll(() => { + process.env.V0_API_KEY = originalApiKey; + }); + + it("should successfully generate text with scraped site config", async () => { + (scrapeAndAnalyzeWebsite as jest.Mock).mockResolvedValue(mockSiteConfig); + (generateText as jest.Mock).mockResolvedValue({ text: mockGeneratedText }); + + const result = await generateV0Text({ + projectDescription: mockProjectDescription, + url: mockUrl, + workspaceId: mockWorkspaceId, + }); + + expect(scrapeAndAnalyzeWebsite).toHaveBeenCalledWith( + mockUrl, + mockWorkspaceId + ); + + const siteConfigJsonString = JSON.stringify(mockSiteConfig, null, 2); + const baseTemplate = `Develop a Next.js website that dynamically renders content based on a JSON configuration file. The website should feature a \`site.config\` file to manage site-wide parameters. The JSON response will provide the data to populate various components. The website should be designed to be responsive and accessible, ensuring a seamless user experience across different devices and browsers. The \`site.config\` file should include parameters such as site title, description, and any other global settings. The website should parse the JSON data and use it to render the necessary components, such as navigation, content sections, and footers. The design should be clean and modern, with a focus on usability and readability. The website should handle potential errors gracefully, providing informative messages to the user if any issues arise during data fetching or rendering. The JSON response will contain the data for the website's content, including text, images, and links. The website should be able to update the content dynamically without requiring code changes. The website should follow best practices for SEO, including proper use of meta tags and semantic HTML. The website should be deployed on Vercel.`; + const siteConfigIntegration = `Here is the JSON data (SiteConfig) to be used for the website's content and configuration:\n${siteConfigJsonString}`; + const userCustomization = `Additional project requirements or focus areas:\n${mockProjectDescription}`; + const outputFormatInstruction = `Ensure the output is a complete Next.js project structure (files and folders), with each file clearly delineated with its path in a markdown-like format (e.g., \`\`\`tsx file="path/to/file.tsx" ... \`\`\`). Include TypeScript, Tailwind CSS, and shadcn/ui (or other sensible defaults if not specified in the description or site config).`; + const expectedPrompt = `${baseTemplate}\n\n${siteConfigIntegration}\n\n${userCustomization}\n\n${outputFormatInstruction}`; + + expect(generateText).toHaveBeenCalledWith({ + model: "mocked-vercel-model", + prompt: expectedPrompt, + headers: { + Authorization: "Bearer test-api-key", + }, + }); + expect(vercel).toHaveBeenCalledWith("v0-1.0-md"); + expect(result).toEqual({ + success: true, + data: mockGeneratedText, + error: null, + }); + }); + + it("should return error if scrapeAndAnalyzeWebsite fails", async () => { + const scraperError = new Error("Scraping failed miserably"); + (scrapeAndAnalyzeWebsite as jest.Mock).mockRejectedValue(scraperError); + + const result = await generateV0Text({ + projectDescription: mockProjectDescription, + url: mockUrl, + workspaceId: mockWorkspaceId, + }); + + expect(generateText).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + data: null, + error: `Failed to fetch site configuration: ${scraperError.message}`, + }); + }); + + it("should return error if generateText (v0 API) fails", async () => { + (scrapeAndAnalyzeWebsite as jest.Mock).mockResolvedValue(mockSiteConfig); + const apiError = new Error("V0 API blew up"); + (generateText as jest.Mock).mockRejectedValue(apiError); + + const result = await generateV0Text({ + projectDescription: mockProjectDescription, + url: mockUrl, + workspaceId: mockWorkspaceId, + }); + + expect(result).toEqual({ + success: false, + data: null, + error: `Failed to generate text from v0 API: ${apiError.message}`, + }); + }); + + it("should return error if V0_API_KEY is not configured", async () => { + process.env.V0_API_KEY = undefined; + + const result = await generateV0Text({ + projectDescription: mockProjectDescription, + url: mockUrl, + workspaceId: mockWorkspaceId, + }); + + expect(scrapeAndAnalyzeWebsite).not.toHaveBeenCalled(); + expect(generateText).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + data: null, + error: "V0_API_KEY is not configured.", + }); + }); +}); + +describe("parseV0Output", () => { + it("should correctly parse valid input with multiple files", () => { + const validInput = ` +Some introductory text. + +\`\`\`tsx file="app/components/Button.tsx" +export const Button = () => ; +\`\`\` + +Some text between blocks. + +\`\`\`json file="package.json" +{ + "name": "test-project" +} +\`\`\` + +\`\`\` file="README.md" +# Test Project +\`\`\` +`; + const expected = [ + { + filePath: "app/components/Button.tsx", + content: "export const Button = () => ;", + }, + { filePath: "package.json", content: '{\n "name": "test-project"\n}' }, + { filePath: "README.md", content: "# Test Project" }, + ]; + expect(parseV0Output(validInput)).toEqual(expected); + }); + + it("should return an empty array for input with no valid file blocks", () => { + const invalidInput = "No code blocks here, just text."; + expect(parseV0Output(invalidInput)).toEqual([]); + }); + + it("should handle malformed input gracefully", () => { + const malformedInput = ` +\`\`\`tsx file="app/Good.tsx" +const Good = () =>

Good

; +\`\`\` +\`\`\` file="app/Bad.tsx" +// Missing closing backticks +`; + const expected = [ + { filePath: "app/Good.tsx", content: "const Good = () =>

Good

;" }, + ]; + const result = parseV0Output(malformedInput); + expect(result).toEqual(expected); + + const malformedInput2 = "```tsx file=no_quotes.tsx\ncontent\n```"; + expect(parseV0Output(malformedInput2)).toEqual([]); + }); + + it("should handle different language specifiers and no specifier", () => { + const input = ` +\`\`\`tsx file="component.tsx" +// tsx content +\`\`\` +\`\`\`json file="data.json" +// json content +\`\`\` +\`\`\`js file="script.js" +// js content +\`\`\` +\`\`\` file="text.txt" +// no specifier content +\`\`\` +`; + const expected = [ + { filePath: "component.tsx", content: "// tsx content" }, + { filePath: "data.json", content: "// json content" }, + { filePath: "script.js", content: "// js content" }, + { filePath: "text.txt", content: "// no specifier content" }, + ]; + expect(parseV0Output(input)).toEqual(expected); + }); +}); + +describe("createProjectFromV0Output", () => { + const mockCwd = "/test/cwd"; + const mockProjectName = "my-test-project"; + const mockBasePath = `${mockCwd}/generated_projects`; + const mockProjectPath = `${mockBasePath}/${mockProjectName}`; + + beforeEach(() => { + (fs.mkdirSync as jest.Mock).mockReset(); + (fs.writeFileSync as jest.Mock).mockReset(); + (fs.existsSync as jest.Mock).mockReset(); + (path.join as jest.Mock).mockImplementation((...args) => args.join("/")); + (process.cwd as jest.Mock) = jest.fn(() => mockCwd); + }); + + it("should create files and directories for valid input", async () => { + const v0ApiOutput = ` +\`\`\`ts file="src/index.ts" +console.log('hello'); +\`\`\` +\`\`\`md file="README.md" +# Test +\`\`\` +`; + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const result = await createProjectFromV0Output(v0ApiOutput, mockProjectName); + + expect(fs.existsSync).toHaveBeenCalledWith(mockProjectPath); + expect(fs.mkdirSync).toHaveBeenCalledWith(mockProjectPath, { recursive: true }); + expect(fs.existsSync).toHaveBeenCalledWith(path.join(mockProjectPath, "src")); + expect(fs.mkdirSync).toHaveBeenCalledWith(path.join(mockProjectPath, "src"), { recursive: true }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(mockProjectPath, "src/index.ts"), + "console.log('hello');" + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(mockProjectPath, "README.md"), + "# Test" + ); + expect(result).toEqual({ + success: true, + message: `Project ${mockProjectName} created successfully at ${mockProjectPath}`, + projectPath: mockProjectPath, + }); + }); + + it("should return error if parseV0Output returns empty array", async () => { + const v0ApiOutput = "no valid blocks"; + const result = await createProjectFromV0Output(v0ApiOutput, mockProjectName); + expect(fs.mkdirSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + message: "No files found in the API output or failed to parse.", + projectPath: null, + }); + }); + + it("should return error if file operation fails", async () => { + const v0ApiOutput = `\`\`\`ts file="src/index.ts"\nconsole.log('fail');\`\`\``; + (fs.existsSync as jest.Mock).mockReturnValue(false); + (fs.writeFileSync as jest.Mock).mockImplementation(() => { + throw new Error("Disk full"); + }); + + const result = await createProjectFromV0Output(v0ApiOutput, mockProjectName); + expect(result).toEqual({ + success: false, + message: "Error creating project: Disk full", + projectPath: null, + }); + }); +}); + +describe("createGithubRepoAndPush", () => { + const mockProjectName = "my-git-project"; + const mockProjectPath = `/generated_projects/${mockProjectName}`; + const mockGithubToken = "fake-token"; + const mockRepoUrl = `https://github.com/user/${mockProjectName}.git`; + + let mockExecSync: jest.Mock; + let mockCreateForAuthenticatedUser: jest.Mock; + + beforeEach(() => { + mockExecSync = execSync as jest.Mock; + mockExecSync.mockReset().mockReturnValue(""); // Default success + + mockCreateForAuthenticatedUser = jest.fn().mockResolvedValue({ + data: { clone_url: mockRepoUrl }, + }); + (Octokit as jest.Mock).mockImplementation(() => ({ + rest: { + repos: { + createForAuthenticatedUser: mockCreateForAuthenticatedUser, + }, + }, + })); + }); + + it("should successfully create repo and push", async () => { + const result = await createGithubRepoAndPush( + mockProjectName, + mockProjectPath, + mockGithubToken + ); + + expect(mockExecSync).toHaveBeenCalledTimes(6); // init, checkout, add, commit, remote add, push + expect(mockExecSync).toHaveBeenNthCalledWith(1, "git init", { cwd: mockProjectPath, stdio: "pipe" }); + expect(mockExecSync).toHaveBeenNthCalledWith(2, "git checkout -b main", { cwd: mockProjectPath, stdio: "pipe" }); + expect(mockExecSync).toHaveBeenNthCalledWith(3, "git add .", { cwd: mockProjectPath, stdio: "pipe" }); + expect(mockExecSync).toHaveBeenNthCalledWith(4, `git commit -m "Initial commit: scaffold project ${mockProjectName}"`, { cwd: mockProjectPath, stdio: "pipe" }); + expect(mockExecSync).toHaveBeenNthCalledWith(5, `git remote add origin ${mockRepoUrl}`, { cwd: mockProjectPath, stdio: "pipe" }); + expect(mockExecSync).toHaveBeenNthCalledWith(6, "git push -u origin main", { cwd: mockProjectPath, stdio: "pipe" }); + + expect(mockCreateForAuthenticatedUser).toHaveBeenCalledWith({ + name: mockProjectName, + private: true, + }); + expect(result).toEqual({ + success: true, + message: "Successfully created GitHub repository and pushed initial commit.", + repoUrl: mockRepoUrl, + }); + }); + + it("should handle git init failure", async () => { + mockExecSync.mockImplementation((command: string) => { + if (command === "git init") throw new Error("git init failed"); + return ""; + }); + const result = await createGithubRepoAndPush(mockProjectName, mockProjectPath, mockGithubToken); + expect(result).toEqual({ + success: false, + message: "Failed to initialize git repository or commit files: git init failed", + repoUrl: null, + }); + }); + + it("should handle GitHub API failure", async () => { + mockCreateForAuthenticatedUser.mockRejectedValue(new Error("GitHub API error")); + const result = await createGithubRepoAndPush(mockProjectName, mockProjectPath, mockGithubToken); + expect(result).toEqual({ + success: false, + message: "Failed to create GitHub repository: GitHub API error", + repoUrl: null, + }); + }); + + it("should handle git push failure", async () => { + mockExecSync.mockImplementation((command: string) => { + if (command === "git push -u origin main") throw new Error("git push failed"); + return ""; + }); + const result = await createGithubRepoAndPush(mockProjectName, mockProjectPath, mockGithubToken); + expect(result).toEqual({ + success: false, + message: "Failed to push to GitHub repository: git push failed", + repoUrl: mockRepoUrl, + }); + expect(mockExecSync).toHaveBeenCalledWith("git init", expect.anything()); + expect(mockExecSync).toHaveBeenCalledWith("git add .", expect.anything()); + expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("git commit"), expect.anything()); + expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("git remote add origin"), expect.anything()); + }); +}); diff --git a/app/actions/v0/v0Actions.ts b/app/actions/v0/v0Actions.ts new file mode 100644 index 0000000..05f3f8f --- /dev/null +++ b/app/actions/v0/v0Actions.ts @@ -0,0 +1,209 @@ +"use server"; + +import { generateText } from "ai"; +import { vercel } from "@ai-sdk/vercel"; +import { scrapeAndAnalyzeWebsite } from "app/actions/scraper/scraperActions"; + +// Interface for function parameters (for clarity, TypeScript specific) +// interface GenerateV0TextParams { +// projectDescription: string; +// url: string; // Will be used in a subsequent step +// workspaceId: string; // Will be used in a subsequent step +// } + +export async function generateV0Text({ + projectDescription, + url, + workspaceId, +}: { + projectDescription: string; + url: string; + workspaceId: string; +}) { + const apiKey = process.env.V0_API_KEY; + + if (!apiKey) { + return { + success: false, + data: null, + error: "V0_API_KEY is not configured.", + }; + } + + let siteConfigData; + try { + siteConfigData = await scrapeAndAnalyzeWebsite(url, workspaceId); + } catch (error: any) { + return { + success: false, + data: null, + error: "Failed to fetch site configuration: " + error.message, + }; + } + + const siteConfigJsonString = JSON.stringify(siteConfigData, null, 2); + // siteConfigJsonString will be used in the prompt in the next step. + + try { + const baseTemplate = `Develop a Next.js website that dynamically renders content based on a JSON configuration file. The website should feature a \`site.config\` file to manage site-wide parameters. The JSON response will provide the data to populate various components. The website should be designed to be responsive and accessible, ensuring a seamless user experience across different devices and browsers. The \`site.config\` file should include parameters such as site title, description, and any other global settings. The website should parse the JSON data and use it to render the necessary components, such as navigation, content sections, and footers. The design should be clean and modern, with a focus on usability and readability. The website should handle potential errors gracefully, providing informative messages to the user if any issues arise during data fetching or rendering. The JSON response will contain the data for the website's content, including text, images, and links. The website should be able to update the content dynamically without requiring code changes. The website should follow best practices for SEO, including proper use of meta tags and semantic HTML. The website should be deployed on Vercel.`; + + const siteConfigIntegration = `Here is the JSON data (SiteConfig) to be used for the website's content and configuration: +${siteConfigJsonString}`; + + const userCustomization = `Additional project requirements or focus areas: +${projectDescription}`; + + const outputFormatInstruction = `Ensure the output is a complete Next.js project structure (files and folders), with each file clearly delineated with its path in a markdown-like format (e.g., \`\`\`tsx file="path/to/file.tsx" ... \`\`\`). Include TypeScript, Tailwind CSS, and shadcn/ui (or other sensible defaults if not specified in the description or site config).`; + + const finalPrompt = `${baseTemplate}\n\n${siteConfigIntegration}\n\n${userCustomization}\n\n${outputFormatInstruction}`; + + const { text } = await generateText({ + model: vercel("v0-1.0-md"), + prompt: finalPrompt, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + return { success: true, data: text, error: null }; + } catch (error: any) { + return { + success: false, + data: null, + error: "Failed to generate text from v0 API: " + error.message, + }; + } +} + +import { Octokit } from "octokit"; +import { execSync } from "child_process"; + +export async function createGithubRepoAndPush( + projectName: string, + projectPath: string, + githubToken: string +) { + // 1. Git Initialization and Commit + try { + execSync("git init", { cwd: projectPath, stdio: "pipe" }); + execSync("git checkout -b main", { cwd: projectPath, stdio: "pipe" }); // Ensure 'main' branch + execSync("git add .", { cwd: projectPath, stdio: "pipe" }); + execSync(`git commit -m "Initial commit: scaffold project ${projectName}"`, { + cwd: projectPath, + stdio: "pipe", + }); + } catch (error: any) { + return { + success: false, + message: + "Failed to initialize git repository or commit files: " + error.message, + repoUrl: null, + }; + } + + // 2. GitHub Repository Creation + const octokit = new Octokit({ auth: githubToken }); + let repoUrl: string; + + try { + const repoResponse = await octokit.rest.repos.createForAuthenticatedUser({ + name: projectName, + private: true, // Or allow user to choose + }); + repoUrl = repoResponse.data.clone_url; + } catch (error: any) { + return { + success: false, + message: "Failed to create GitHub repository: " + error.message, + repoUrl: null, + }; + } + + // 3. Pushing to GitHub + try { + execSync(`git remote add origin ${repoUrl}`, { cwd: projectPath, stdio: "pipe" }); + execSync("git push -u origin main", { cwd: projectPath, stdio: "pipe" }); + } catch (error: any) { + return { + success: false, + message: "Failed to push to GitHub repository: " + error.message, + repoUrl: repoUrl, // Include repoUrl as it was successfully created + }; + } + + // 4. Success Response + return { + success: true, + message: "Successfully created GitHub repository and pushed initial commit.", + repoUrl: repoUrl, + }; +} + +import fs from "fs"; +import path from "path"; + +// Interface for the parsed file structure (for clarity, TypeScript specific) +// interface ParsedFile { +// filePath: string; +// content: string; +// } + +function parseV0Output(v0ApiOutput: string): { filePath: string; content: string }[] { + const files: { filePath: string; content: string }[] = []; + // Regex to capture: optional language, file path, and content + const regex = /```(\w+)?\s*file="([^"]+)"\s*\n([\s\S]*?)\n```/g; + let match; + + while ((match = regex.exec(v0ApiOutput)) !== null) { + // match[1] is the optional language (e.g., tsx, json) - currently not used but captured + // match[2] is the filePath + // match[3] is the content + if (match[2] && match[3]) { + files.push({ + filePath: match[2].trim(), + content: match[3].trim(), + }); + } + } + return files; +} + +export async function createProjectFromV0Output( + v0ApiOutput: string, + projectName: string +) { + const basePath = path.join(process.cwd(), "generated_projects"); + const projectPath = path.join(basePath, projectName); + + try { + const filesToCreate = parseV0Output(v0ApiOutput); + + if (filesToCreate.length === 0) { + return { success: false, message: "No files found in the API output or failed to parse.", projectPath: null }; + } + + if (!fs.existsSync(projectPath)) { + fs.mkdirSync(projectPath, { recursive: true }); + } + + for (const file of filesToCreate) { + const fullPath = path.join(projectPath, file.filePath); + const dirName = path.dirname(fullPath); + if (!fs.existsSync(dirName)) { + fs.mkdirSync(dirName, { recursive: true }); + } + fs.writeFileSync(fullPath, file.content); + } + + return { + success: true, + message: `Project ${projectName} created successfully at ${projectPath}`, + projectPath: projectPath, + }; + } catch (error: any) { + return { + success: false, + message: "Error creating project: " + error.message, + projectPath: null, + }; + } +} diff --git a/app/dashboard/v0-tester/page.tsx b/app/dashboard/v0-tester/page.tsx new file mode 100644 index 0000000..c232f52 --- /dev/null +++ b/app/dashboard/v0-tester/page.tsx @@ -0,0 +1,18 @@ +// app/dashboard/v0-tester/page.tsx +import { getCurrentUserWorkspaceDetails } from "@/app/actions/database/workspaceActions"; +import V0TesterClientContent from "@/components/v0-tester/V0TesterClientContent"; // We will create this next + +export default async function V0TesterPage() { + const workspaceDetails = await getCurrentUserWorkspaceDetails(); + + // We might not need promptUsageCount for the v0 tester, + // but workspaceId is useful for the scraper part. + // You can decide if any initial checks like promptUsageCount are relevant here. + // For now, let's assume no usage limit for this new feature. + + return ( + + ); +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 9535eeb..1292f54 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -50,6 +50,11 @@ const data = { url: "/dashboard/projects", icon: IconFolder, }, + { + title: "V0 Tester", + url: "/dashboard/v0-tester", + icon: IconFileAi, + }, // { // title: "Lifecycle", // url: "#", diff --git a/components/v0-tester/V0TesterClientContent.tsx b/components/v0-tester/V0TesterClientContent.tsx new file mode 100644 index 0000000..27a9285 --- /dev/null +++ b/components/v0-tester/V0TesterClientContent.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useState, useEffect } from "react"; +// Keep necessary imports like Button, Input, Textarea, Card components, Loader2, toast +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; // Will need this for project description +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +// Server actions we will call: +import { generateV0Text, createProjectFromV0Output, createGithubRepoAndPush } from "@/app/actions/v0/v0Actions"; + +interface V0TesterClientContentProps { + workspaceId: string; +} + +export default function V0TesterClientContent({ workspaceId }: V0TesterClientContentProps) { + const [projectDescription, setProjectDescription] = useState(""); + const [urlToScrape, setUrlToScrape] = useState(""); + const [projectName, setProjectName] = useState(""); + const [githubToken, setGithubToken] = useState(""); // Added for GitHub token + const [isLoading, setIsLoading] = useState(false); + const [isLoadingGitHub, setIsLoadingGitHub] = useState(false); // Separate loading for GitHub action + const [generatedFilesPath, setGeneratedFilesPath] = useState(null); + const [githubRepoUrl, setGithubRepoUrl] = useState(null); + const [v0ApiOutput, setV0ApiOutput] = useState(null); // To display raw v0 output + + // Placeholder for handleSubmit, handleGenerateAndPush functions + const handleGenerateProject = async () => { + setIsLoading(true); + setV0ApiOutput(null); + setGeneratedFilesPath(null); + setGithubRepoUrl(null); + + if (!projectDescription || !urlToScrape || !projectName) { + toast.error("Please fill in Project Description, URL to Scrape, and Project Name."); + setIsLoading(false); + return; + } + + try { + const v0Result = await generateV0Text({ projectDescription, url: urlToScrape, workspaceId }); + setV0ApiOutput(v0Result.data || JSON.stringify(v0Result.error, null, 2)); + + if (!v0Result.success || !v0Result.data) { + toast.error(v0Result.error || "Failed to generate content from v0 API."); + setIsLoading(false); + return; + } + toast.success("Successfully generated content from v0 API."); + + const creationResult = await createProjectFromV0Output(v0Result.data, projectName); + if (creationResult.success) { + setGeneratedFilesPath(creationResult.projectPath); + toast.success(creationResult.message); + } else { + toast.error(creationResult.message); + } + } catch (error: any) { + toast.error("An unexpected error occurred: " + error.message); + } finally { + setIsLoading(false); + } + }; + + const handleGenerateAndPush = async () => { + setIsLoadingGitHub(true); + setV0ApiOutput(null); + setGeneratedFilesPath(null); + setGithubRepoUrl(null); + + if (!projectDescription || !urlToScrape || !projectName || !githubToken) { + toast.error("Please fill in all fields, including Project Description, URL, Project Name, and GitHub Token."); + setIsLoadingGitHub(false); + return; + } + + try { + // Step 1: Generate V0 Text + const v0Result = await generateV0Text({ projectDescription, url: urlToScrape, workspaceId }); + setV0ApiOutput(v0Result.data || JSON.stringify(v0Result.error, null, 2)); + + if (!v0Result.success || !v0Result.data) { + toast.error(v0Result.error || "Failed to generate content from v0 API."); + setIsLoadingGitHub(false); + return; + } + toast.success("Successfully generated content from v0 API."); + + // Step 2: Create Project from V0 Output + const creationResult = await createProjectFromV0Output(v0Result.data, projectName); + if (!creationResult.success || !creationResult.projectPath) { + toast.error(creationResult.message || "Failed to create project files."); + setGeneratedFilesPath(creationResult.projectPath); // Still show path if partially created or error message refers to it + setIsLoadingGitHub(false); + return; + } + setGeneratedFilesPath(creationResult.projectPath); + toast.success(creationResult.message || "Project files created successfully."); + + // Step 3: Create GitHub Repo and Push + const githubResult = await createGithubRepoAndPush(projectName, creationResult.projectPath, githubToken); + if (githubResult.success) { + setGithubRepoUrl(githubResult.repoUrl); + toast.success(githubResult.message); + } else { + toast.error(githubResult.message); + } + } catch (error: any) { + toast.error("An unexpected error occurred during the GitHub process: " + error.message); + } finally { + setIsLoadingGitHub(false); + } + }; + + return ( +
+
+
+

V0 Project Generator Tester

+

+ Test the v0 API project generation flow. +

+
+ + {/* Input Card - Will be expanded in the next step */} + + + Project Details + + Provide details for the v0 API to generate the project. + + + +
+ +