diff --git a/app/(public)/layout.tsx b/app/(public)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4c640fb83ccfced8842764d029c87a2d85718d65 --- /dev/null +++ b/app/(public)/layout.tsx @@ -0,0 +1,15 @@ +import Navigation from "@/components/public/navigation"; + +export default async function PublicLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+
+ + {children} +
+ ); +} diff --git a/app/(public)/page.tsx b/app/(public)/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d505589926b285353023b2e3dd1682f7e0788ee --- /dev/null +++ b/app/(public)/page.tsx @@ -0,0 +1,193 @@ +// import { AskAi } from "@/components/space/ask-ai"; +import { redirect } from "next/navigation"; +import { AnimatedText } from "@/components/animated-text"; +import { AnimatedBlobs } from "@/components/animated-blobs"; + +export default function Home() { + redirect("/projects"); + return ( +
+
+
+ ✨ DeepSite v3 is out! +
+

+ Code your website with AI in seconds +

+ +
{/* */}
+ +
+ +
+
+
+
+ 🚀 Powerful Features +
+

+ Everything you need +

+

+ Build, deploy, and scale your websites with cutting-edge features +

+
+ + {/* Bento Grid */} +
+ {/* Multi Pages */} +
+
+
📄
+

+ Multi Pages +

+

+ Create complex websites with multiple interconnected pages. + Build everything from simple landing pages to full-featured web + applications with dynamic routing and navigation. +

+
+ + Dynamic Routing + + + Navigation + + + SEO Ready + +
+
+
+
+ + {/* Auto Deploy */} +
+
+
+

+ Auto Deploy +

+

+ Push your changes and watch them go live instantly. No complex + CI/CD setup required. +

+
+
+
+ + {/* Free Hosting */} +
+
+
🌐
+

+ Free Hosting +

+

+ Host your websites for free with global CDN and lightning-fast + performance. +

+
+
+
+ + {/* Open Source Models */} +
+
+
🔓
+

+ Open Source Models +

+

+ Powered by cutting-edge open source AI models. Transparent, + customizable, and community-driven development. +

+
+ + Llama + + + Mistral + + + CodeLlama + +
+
+
+
+ + {/* UX Focus */} +
+
+
+

+ Perfect UX +

+

+ Intuitive interface designed for developers and non-developers + alike. +

+
+
+
+ + {/* Hugging Face Integration */} +
+
+
🤗
+

+ Hugging Face +

+

+ Seamless integration with Hugging Face models and datasets for + cutting-edge AI capabilities. +

+
+
+
+ + {/* Performance */} +
+
+
🚀
+

+ Blazing Fast +

+

+ Optimized performance with edge computing and smart caching. +

+
+
+
+
+
+ + {/* Background Effects */} +
+
+
+
+
+
+ ); +} diff --git a/app/(public)/projects/page.tsx b/app/(public)/projects/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6bf68718b1bf1ac36b01e40fbe8c2e7e1e9925bb --- /dev/null +++ b/app/(public)/projects/page.tsx @@ -0,0 +1,12 @@ +import { getProjects } from "@/app/actions/projects"; +import { MyProjects } from "@/components/my-projects"; +import { NotLogged } from "@/components/not-logged/not-logged"; + +export default async function ProjectsPage() { + const { ok, projects } = await getProjects(); + if (!ok) { + return ; + } + + return ; +} diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..a343e65e6726b35b32f022c117d3f3b5187d78e6 --- /dev/null +++ b/app/actions/auth.ts @@ -0,0 +1,18 @@ +"use server"; + +import { headers } from "next/headers"; + +export async function getAuth() { + const authList = await headers(); + const host = authList.get("host") ?? "localhost:3000"; + const url = host.includes("/spaces/enzostvs") + ? "enzostvs-deepsite.hf.space" + : host; + const redirect_uri = + `${host.includes("localhost") ? "http://" : "https://"}` + + url + + "/auth/callback"; + + const loginRedirectUrl = `https://huggingface.co/oauth/authorize?client_id=${process.env.OAUTH_CLIENT_ID}&redirect_uri=${redirect_uri}&response_type=code&scope=openid%20profile%20write-repos%20manage-repos%20inference-api&prompt=consent&state=1234567890`; + return loginRedirectUrl; +} diff --git a/app/actions/projects.ts b/app/actions/projects.ts new file mode 100644 index 0000000000000000000000000000000000000000..41e14705da939618f8c00543363aca9884c1171c --- /dev/null +++ b/app/actions/projects.ts @@ -0,0 +1,79 @@ +"use server"; + +import { isAuthenticated } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import { listSpaces } from "@huggingface/hub"; +import { ProjectType } from "@/types"; + +export async function getProjects(): Promise<{ + ok: boolean; + projects: ProjectType[]; + isEmpty?: boolean; +}> { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return { + ok: false, + projects: [], + }; + } + + // await dbConnect(); + // const projects = await Project.find({ + // user_id: user?.id, + // }) + // .sort({ _createdAt: -1 }) + // .limit(100) + // .lean(); + // if (!projects) { + // return { + // ok: true, + // isEmpty: true, + // projects: [], + // }; + // } + + // const mappedProjects = [] + + // for (const project of projects) { + // const space = await spaceInfo({ + // name: project.space_id, + // accessToken: user.token as string, + // additionalFields: ["author", "cardData"], + // }); + // if (!space.private) { + // mappedProjects.push({ + // ...project, + // name: space.name, + // cardData: space.cardData, + // }); + // } + // } + const projects = []; + // get user spaces from Hugging Face + for await (const space of listSpaces({ + accessToken: user.token as string, + additionalFields: ["author", "cardData"], + search: { + owner: user.name, + } + })) { + if ( + !space.private && + space.sdk === "static" && + Array.isArray((space.cardData as { tags?: string[] })?.tags) && + ( + ((space.cardData as { tags?: string[] })?.tags?.includes("deepsite-v3")) || + ((space.cardData as { tags?: string[] })?.tags?.includes("deepsite")) + ) + ) { + projects.push(space); + } + } + + return { + ok: true, + projects, + }; +} diff --git a/app/api/ask/route.ts b/app/api/ask/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb920b5909d62bbc37449d376883b6f319005542 --- /dev/null +++ b/app/api/ask/route.ts @@ -0,0 +1,541 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { InferenceClient } from "@huggingface/inference"; + +import { MODELS, PROVIDERS } from "@/lib/providers"; +import { + DIVIDER, + FOLLOW_UP_SYSTEM_PROMPT, + INITIAL_SYSTEM_PROMPT, + MAX_REQUESTS_PER_IP, + NEW_PAGE_END, + NEW_PAGE_START, + REPLACE_END, + SEARCH_START, + UPDATE_PAGE_START, + UPDATE_PAGE_END, +} from "@/lib/prompts"; +import MY_TOKEN_KEY from "@/lib/get-cookie-name"; +import { Page } from "@/types"; +import { uploadFiles } from "@huggingface/hub"; +import { isAuthenticated } from "@/lib/auth"; +import { getBestProvider } from "@/lib/best-provider"; +import { rewritePrompt } from "@/lib/rewrite-prompt"; + +const ipAddresses = new Map(); + +export async function POST(request: NextRequest) { + const authHeaders = await headers(); + const userToken = request.cookies.get(MY_TOKEN_KEY())?.value; + + const body = await request.json(); + const { prompt, provider, model, redesignMarkdown, enhancedSettings } = body; + + if (!model || (!prompt && !redesignMarkdown)) { + return NextResponse.json( + { ok: false, error: "Missing required fields" }, + { status: 400 } + ); + } + + const selectedModel = MODELS.find( + (m) => m.value === model || m.label === model + ); + + if (!selectedModel) { + return NextResponse.json( + { ok: false, error: "Invalid model selected" }, + { status: 400 } + ); + } + + if (!selectedModel.providers.includes(provider) && provider !== "auto") { + return NextResponse.json( + { + ok: false, + error: `The selected model does not support the ${provider} provider.`, + openSelectProvider: true, + }, + { status: 400 } + ); + } + + let token = userToken; + let billTo: string | null = null; + + /** + * Handle local usage token, this bypass the need for a user token + * and allows local testing without authentication. + * This is useful for development and testing purposes. + */ + if (process.env.HF_TOKEN && process.env.HF_TOKEN.length > 0) { + token = process.env.HF_TOKEN; + } + + const ip = authHeaders.get("x-forwarded-for")?.includes(",") + ? authHeaders.get("x-forwarded-for")?.split(",")[1].trim() + : authHeaders.get("x-forwarded-for"); + + if (!token) { + ipAddresses.set(ip, (ipAddresses.get(ip) || 0) + 1); + if (ipAddresses.get(ip) > MAX_REQUESTS_PER_IP) { + return NextResponse.json( + { + ok: false, + openLogin: true, + message: "Log In to continue using the service", + }, + { status: 429 } + ); + } + + token = process.env.DEFAULT_HF_TOKEN as string; + billTo = "huggingface"; + } + + const selectedProvider = await getBestProvider(selectedModel.value, provider) + + let rewrittenPrompt = prompt; + + if (enhancedSettings.isActive) { + rewrittenPrompt = await rewritePrompt(prompt, enhancedSettings, { token, billTo }, selectedModel.value, selectedProvider); + } + + console.log(rewrittenPrompt); + + try { + const encoder = new TextEncoder(); + const stream = new TransformStream(); + const writer = stream.writable.getWriter(); + + const response = new NextResponse(stream.readable, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + + (async () => { + // let completeResponse = ""; + try { + const client = new InferenceClient(token); + const chatCompletion = client.chatCompletionStream( + { + model: selectedModel.value, + provider: selectedProvider, + messages: [ + { + role: "system", + content: INITIAL_SYSTEM_PROMPT, + }, + { + role: "user", + content: `${rewritePrompt}${redesignMarkdown ? `\n\nHere is my current design as a markdown:\n\n${redesignMarkdown}\n\nNow, please create a new design based on this markdown. Use the images in the markdown.` : ""}` + }, + ], + max_tokens: selectedProvider.max_tokens, + }, + billTo ? { billTo } : {} + ); + + while (true) { + const { done, value } = await chatCompletion.next() + if (done) { + break; + } + + const chunk = value.choices[0]?.delta?.content; + if (chunk) { + await writer.write(encoder.encode(chunk)); + } + } + + // Explicitly close the writer after successful completion + await writer.close(); + } catch (error: any) { + if (error.message?.includes("exceeded your monthly included credits")) { + await writer.write( + encoder.encode( + JSON.stringify({ + ok: false, + openProModal: true, + message: error.message, + }) + ) + ); + } else if (error?.message?.includes("inference provider information")) { + await writer.write( + encoder.encode( + JSON.stringify({ + ok: false, + openSelectProvider: true, + message: error.message, + }) + ) + ); + } + else { + await writer.write( + encoder.encode( + JSON.stringify({ + ok: false, + message: + error.message || + "An error occurred while processing your request.", + }) + ) + ); + } + } finally { + // Ensure the writer is always closed, even if already closed + try { + await writer?.close(); + } catch { + // Ignore errors when closing the writer as it might already be closed + } + } + })(); + + return response; + } catch (error: any) { + return NextResponse.json( + { + ok: false, + openSelectProvider: true, + message: + error?.message || "An error occurred while processing your request.", + }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest) { + const user = await isAuthenticated(); + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const authHeaders = await headers(); + + const body = await request.json(); + const { prompt, previousPrompts, provider, selectedElementHtml, model, pages, files, repoId } = + body; + + if (!prompt || pages.length === 0 || !repoId) { + return NextResponse.json( + { ok: false, error: "Missing required fields" }, + { status: 400 } + ); + } + + const selectedModel = MODELS.find( + (m) => m.value === model || m.label === model + ); + if (!selectedModel) { + return NextResponse.json( + { ok: false, error: "Invalid model selected" }, + { status: 400 } + ); + } + + let token = user.token as string; + let billTo: string | null = null; + + /** + * Handle local usage token, this bypass the need for a user token + * and allows local testing without authentication. + * This is useful for development and testing purposes. + */ + if (process.env.HF_TOKEN && process.env.HF_TOKEN.length > 0) { + token = process.env.HF_TOKEN; + } + + const ip = authHeaders.get("x-forwarded-for")?.includes(",") + ? authHeaders.get("x-forwarded-for")?.split(",")[1].trim() + : authHeaders.get("x-forwarded-for"); + + if (!token) { + ipAddresses.set(ip, (ipAddresses.get(ip) || 0) + 1); + if (ipAddresses.get(ip) > MAX_REQUESTS_PER_IP) { + return NextResponse.json( + { + ok: false, + openLogin: true, + message: "Log In to continue using the service", + }, + { status: 429 } + ); + } + + token = process.env.DEFAULT_HF_TOKEN as string; + billTo = "huggingface"; + } + + const client = new InferenceClient(token); + + const selectedProvider = await getBestProvider(selectedModel.value, provider) + + try { + const response = await client.chatCompletion( + { + model: selectedModel.value, + provider: selectedProvider, + messages: [ + { + role: "system", + content: FOLLOW_UP_SYSTEM_PROMPT, + }, + { + role: "user", + content: previousPrompts + ? `Also here are the previous prompts:\n\n${previousPrompts.map((p: string) => `- ${p}`).join("\n")}` + : "You are modifying the HTML file based on the user's request.", + }, + { + role: "assistant", + + content: `${ + selectedElementHtml + ? `\n\nYou have to update ONLY the following element, NOTHING ELSE: \n\n\`\`\`html\n${selectedElementHtml}\n\`\`\`` + : "" + }. Current pages: ${pages?.map((p: Page) => `- ${p.path} \n${p.html}`).join("\n")}. ${files?.length > 0 ? `Current images: ${files?.map((f: string) => `- ${f}`).join("\n")}.` : ""}`, + }, + { + role: "user", + content: prompt, + }, + ], + ...(selectedProvider.id !== "sambanova" + ? { + max_tokens: selectedProvider.max_tokens, + } + : {}), + }, + billTo ? { billTo } : {} + ); + + const chunk = response.choices[0]?.message?.content; + if (!chunk) { + return NextResponse.json( + { ok: false, message: "No content returned from the model" }, + { status: 400 } + ); + } + + if (chunk) { + const updatedLines: number[][] = []; + let newHtml = ""; + const updatedPages = [...(pages || [])]; + + const updatePageRegex = new RegExp(`${UPDATE_PAGE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\s]+)\\s*${UPDATE_PAGE_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)(?=${UPDATE_PAGE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${NEW_PAGE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|$)`, 'g'); + let updatePageMatch; + + while ((updatePageMatch = updatePageRegex.exec(chunk)) !== null) { + const [, pagePath, pageContent] = updatePageMatch; + + const pageIndex = updatedPages.findIndex(p => p.path === pagePath); + if (pageIndex !== -1) { + let pageHtml = updatedPages[pageIndex].html; + + let processedContent = pageContent; + const htmlMatch = pageContent.match(/```html\s*([\s\S]*?)\s*```/); + if (htmlMatch) { + processedContent = htmlMatch[1]; + } + let position = 0; + let moreBlocks = true; + + while (moreBlocks) { + const searchStartIndex = processedContent.indexOf(SEARCH_START, position); + if (searchStartIndex === -1) { + moreBlocks = false; + continue; + } + + const dividerIndex = processedContent.indexOf(DIVIDER, searchStartIndex); + if (dividerIndex === -1) { + moreBlocks = false; + continue; + } + + const replaceEndIndex = processedContent.indexOf(REPLACE_END, dividerIndex); + if (replaceEndIndex === -1) { + moreBlocks = false; + continue; + } + + const searchBlock = processedContent.substring( + searchStartIndex + SEARCH_START.length, + dividerIndex + ); + const replaceBlock = processedContent.substring( + dividerIndex + DIVIDER.length, + replaceEndIndex + ); + + if (searchBlock.trim() === "") { + pageHtml = `${replaceBlock}\n${pageHtml}`; + updatedLines.push([1, replaceBlock.split("\n").length]); + } else { + const blockPosition = pageHtml.indexOf(searchBlock); + if (blockPosition !== -1) { + const beforeText = pageHtml.substring(0, blockPosition); + const startLineNumber = beforeText.split("\n").length; + const replaceLines = replaceBlock.split("\n").length; + const endLineNumber = startLineNumber + replaceLines - 1; + + updatedLines.push([startLineNumber, endLineNumber]); + pageHtml = pageHtml.replace(searchBlock, replaceBlock); + } + } + + position = replaceEndIndex + REPLACE_END.length; + } + + updatedPages[pageIndex].html = pageHtml; + + if (pagePath === '/' || pagePath === '/index' || pagePath === 'index') { + newHtml = pageHtml; + } + } + } + + const newPageRegex = new RegExp(`${NEW_PAGE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\s]+)\\s*${NEW_PAGE_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([\\s\\S]*?)(?=${UPDATE_PAGE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${NEW_PAGE_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|$)`, 'g'); + let newPageMatch; + + while ((newPageMatch = newPageRegex.exec(chunk)) !== null) { + const [, pagePath, pageContent] = newPageMatch; + + let pageHtml = pageContent; + const htmlMatch = pageContent.match(/```html\s*([\s\S]*?)\s*```/); + if (htmlMatch) { + pageHtml = htmlMatch[1]; + } + + const existingPageIndex = updatedPages.findIndex(p => p.path === pagePath); + + if (existingPageIndex !== -1) { + updatedPages[existingPageIndex] = { + path: pagePath, + html: pageHtml.trim() + }; + } else { + updatedPages.push({ + path: pagePath, + html: pageHtml.trim() + }); + } + } + + if (updatedPages.length === pages?.length && !chunk.includes(UPDATE_PAGE_START)) { + let position = 0; + let moreBlocks = true; + + while (moreBlocks) { + const searchStartIndex = chunk.indexOf(SEARCH_START, position); + if (searchStartIndex === -1) { + moreBlocks = false; + continue; + } + + const dividerIndex = chunk.indexOf(DIVIDER, searchStartIndex); + if (dividerIndex === -1) { + moreBlocks = false; + continue; + } + + const replaceEndIndex = chunk.indexOf(REPLACE_END, dividerIndex); + if (replaceEndIndex === -1) { + moreBlocks = false; + continue; + } + + const searchBlock = chunk.substring( + searchStartIndex + SEARCH_START.length, + dividerIndex + ); + const replaceBlock = chunk.substring( + dividerIndex + DIVIDER.length, + replaceEndIndex + ); + + if (searchBlock.trim() === "") { + newHtml = `${replaceBlock}\n${newHtml}`; + updatedLines.push([1, replaceBlock.split("\n").length]); + } else { + const blockPosition = newHtml.indexOf(searchBlock); + if (blockPosition !== -1) { + const beforeText = newHtml.substring(0, blockPosition); + const startLineNumber = beforeText.split("\n").length; + const replaceLines = replaceBlock.split("\n").length; + const endLineNumber = startLineNumber + replaceLines - 1; + + updatedLines.push([startLineNumber, endLineNumber]); + newHtml = newHtml.replace(searchBlock, replaceBlock); + } + } + + position = replaceEndIndex + REPLACE_END.length; + } + + // Update the main HTML if it's the index page + const mainPageIndex = updatedPages.findIndex(p => p.path === '/' || p.path === '/index' || p.path === 'index'); + if (mainPageIndex !== -1) { + updatedPages[mainPageIndex].html = newHtml; + } + } + + const files: File[] = []; + updatedPages.forEach((page: Page) => { + const file = new File([page.html], page.path, { type: "text/html" }); + files.push(file); + }); + + uploadFiles({ + repo: { + type: "space", + name: repoId, + }, + files, + commitTitle: prompt, + accessToken: user.token as string, + }); + + return NextResponse.json({ + ok: true, + updatedLines, + pages: updatedPages, + }); + } else { + return NextResponse.json( + { ok: false, message: "No content returned from the model" }, + { status: 400 } + ); + } + } catch (error: any) { + if (error.message?.includes("exceeded your monthly included credits")) { + return NextResponse.json( + { + ok: false, + openProModal: true, + message: error.message, + }, + { status: 402 } + ); + } + return NextResponse.json( + { + ok: false, + openSelectProvider: true, + message: + error.message || "An error occurred while processing your request.", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..221e0c266bd2e6673779b9e81caf333243147f60 --- /dev/null +++ b/app/api/auth/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { code } = body; + + if (!code) { + return NextResponse.json( + { error: "Code is required" }, + { + status: 400, + headers: { + "Content-Type": "application/json", + }, + } + ); + } + + const Authorization = `Basic ${Buffer.from( + `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}` + ).toString("base64")}`; + + const host = + req.headers.get("host") ?? req.headers.get("origin") ?? "localhost:3000"; + + const url = host.includes("/spaces/enzostvs") + ? "enzostvs-deepsite.hf.space" + : host; + const redirect_uri = + `${host.includes("localhost") ? "http://" : "https://"}` + + url + + "/auth/callback"; + const request_auth = await fetch("https://huggingface.co/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri, + }), + }); + + const response = await request_auth.json(); + if (!response.access_token) { + return NextResponse.json( + { error: "Failed to retrieve access token" }, + { + status: 400, + headers: { + "Content-Type": "application/json", + }, + } + ); + } + + const userResponse = await fetch("https://huggingface.co/api/whoami-v2", { + headers: { + Authorization: `Bearer ${response.access_token}`, + }, + }); + + if (!userResponse.ok) { + return NextResponse.json( + { user: null, errCode: userResponse.status }, + { status: userResponse.status } + ); + } + const user = await userResponse.json(); + + return NextResponse.json( + { + access_token: response.access_token, + expires_in: response.expires_in, + user, + }, + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + } + ); +} diff --git a/app/api/me/projects/[namespace]/[repoId]/commits/[commitId]/promote/route.ts b/app/api/me/projects/[namespace]/[repoId]/commits/[commitId]/promote/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9fb25130c9f50ff59bd06eff836a234ffa60153 --- /dev/null +++ b/app/api/me/projects/[namespace]/[repoId]/commits/[commitId]/promote/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from "next/server"; +import { RepoDesignation, listFiles, uploadFiles } from "@huggingface/hub"; + +import { isAuthenticated } from "@/lib/auth"; +import Project from "@/models/Project"; +import dbConnect from "@/lib/mongodb"; + +export async function POST( + req: NextRequest, + { params }: { + params: Promise<{ + namespace: string; + repoId: string; + commitId: string; + }> + } +) { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + const param = await params; + const { namespace, repoId, commitId } = param; + + const project = await Project.findOne({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + }).lean(); + + if (!project) { + return NextResponse.json( + { ok: false, error: "Project not found" }, + { status: 404 } + ); + } + + try { + const repo: RepoDesignation = { + type: "space", + name: `${namespace}/${repoId}`, + }; + + // Fetch files from the specific commit + const files: File[] = []; + const allowedExtensions = ["html", "md", "css", "js", "json", "txt"]; + + // Get all files from the specific commit + for await (const fileInfo of listFiles({ + repo, + accessToken: user.token as string, + revision: commitId, + })) { + const fileExtension = fileInfo.path.split('.').pop()?.toLowerCase(); + + if (allowedExtensions.includes(fileExtension || "")) { + // Fetch the file content from the specific commit + const response = await fetch( + `https://huggingface.co/spaces/${namespace}/${repoId}/raw/${commitId}/${fileInfo.path}` + ); + + if (response.ok) { + const content = await response.text(); + let mimeType = "text/plain"; + + switch (fileExtension) { + case "html": + mimeType = "text/html"; + break; + case "css": + mimeType = "text/css"; + break; + case "js": + mimeType = "application/javascript"; + break; + case "json": + mimeType = "application/json"; + break; + case "md": + mimeType = "text/markdown"; + break; + } + + const file = new File([content], fileInfo.path, { type: mimeType }); + files.push(file); + } + } + } + + if (files.length === 0) { + return NextResponse.json( + { ok: false, error: "No files found in the specified commit" }, + { status: 404 } + ); + } + + // Upload the files to the main branch with a promotion commit message + await uploadFiles({ + repo, + files, + accessToken: user.token as string, + commitTitle: `Promote version ${commitId.slice(0, 7)} to main`, + commitDescription: `Promoted commit ${commitId} to main branch`, + }); + + return NextResponse.json( + { + ok: true, + message: "Version promoted successfully", + promotedCommit: commitId, + filesPromoted: files.length + }, + { status: 200 } + ); + + } catch (error: any) { + console.error("Error promoting version:", error); + + // Handle specific HuggingFace API errors + if (error.statusCode === 404) { + return NextResponse.json( + { ok: false, error: "Commit not found" }, + { status: 404 } + ); + } + + if (error.statusCode === 403) { + return NextResponse.json( + { ok: false, error: "Access denied to repository" }, + { status: 403 } + ); + } + + return NextResponse.json( + { ok: false, error: error.message || "Failed to promote version" }, + { status: 500 } + ); + } +} diff --git a/app/api/me/projects/[namespace]/[repoId]/images/route.ts b/app/api/me/projects/[namespace]/[repoId]/images/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd5edfbeecd373b7b0cd847f19f1b679a281d657 --- /dev/null +++ b/app/api/me/projects/[namespace]/[repoId]/images/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import { RepoDesignation, uploadFiles } from "@huggingface/hub"; + +import { isAuthenticated } from "@/lib/auth"; +import Project from "@/models/Project"; +import dbConnect from "@/lib/mongodb"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ namespace: string; repoId: string }> } +) { + try { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + const param = await params; + const { namespace, repoId } = param; + + const project = await Project.findOne({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + }).lean(); + + if (!project) { + return NextResponse.json( + { + ok: false, + error: "Project not found", + }, + { status: 404 } + ); + } + + // Parse the FormData to get the images + const formData = await req.formData(); + const imageFiles = formData.getAll("images") as File[]; + + if (!imageFiles || imageFiles.length === 0) { + return NextResponse.json( + { + ok: false, + error: "At least one image file is required under the 'images' key", + }, + { status: 400 } + ); + } + + const files: File[] = []; + for (const file of imageFiles) { + if (!(file instanceof File)) { + return NextResponse.json( + { + ok: false, + error: "Invalid file format - all items under 'images' key must be files", + }, + { status: 400 } + ); + } + + if (!file.type.startsWith('image/')) { + return NextResponse.json( + { + ok: false, + error: `File ${file.name} is not an image`, + }, + { status: 400 } + ); + } + + // Create File object with images/ folder prefix + const fileName = `images/${file.name}`; + const processedFile = new File([file], fileName, { type: file.type }); + files.push(processedFile); + } + + // Upload files to HuggingFace space + const repo: RepoDesignation = { + type: "space", + name: `${namespace}/${repoId}`, + }; + + await uploadFiles({ + repo, + files, + accessToken: user.token as string, + commitTitle: `Upload ${files.length} image(s)`, + }); + + return NextResponse.json({ + ok: true, + message: `Successfully uploaded ${files.length} image(s) to ${namespace}/${repoId}/images/`, + uploadedFiles: files.map((file) => `https://huggingface.co/spaces/${namespace}/${repoId}/resolve/main/${file.name}`), + }, { status: 200 }); + + } catch (error) { + console.error('Error uploading images:', error); + return NextResponse.json( + { + ok: false, + error: "Failed to upload images", + }, + { status: 500 } + ); + } +} diff --git a/app/api/me/projects/[namespace]/[repoId]/route.ts b/app/api/me/projects/[namespace]/[repoId]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa26b365df82ed8936bb346f779a076b271a93d1 --- /dev/null +++ b/app/api/me/projects/[namespace]/[repoId]/route.ts @@ -0,0 +1,235 @@ +import { NextRequest, NextResponse } from "next/server"; +import { RepoDesignation, spaceInfo, uploadFiles, listFiles, deleteRepo, listCommits } from "@huggingface/hub"; + +import { isAuthenticated } from "@/lib/auth"; +import Project from "@/models/Project"; +import dbConnect from "@/lib/mongodb"; +import { Commit, Page } from "@/types"; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ namespace: string; repoId: string }> } +) { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + const param = await params; + const { namespace, repoId } = param; + + const project = await Project.findOne({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + }).lean(); + + if (!project) { + return NextResponse.json( + { ok: false, error: "Project not found" }, + { status: 404 } + ); + } + + try { + const space = await spaceInfo({ + name: `${namespace}/${repoId}`, + accessToken: user.token as string, + additionalFields: ["author"], + }); + + if (!space || space.sdk !== "static") { + return NextResponse.json( + { ok: false, error: "Space is not a static space." }, + { status: 404 } + ); + } + + if (space.author !== user.name) { + return NextResponse.json( + { ok: false, error: "Space does not belong to the authenticated user." }, + { status: 403 } + ); + } + + if (space.private) { + return NextResponse.json( + { ok: false, error: "Your space must be public to access it." }, + { status: 403 } + ); + } + + const repo: RepoDesignation = { + type: "space", + name: `${namespace}/${repoId}`, + }; + + await deleteRepo({ + repo, + accessToken: user.token as string, + }); + + await Project.deleteOne({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + }); + + return NextResponse.json({ ok: true }, { status: 200 }); + } catch (error: any) { + return NextResponse.json( + { ok: false, error: error.message }, + { status: 500 } + ); + } +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ namespace: string; repoId: string }> } +) { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + const param = await params; + const { namespace, repoId } = param; + + const project = await Project.findOne({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + }).lean(); + if (!project) { + return NextResponse.json( + { + ok: false, + error: "Project not found", + }, + { status: 404 } + ); + } + try { + const space = await spaceInfo({ + name: namespace + "/" + repoId, + accessToken: user.token as string, + additionalFields: ["author"], + }); + + if (!space || space.sdk !== "static") { + return NextResponse.json( + { + ok: false, + error: "Space is not a static space", + }, + { status: 404 } + ); + } + if (space.author !== user.name) { + return NextResponse.json( + { + ok: false, + error: "Space does not belong to the authenticated user", + }, + { status: 403 } + ); + } + if (space.private) { + return NextResponse.json( + { + ok: false, + error: "Space must be public to access it", + }, + { status: 403 } + ); + } + + const repo: RepoDesignation = { + type: "space", + name: `${namespace}/${repoId}`, + }; + + const htmlFiles: Page[] = []; + const files: string[] = []; + + const allowedFilesExtensions = ["jpg", "jpeg", "png", "gif", "svg", "webp", "avif", "heic", "heif", "ico", "bmp", "tiff", "tif"]; + + for await (const fileInfo of listFiles({repo, accessToken: user.token as string})) { + if (fileInfo.path.endsWith(".html")) { + const res = await fetch(`https://huggingface.co/spaces/${namespace}/${repoId}/raw/main/${fileInfo.path}`); + if (res.ok) { + const html = await res.text(); + if (fileInfo.path === "index.html") { + htmlFiles.unshift({ + path: fileInfo.path, + html, + }); + } else { + htmlFiles.push({ + path: fileInfo.path, + html, + }); + } + } + } + if (fileInfo.type === "directory" && fileInfo.path === "images") { + for await (const imageInfo of listFiles({repo, accessToken: user.token as string, path: fileInfo.path})) { + if (allowedFilesExtensions.includes(imageInfo.path.split(".").pop() || "")) { + files.push(`https://huggingface.co/spaces/${namespace}/${repoId}/resolve/main/${imageInfo.path}`); + } + } + } + } + const commits: Commit[] = []; + for await (const commit of listCommits({ repo, accessToken: user.token as string })) { + if (commit.title.includes("initial commit") || commit.title.includes("image(s)") || commit.title.includes("Promote version")) { + continue; + } + commits.push({ + title: commit.title, + oid: commit.oid, + date: commit.date, + }); + } + + if (htmlFiles.length === 0) { + return NextResponse.json( + { + ok: false, + error: "No HTML files found", + }, + { status: 404 } + ); + } + + return NextResponse.json( + { + project, + pages: htmlFiles, + files, + commits, + ok: true, + }, + { status: 200 } + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.statusCode === 404) { + await Project.deleteOne({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + }); + return NextResponse.json( + { error: "Space not found", ok: false }, + { status: 404 } + ); + } + return NextResponse.json( + { error: error.message, ok: false }, + { status: 500 } + ); + } +} diff --git a/app/api/me/projects/route.ts b/app/api/me/projects/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cb6363099f5ebdc3f153b2da71a15707d6880dc --- /dev/null +++ b/app/api/me/projects/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { RepoDesignation, createRepo, listCommits, uploadFiles } from "@huggingface/hub"; + +import { isAuthenticated } from "@/lib/auth"; +import Project from "@/models/Project"; +import dbConnect from "@/lib/mongodb"; +import { Commit, Page } from "@/types"; +import { COLORS } from "@/lib/utils"; + +export async function POST( + req: NextRequest, +) { + const user = await isAuthenticated(); + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + const { title: titleFromRequest, pages, prompt } = await req.json(); + + const title = titleFromRequest ?? "DeepSite Project"; + + const formattedTitle = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .split("-") + .filter(Boolean) + .join("-") + .slice(0, 96); + + const repo: RepoDesignation = { + type: "space", + name: `${user.name}/${formattedTitle}`, + }; + const colorFrom = COLORS[Math.floor(Math.random() * COLORS.length)]; + const colorTo = COLORS[Math.floor(Math.random() * COLORS.length)]; + const README = `--- +title: ${title} +colorFrom: ${colorFrom} +colorTo: ${colorTo} +emoji: 🐳 +sdk: static +pinned: false +tags: + - deepsite-v3 +--- + +# Welcome to your new DeepSite project! +This project was created with [DeepSite](https://deepsite.hf.co). +`; + + const files: File[] = []; + const readmeFile = new File([README], "README.md", { type: "text/markdown" }); + files.push(readmeFile); + pages.forEach((page: Page) => { + const file = new File([page.html], page.path, { type: "text/html" }); + files.push(file); + }); + + try { + const { repoUrl } = await createRepo({ + repo, + accessToken: user.token as string, + }); + await uploadFiles({ + repo, + files, + accessToken: user.token as string, + commitTitle: prompt ?? "Redesign my website" + }); + + const path = repoUrl.split("/").slice(-2).join("/"); + const project = await Project.create({ + user_id: user.id, + space_id: path, + }); + + const commits: Commit[] = []; + for await (const commit of listCommits({ repo, accessToken: user.token as string })) { + if (commit.title.includes("initial commit") || commit.title.includes("image(s)") || commit.title.includes("Promote version")) { + continue; + } + commits.push({ + title: commit.title, + oid: commit.oid, + date: commit.date, + }); + } + + let newProject = { + files, + pages, + commits, + project, + } + + return NextResponse.json({ space: newProject, path, ok: true }, { status: 201 }); + } catch (err: any) { + return NextResponse.json( + { error: err.message, ok: false }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/me/route.ts b/app/api/me/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4164daba5c58bb2fe7f4f7508de7165f32ca443 --- /dev/null +++ b/app/api/me/route.ts @@ -0,0 +1,25 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const authHeaders = await headers(); + const token = authHeaders.get("Authorization"); + if (!token) { + return NextResponse.json({ user: null, errCode: 401 }, { status: 401 }); + } + + const userResponse = await fetch("https://huggingface.co/api/whoami-v2", { + headers: { + Authorization: `${token}`, + }, + }); + + if (!userResponse.ok) { + return NextResponse.json( + { user: null, errCode: userResponse.status }, + { status: userResponse.status } + ); + } + const user = await userResponse.json(); + return NextResponse.json({ user, errCode: null }, { status: 200 }); +} diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac013ef8c148298fd30bb09c70b3fb8f7d00e2ae --- /dev/null +++ b/app/api/proxy/route.ts @@ -0,0 +1,246 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAuthenticated } from "@/lib/auth"; + +export async function GET(req: NextRequest) { + const user: any = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const spaceId = searchParams.get('spaceId'); + const commitId = searchParams.get('commitId'); + const path = searchParams.get('path') || '/'; + + if (!spaceId) { + return NextResponse.json({ error: "spaceId parameter required" }, { status: 400 }); + } + + try { + const spaceDomain = `${spaceId.replace("/", "-")}${commitId !== null? `--rev-${commitId.slice(0, 7)}` : ""}.static.hf.space`; + const targetUrl = `https://${spaceDomain}${path}`; + + const response = await fetch(targetUrl, { + headers: { + 'User-Agent': req.headers.get('user-agent') || '', + }, + }); + + if (!response.ok) { + console.error('Failed to fetch from HF space:', response.status, response.statusText); + return NextResponse.json({ + error: "Failed to fetch content", + details: `${response.status} ${response.statusText}`, + targetUrl + }, { status: response.status }); + } + + let content = await response.text(); + const contentType = response.headers.get('content-type') || 'text/html'; + + // Rewrite relative URLs to go through the proxy + if (contentType.includes('text/html')) { + const baseUrl = `https://${spaceDomain}`; + + // Fix relative URLs in href attributes + content = content.replace(/href="([^"]+)"/g, (match, url) => { + if (url.startsWith('/') && !url.startsWith('//')) { + // Relative URL starting with / + return `href="${baseUrl}${url}"`; + } else if (!url.includes('://') && !url.startsWith('#') && !url.startsWith('mailto:') && !url.startsWith('tel:')) { + // Relative URL not starting with / + return `href="${baseUrl}/${url}"`; + } + return match; + }); + + // Fix relative URLs in src attributes + content = content.replace(/src="([^"]+)"/g, (match, url) => { + if (url.startsWith('/') && !url.startsWith('//')) { + return `src="${baseUrl}${url}"`; + } else if (!url.includes('://')) { + return `src="${baseUrl}/${url}"`; + } + return match; + }); + + // Add base tag to ensure relative URLs work correctly + const baseTag = ``; + if (content.includes('')) { + content = content.replace('', `${baseTag}`); + } else if (content.includes('')) { + content = content.replace('', `${baseTag}`); + } else { + content = `${baseTag}` + content; + } + } + + const injectedScript = ` + + `; + + let modifiedContent; + if (content.includes('')) { + modifiedContent = content.replace( + /<\/body>/i, + `${injectedScript}` + ); + } else { + modifiedContent = content + injectedScript; + } + + return new NextResponse(modifiedContent, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'X-Frame-Options': 'SAMEORIGIN', + }, + }); + + } catch (error) { + console.error('Proxy error:', error); + return NextResponse.json({ + error: "Proxy request failed", + details: error instanceof Error ? error.message : String(error), + spaceId + }, { status: 500 }); + } +} diff --git a/app/api/re-design/route.ts b/app/api/re-design/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..777c2cbd18f95592080c0fe1ad2cab23a5264397 --- /dev/null +++ b/app/api/re-design/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function PUT(request: NextRequest) { + const body = await request.json(); + const { url } = body; + + if (!url) { + return NextResponse.json({ error: "URL is required" }, { status: 400 }); + } + + try { + const response = await fetch( + `https://r.jina.ai/${encodeURIComponent(url)}`, + { + method: "POST", + } + ); + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch redesign" }, + { status: 500 } + ); + } + const markdown = await response.text(); + return NextResponse.json( + { + ok: true, + markdown, + }, + { status: 200 } + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + return NextResponse.json( + { error: error.message || "An error occurred" }, + { status: 500 } + ); + } +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c361e3e555493e1fa763fa0eb612a83442d06ae8 --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,74 @@ +"use client"; +import Link from "next/link"; +import { useUser } from "@/hooks/useUser"; +import { use, useState } from "react"; +import { useMount, useTimeoutFn } from "react-use"; + +import { Button } from "@/components/ui/button"; +import { AnimatedBlobs } from "@/components/animated-blobs"; +export default function AuthCallback({ + searchParams, +}: { + searchParams: Promise<{ code: string }>; +}) { + const [showButton, setShowButton] = useState(false); + const { code } = use(searchParams); + const { loginFromCode } = useUser(); + + useMount(async () => { + if (code) { + await loginFromCode(code); + } + }); + + useTimeoutFn(() => setShowButton(true), 7000); + + return ( +
+
+
+
+
+
+
+ 🚀 +
+
+ 👋 +
+
+ 🙌 +
+
+

+ Login In Progress... +

+

+ Wait a moment while we log you in with your code. +

+
+
+
+

+ If you are not redirected automatically in the next 5 seconds, + please click the button below +

+ {showButton ? ( + + + + ) : ( +

+ Please wait, we are logging you in... +

+ )} +
+
+
+ +
+
+ ); +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a45a6bc6f58907b4ee5efbf0f70a51ee153625c7 --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import { Metadata } from "next"; + +import { getAuth } from "@/app/actions/auth"; + +export const revalidate = 1; + +export const metadata: Metadata = { + robots: "noindex, nofollow", +}; + +export default async function Auth() { + const loginRedirectUrl = await getAuth(); + if (loginRedirectUrl) { + redirect(loginRedirectUrl); + } + + return ( +
+
+

Error

+

+ An error occurred while trying to log in. Please try again later. +

+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css deleted file mode 100644 index a2dc41ecee5ec435200fe7cba2bde4107f823774..0000000000000000000000000000000000000000 --- a/app/globals.css +++ /dev/null @@ -1,26 +0,0 @@ -@import "tailwindcss"; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87eb875260ed98651bc419c8139b5119e554..14e014577ac2186c8ff9a4be8d6e60dad57e8290 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,33 +1,115 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Metadata, Viewport } from "next"; +import { Inter, PT_Sans } from "next/font/google"; +import { cookies } from "next/headers"; +import Script from "next/script"; -const geistSans = Geist({ - variable: "--font-geist-sans", +import "@/assets/globals.css"; +import { Toaster } from "@/components/ui/sonner"; +import MY_TOKEN_KEY from "@/lib/get-cookie-name"; +import { apiServer } from "@/lib/api"; +import IframeDetector from "@/components/iframe-detector"; +import AppContext from "@/components/contexts/app-context"; +import TanstackContext from "@/components/contexts/tanstack-query-context"; +import { LoginProvider } from "@/components/contexts/login-context"; +import { ProProvider } from "@/components/contexts/pro-context"; + +const inter = Inter({ + variable: "--font-inter-sans", subsets: ["latin"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const ptSans = PT_Sans({ + variable: "--font-ptSans-mono", subsets: ["latin"], + weight: ["400", "700"], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "DeepSite | Build with AI ✨", + description: + "DeepSite is a web development tool that helps you build websites with AI, no code required. Let's deploy your website with DeepSite and enjoy the magic of AI.", + openGraph: { + title: "DeepSite | Build with AI ✨", + description: + "DeepSite is a web development tool that helps you build websites with AI, no code required. Let's deploy your website with DeepSite and enjoy the magic of AI.", + url: "https://deepsite.hf.co", + siteName: "DeepSite", + images: [ + { + url: "https://deepsite.hf.co/banner.png", + width: 1200, + height: 630, + alt: "DeepSite Open Graph Image", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "DeepSite | Build with AI ✨", + description: + "DeepSite is a web development tool that helps you build websites with AI, no code required. Let's deploy your website with DeepSite and enjoy the magic of AI.", + images: ["https://deepsite.hf.co/banner.png"], + }, + appleWebApp: { + capable: true, + title: "DeepSite", + statusBarStyle: "black-translucent", + }, + icons: { + icon: "/logo.svg", + shortcut: "/logo.svg", + apple: "/logo.svg", + }, +}; + +export const viewport: Viewport = { + initialScale: 1, + maximumScale: 1, + themeColor: "#000000", }; -export default function RootLayout({ +async function getMe() { + const cookieStore = await cookies(); + const token = cookieStore.get(MY_TOKEN_KEY())?.value; + if (!token) return { user: null, errCode: null }; + try { + const res = await apiServer.get("/me", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return { user: res.data.user, errCode: null }; + } catch (err: any) { + return { user: null, errCode: err.status }; + } +} + +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const data = await getMe(); return ( + - {children} + + + + + + {children} + + + ); diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 21b686d98132abcf2202a699b148aa6a280a0d7e..0000000000000000000000000000000000000000 --- a/app/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- - -
- -
- ); -} diff --git a/app/projects/[namespace]/[repoId]/page.tsx b/app/projects/[namespace]/[repoId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..66d78b3ec8bf617eb018764cb76bff1913779ae2 --- /dev/null +++ b/app/projects/[namespace]/[repoId]/page.tsx @@ -0,0 +1,10 @@ +import { AppEditor } from "@/components/editor"; + +export default async function ProjectNamespacePage({ + params, +}: { + params: Promise<{ namespace: string; repoId: string }>; +}) { + const { namespace, repoId } = await params; + return ; +} diff --git a/app/projects/new/page.tsx b/app/projects/new/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..591377b92589fa8c7077817d12dc231ff030a776 --- /dev/null +++ b/app/projects/new/page.tsx @@ -0,0 +1,5 @@ +import { AppEditor } from "@/components/editor"; + +export default function NewProjectPage() { + return ; +} diff --git a/assets/globals.css b/assets/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..9b6618f9d91f8066c44b1ce2608d9af665f7d42f --- /dev/null +++ b/assets/globals.css @@ -0,0 +1,371 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-inter-sans); + --font-mono: var(--font-ptSans-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +body { + @apply scroll-smooth +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply scroll-smooth; + } +} + +.background__noisy { + @apply bg-blend-normal pointer-events-none opacity-90; + background-size: 25ww auto; + background-image: url("/background_noisy.webp"); + @apply fixed w-screen h-screen -z-1 top-0 left-0; +} + +.monaco-editor .margin { + @apply !bg-neutral-900; +} +.monaco-editor .monaco-editor-background { + @apply !bg-neutral-900; +} +.monaco-editor .line-numbers { + @apply !text-neutral-500; +} + +.matched-line { + @apply bg-sky-500/30; +} + +/* Fast liquid deformation animations */ +@keyframes liquidBlob1 { + 0%, 100% { + border-radius: 40% 60% 50% 50%; + transform: scaleX(1) scaleY(1) rotate(0deg); + } + 12.5% { + border-radius: 20% 80% 70% 30%; + transform: scaleX(1.6) scaleY(0.4) rotate(25deg); + } + 25% { + border-radius: 80% 20% 30% 70%; + transform: scaleX(0.5) scaleY(2.1) rotate(-15deg); + } + 37.5% { + border-radius: 30% 70% 80% 20%; + transform: scaleX(1.8) scaleY(0.6) rotate(40deg); + } + 50% { + border-radius: 70% 30% 20% 80%; + transform: scaleX(0.4) scaleY(1.9) rotate(-30deg); + } + 62.5% { + border-radius: 25% 75% 60% 40%; + transform: scaleX(1.5) scaleY(0.7) rotate(55deg); + } + 75% { + border-radius: 75% 25% 40% 60%; + transform: scaleX(0.6) scaleY(1.7) rotate(-10deg); + } + 87.5% { + border-radius: 50% 50% 75% 25%; + transform: scaleX(1.3) scaleY(0.8) rotate(35deg); + } +} + +@keyframes liquidBlob2 { + 0%, 100% { + border-radius: 60% 40% 50% 50%; + transform: scaleX(1) scaleY(1) rotate(12deg); + } + 16% { + border-radius: 15% 85% 60% 40%; + transform: scaleX(0.3) scaleY(2.3) rotate(50deg); + } + 32% { + border-radius: 85% 15% 25% 75%; + transform: scaleX(2.0) scaleY(0.5) rotate(-20deg); + } + 48% { + border-radius: 30% 70% 85% 15%; + transform: scaleX(0.4) scaleY(1.8) rotate(70deg); + } + 64% { + border-radius: 70% 30% 15% 85%; + transform: scaleX(1.9) scaleY(0.6) rotate(-35deg); + } + 80% { + border-radius: 40% 60% 70% 30%; + transform: scaleX(0.7) scaleY(1.6) rotate(45deg); + } +} + +@keyframes liquidBlob3 { + 0%, 100% { + border-radius: 50% 50% 40% 60%; + transform: scaleX(1) scaleY(1) rotate(0deg); + } + 20% { + border-radius: 10% 90% 75% 25%; + transform: scaleX(2.2) scaleY(0.3) rotate(-45deg); + } + 40% { + border-radius: 90% 10% 20% 80%; + transform: scaleX(0.4) scaleY(2.5) rotate(60deg); + } + 60% { + border-radius: 25% 75% 90% 10%; + transform: scaleX(1.7) scaleY(0.5) rotate(-25deg); + } + 80% { + border-radius: 75% 25% 10% 90%; + transform: scaleX(0.6) scaleY(2.0) rotate(80deg); + } +} + +@keyframes liquidBlob4 { + 0%, 100% { + border-radius: 45% 55% 50% 50%; + transform: scaleX(1) scaleY(1) rotate(-15deg); + } + 14% { + border-radius: 90% 10% 65% 35%; + transform: scaleX(0.2) scaleY(2.8) rotate(35deg); + } + 28% { + border-radius: 10% 90% 20% 80%; + transform: scaleX(2.4) scaleY(0.4) rotate(-50deg); + } + 42% { + border-radius: 35% 65% 90% 10%; + transform: scaleX(0.3) scaleY(2.1) rotate(70deg); + } + 56% { + border-radius: 80% 20% 10% 90%; + transform: scaleX(2.0) scaleY(0.5) rotate(-40deg); + } + 70% { + border-radius: 20% 80% 55% 45%; + transform: scaleX(0.5) scaleY(1.9) rotate(55deg); + } + 84% { + border-radius: 65% 35% 80% 20%; + transform: scaleX(1.6) scaleY(0.6) rotate(-25deg); + } +} + +/* Fast flowing movement animations */ +@keyframes liquidFlow1 { + 0%, 100% { transform: translate(0, 0); } + 16% { transform: translate(60px, -40px); } + 32% { transform: translate(-45px, -70px); } + 48% { transform: translate(80px, 25px); } + 64% { transform: translate(-30px, 60px); } + 80% { transform: translate(50px, -20px); } +} + +@keyframes liquidFlow2 { + 0%, 100% { transform: translate(0, 0); } + 20% { transform: translate(-70px, 50px); } + 40% { transform: translate(90px, -30px); } + 60% { transform: translate(-40px, -55px); } + 80% { transform: translate(65px, 35px); } +} + +@keyframes liquidFlow3 { + 0%, 100% { transform: translate(0, 0); } + 12% { transform: translate(-50px, -60px); } + 24% { transform: translate(40px, -20px); } + 36% { transform: translate(-30px, 70px); } + 48% { transform: translate(70px, 20px); } + 60% { transform: translate(-60px, -35px); } + 72% { transform: translate(35px, 55px); } + 84% { transform: translate(-25px, -45px); } +} + +@keyframes liquidFlow4 { + 0%, 100% { transform: translate(0, 0); } + 14% { transform: translate(50px, 60px); } + 28% { transform: translate(-80px, -40px); } + 42% { transform: translate(30px, -90px); } + 56% { transform: translate(-55px, 45px); } + 70% { transform: translate(75px, -25px); } + 84% { transform: translate(-35px, 65px); } +} + +/* Light sweep animation for buttons */ +@keyframes lightSweep { + 0% { + transform: translateX(-150%); + opacity: 0; + } + 8% { + opacity: 0.3; + } + 25% { + opacity: 0.8; + } + 42% { + opacity: 0.3; + } + 50% { + transform: translateX(150%); + opacity: 0; + } + 58% { + opacity: 0.3; + } + 75% { + opacity: 0.8; + } + 92% { + opacity: 0.3; + } + 100% { + transform: translateX(-150%); + opacity: 0; + } +} + +.light-sweep { + position: relative; + overflow: hidden; +} + +.light-sweep::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 300%; + background: linear-gradient( + 90deg, + transparent 0%, + transparent 20%, + rgba(56, 189, 248, 0.1) 35%, + rgba(56, 189, 248, 0.2) 45%, + rgba(255, 255, 255, 0.2) 50%, + rgba(168, 85, 247, 0.2) 55%, + rgba(168, 85, 247, 0.1) 65%, + transparent 80%, + transparent 100% + ); + animation: lightSweep 7s cubic-bezier(0.4, 0, 0.2, 1) infinite; + pointer-events: none; + z-index: 1; + filter: blur(1px); +} diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..e69f057d4d4c256f02881888e781aa0943010c3e --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/space.svg b/assets/space.svg new file mode 100644 index 0000000000000000000000000000000000000000..f133cf120bb1f4fe43c949d099965ae9a84db240 --- /dev/null +++ b/assets/space.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/components.json b/components.json new file mode 100644 index 0000000000000000000000000000000000000000..8854f1e2cb22a6949612c72de37b6d9a57489b85 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "assets/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/animated-blobs/index.tsx b/components/animated-blobs/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..516c36cf8ac5dd62a5f293b405bf3b2c480cb78f --- /dev/null +++ b/components/animated-blobs/index.tsx @@ -0,0 +1,34 @@ +export function AnimatedBlobs() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/components/animated-text/index.tsx b/components/animated-text/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1bfef666235566c3f8fec1872b30ff5bb55b3168 --- /dev/null +++ b/components/animated-text/index.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface AnimatedTextProps { + className?: string; +} + +export function AnimatedText({ className = "" }: AnimatedTextProps) { + const [displayText, setDisplayText] = useState(""); + const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0); + const [isTyping, setIsTyping] = useState(true); + const [showCursor, setShowCursor] = useState(true); + const [lastTypedIndex, setLastTypedIndex] = useState(-1); + const [animationComplete, setAnimationComplete] = useState(false); + + // Randomize suggestions on each component mount + const [suggestions] = useState(() => { + const baseSuggestions = [ + "create a stunning portfolio!", + "build a tic tac toe game!", + "design a website for my restaurant!", + "make a sleek landing page!", + "build an e-commerce store!", + "create a personal blog!", + "develop a modern dashboard!", + "design a company website!", + "build a todo app!", + "create an online gallery!", + "make a contact form!", + "build a weather app!", + ]; + + // Fisher-Yates shuffle algorithm + const shuffled = [...baseSuggestions]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + return shuffled; + }); + + useEffect(() => { + if (animationComplete) return; + + let timeout: NodeJS.Timeout; + + const typeText = () => { + const currentSuggestion = suggestions[currentSuggestionIndex]; + + if (isTyping) { + if (displayText.length < currentSuggestion.length) { + setDisplayText(currentSuggestion.slice(0, displayText.length + 1)); + setLastTypedIndex(displayText.length); + timeout = setTimeout(typeText, 80); + } else { + // Finished typing, wait then start erasing + setLastTypedIndex(-1); + timeout = setTimeout(() => { + setIsTyping(false); + }, 2000); + } + } + }; + + timeout = setTimeout(typeText, 100); + return () => clearTimeout(timeout); + }, [ + displayText, + currentSuggestionIndex, + isTyping, + suggestions, + animationComplete, + ]); + + // Cursor blinking effect + useEffect(() => { + if (animationComplete) { + setShowCursor(false); + return; + } + + const cursorInterval = setInterval(() => { + setShowCursor((prev) => !prev); + }, 600); + + return () => clearInterval(cursorInterval); + }, [animationComplete]); + + useEffect(() => { + if (lastTypedIndex >= 0) { + const timeout = setTimeout(() => { + setLastTypedIndex(-1); + }, 400); + + return () => clearTimeout(timeout); + } + }, [lastTypedIndex]); + + return ( +

+ Hey DeepSite,  + {displayText.split("").map((char, index) => ( + + {char} + + ))} + + | + +

+ ); +} diff --git a/components/contexts/app-context.tsx b/components/contexts/app-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..422de8b2a2b17d1eed4bfa58345237ddd252d8a4 --- /dev/null +++ b/components/contexts/app-context.tsx @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; +import { useMount } from "react-use"; +import { toast } from "sonner"; +import { usePathname, useRouter } from "next/navigation"; + +import { useUser } from "@/hooks/useUser"; +import { User } from "@/types"; +import { useBroadcastChannel } from "@/lib/useBroadcastChannel"; + +export default function AppContext({ + children, + me: initialData, +}: { + children: React.ReactNode; + me?: { + user: User | null; + errCode: number | null; + }; +}) { + const { loginFromCode, user, logout, loading, errCode } = + useUser(initialData); + const pathname = usePathname(); + const router = useRouter(); + + useMount(() => { + if (!initialData?.user && !user) { + if ([401, 403].includes(errCode as number)) { + logout(); + } else if (pathname.includes("/spaces")) { + if (errCode) { + toast.error("An error occured while trying to log in"); + } + // If we did not manage to log in (probs because api is down), we simply redirect to the home page + router.push("/"); + } + } + }); + + const events: any = {}; + + useBroadcastChannel("auth", (message) => { + if (pathname.includes("/auth/callback")) return; + + if (!message.code) return; + if (message.type === "user-oauth" && message?.code && !events.code) { + loginFromCode(message.code); + } + }); + + return children; +} diff --git a/components/contexts/login-context.tsx b/components/contexts/login-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b05e79f1adb8ce7c3bec4cce0df60ac0dfe3ede6 --- /dev/null +++ b/components/contexts/login-context.tsx @@ -0,0 +1,61 @@ +"use client"; + +import React, { createContext, useContext, useState, ReactNode } from "react"; +import { LoginModal } from "@/components/login-modal"; +import { Page } from "@/types"; + +interface LoginContextType { + isOpen: boolean; + openLoginModal: (options?: LoginModalOptions) => void; + closeLoginModal: () => void; +} + +interface LoginModalOptions { + pages?: Page[]; + title?: string; + description?: string; +} + +const LoginContext = createContext(undefined); + +export function LoginProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const [modalOptions, setModalOptions] = useState({}); + + const openLoginModal = (options: LoginModalOptions = {}) => { + setModalOptions(options); + setIsOpen(true); + }; + + const closeLoginModal = () => { + setIsOpen(false); + setModalOptions({}); + }; + + const value = { + isOpen, + openLoginModal, + closeLoginModal, + }; + + return ( + + {children} + + + ); +} + +export function useLoginModal() { + const context = useContext(LoginContext); + if (context === undefined) { + throw new Error("useLoginModal must be used within a LoginProvider"); + } + return context; +} diff --git a/components/contexts/pro-context.tsx b/components/contexts/pro-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ec7c0fc0443594232efe097d1c4e7dec455eeb0d --- /dev/null +++ b/components/contexts/pro-context.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React, { createContext, useContext, useState, ReactNode } from "react"; +import { ProModal } from "@/components/pro-modal"; +import { Page } from "@/types"; +import { useEditor } from "@/hooks/useEditor"; + +interface ProContextType { + isOpen: boolean; + openProModal: (pages: Page[]) => void; + closeProModal: () => void; +} + +const ProContext = createContext(undefined); + +export function ProProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const { pages } = useEditor(); + + const openProModal = () => { + setIsOpen(true); + }; + + const closeProModal = () => { + setIsOpen(false); + }; + + const value = { + isOpen, + openProModal, + closeProModal, + }; + + return ( + + {children} + + + ); +} + +export function useProModal() { + const context = useContext(ProContext); + if (context === undefined) { + throw new Error("useProModal must be used within a ProProvider"); + } + return context; +} diff --git a/components/contexts/tanstack-query-context.tsx b/components/contexts/tanstack-query-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0c214de91a6ce2a9711eb70e96e79152ddee4bb --- /dev/null +++ b/components/contexts/tanstack-query-context.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useState } from "react"; + +export default function TanstackContext({ + children, +}: { + children: React.ReactNode; +}) { + // Create QueryClient instance only once using useState with a function + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + }, + }, + }) + ); + + return ( + + {children} + + + ); +} diff --git a/components/contexts/user-context.tsx b/components/contexts/user-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a3391744618bfcfc979401cdee76051c70fee8f --- /dev/null +++ b/components/contexts/user-context.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { createContext } from "react"; +import { User } from "@/types"; + +export const UserContext = createContext({ + user: undefined as User | undefined, +}); diff --git a/components/editor/ask-ai/index.tsx b/components/editor/ask-ai/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..997c8b7b0b8e422b41f2cfcd96b94485018a0c55 --- /dev/null +++ b/components/editor/ask-ai/index.tsx @@ -0,0 +1,259 @@ +import { useMemo, useState } from "react"; +import classNames from "classnames"; +import { + ArrowUp, + CircleStop, + Pause, + Plus, + Square, + StopCircle, +} from "lucide-react"; +import { useLocalStorage } from "react-use"; +import { toast } from "sonner"; + +import { useAi } from "@/hooks/useAi"; +import { useEditor } from "@/hooks/useEditor"; +import { isTheSameHtml } from "@/lib/compare-html-diff"; +import { EnhancedSettings, Project } from "@/types"; +import { SelectedFiles } from "@/components/editor/ask-ai/selected-files"; +import { SelectedHtmlElement } from "@/components/editor/ask-ai/selected-html-element"; +import { AiLoading } from "@/components/editor/ask-ai/loading"; +import { Button } from "@/components/ui/button"; +import { Uploader } from "@/components/editor/ask-ai/uploader"; +import { ReImagine } from "@/components/editor/ask-ai/re-imagine"; +import { Selector } from "@/components/editor/ask-ai/selector"; +import { PromptBuilder } from "@/components/editor/ask-ai/prompt-builder"; +import { useUser } from "@/hooks/useUser"; +import { useLoginModal } from "@/components/contexts/login-context"; +import { Settings } from "./settings"; +import { useProModal } from "@/components/contexts/pro-context"; +import { MODELS } from "@/lib/providers"; + +export const AskAi = ({ + project, + isNew, + onScrollToBottom, +}: { + project?: Project; + files?: string[]; + isNew?: boolean; + onScrollToBottom?: () => void; +}) => { + const { user } = useUser(); + const { currentPageData, isUploading, pages, isLoadingProject } = useEditor(); + const { + isAiWorking, + isThinking, + selectedFiles, + setSelectedFiles, + selectedElement, + setSelectedElement, + setIsThinking, + callAiNewProject, + callAiFollowUp, + setModel, + selectedModel, + audio: hookAudio, + cancelRequest, + } = useAi(onScrollToBottom); + const { openLoginModal } = useLoginModal(); + const { openProModal } = useProModal(); + const [openProvider, setOpenProvider] = useState(false); + const [providerError, setProviderError] = useState(""); + + const [enhancedSettings, setEnhancedSettings, removeEnhancedSettings] = + useLocalStorage("deepsite-enhancedSettings", { + isActive: true, + primaryColor: undefined, + secondaryColor: undefined, + theme: undefined, + }); + + const [isFollowUp, setIsFollowUp] = useState(true); + const [prompt, setPrompt] = useState(""); + const [think, setThink] = useState(""); + const [openThink, setOpenThink] = useState(false); + + const isSameHtml = useMemo(() => { + return isTheSameHtml(currentPageData.html); + }, [currentPageData.html]); + + const handleThink = (think: string) => { + setThink(think); + setIsThinking(true); + setOpenThink(true); + }; + + const callAi = async (redesignMarkdown?: string) => { + if (!user) return openLoginModal(); + if (isAiWorking) return; + if (!redesignMarkdown && !prompt.trim()) return; + + if (isFollowUp && !redesignMarkdown && !isSameHtml) { + const result = await callAiFollowUp(prompt, enhancedSettings); + + if (result?.error) { + handleError(result.error, result.message); + return; + } + + if (result?.success) { + setPrompt(""); + } + } else { + const result = await callAiNewProject( + prompt, + enhancedSettings, + redesignMarkdown, + handleThink, + () => { + setIsThinking(false); + } + ); + + if (result?.error) { + handleError(result.error, result.message); + return; + } + + if (result?.success) { + setPrompt(""); + if (selectedModel?.isThinker) { + setModel(MODELS[0].value); + } + } + } + }; + + const handleError = (error: string, message?: string) => { + switch (error) { + case "login_required": + openLoginModal(); + break; + case "provider_required": + setOpenProvider(true); + setProviderError(message || ""); + break; + case "pro_required": + openProModal([]); + break; + case "api_error": + toast.error(message || "An error occurred"); + break; + case "network_error": + toast.error(message || "Network error occurred"); + break; + default: + toast.error("An unexpected error occurred"); + } + }; + + return ( +
+
+ + setSelectedFiles(selectedFiles.filter((f) => f !== file)) + } + /> + {selectedElement && ( +
+ setSelectedElement(null)} + /> +
+ )} +
+ {(isAiWorking || isUploading || isThinking) && ( +
+ + {isAiWorking && ( + + )} +
+ )} +