diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5ef6a520780202a1d6addd833d800ccb1ecac0bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..cbe0188aaee92186937765d2c85d76f7b212c537 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine +USER root + +USER 1000 +WORKDIR /usr/src/app +# Copy package.json and package-lock.json to the container +COPY --chown=1000 package.json package-lock.json ./ + +# Copy the rest of the application files to the container +COPY --chown=1000 . . + +RUN npm install +RUN npm run build + +# Expose the application port (assuming your app runs on port 3000) +EXPOSE 3000 + +# Start the application +CMD ["npm", "start"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..12f9f1029000356fe03939f0e2eb635631e90136 Binary files /dev/null and b/README.md differ diff --git a/app/(public)/layout.tsx b/app/(public)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4a4ec57d2609c783602beb6c06c8dca6a1e6192d --- /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..c0849e72cf29027524ec9ebc3818e80a8aee5ef3 --- /dev/null +++ b/app/(public)/page.tsx @@ -0,0 +1,44 @@ +import { AskAi } from "@/components/space/ask-ai"; +import { redirect } from "next/navigation"; +export default function Home() { + redirect("/projects/new"); + return ( + <> +
+
+ ✨ DeepSite Public Beta +
+

+ Code your website with AI in seconds +

+

+ Vibe Coding has never been so easy. +

+
+ +
+
+
+
+
+
+
+
+
+

+ Community Driven +

+
+
+

+ Deploy your website in seconds +

+
+
+

+ Features that make you smile +

+
+ + ); +} diff --git a/app/(public)/projects/page.tsx b/app/(public)/projects/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..374dc6b1194256c5b142a62168ce0f414f6098be --- /dev/null +++ b/app/(public)/projects/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +import { MyProjects } from "@/components/my-projects"; +import { getProjects } from "@/app/actions/projects"; + +export default async function ProjectsPage() { + const { ok, projects } = await getProjects(); + if (!ok) { + redirect("/"); + } + + 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..209b16d9d9960eeafb9e0b02d7b1b3eda638338d --- /dev/null +++ b/app/actions/projects.ts @@ -0,0 +1,63 @@ +"use server"; + +import { isAuthenticated } from "@/lib/auth"; +import { NextResponse } from "next/server"; +import dbConnect from "@/lib/mongodb"; +import Project from "@/models/Project"; +import { Project as ProjectType } from "@/types"; + +export async function getProjects(): Promise<{ + ok: boolean; + projects: ProjectType[]; +}> { + 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: false, + projects: [], + }; + } + return { + ok: true, + projects: JSON.parse(JSON.stringify(projects)) as ProjectType[], + }; +} + +export async function getProject( + namespace: string, + repoId: string +): Promise { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return null; + } + + await dbConnect(); + const project = await Project.findOne({ + user_id: user.id, + namespace, + repoId, + }).lean(); + + if (!project) { + return null; + } + + return JSON.parse(JSON.stringify(project)) as ProjectType; +} diff --git a/app/actions/rewrite-prompt.ts b/app/actions/rewrite-prompt.ts new file mode 100644 index 0000000000000000000000000000000000000000..08bec54038b85c24c3f0d993ec388344d054fd09 --- /dev/null +++ b/app/actions/rewrite-prompt.ts @@ -0,0 +1,35 @@ +import { InferenceClient } from "@huggingface/inference"; + +const START_REWRITE_PROMPT = ">>>>>>> START PROMPT >>>>>>"; +const END_REWRITE_PROMPT = ">>>>>>> END PROMPT >>>>>>"; + +export const callAiRewritePrompt = async (prompt: string, { token, billTo }: { token: string, billTo?: string | null }) => { + const client = new InferenceClient(token); + const response = await client.chatCompletion( + { + model: "deepseek-ai/DeepSeek-V3.1", + provider: "novita", + messages: [{ + role: "system", + content: `You are a helpful assistant that rewrites prompts to make them better. All the prompts will be about creating a website or app. +Try to make the prompt more detailed and specific to create a good UI/UX Design and good code. +Format the result by following this format: +${START_REWRITE_PROMPT} +new prompt here +${END_REWRITE_PROMPT} +If you don't rewrite the prompt, return the original prompt. +Make sure to return the prompt in the same language as the prompt you are given. Also IMPORTANT: Make sure to keep the original intent of the prompt. Improve it it needed, but don't change the original intent. +` + },{ role: "user", content: prompt }], + }, + billTo ? { billTo } : {} + ); + + const responseContent = response.choices[0]?.message?.content; + if (!responseContent) { + return prompt; + } + const startIndex = responseContent.indexOf(START_REWRITE_PROMPT); + const endIndex = responseContent.indexOf(END_REWRITE_PROMPT); + return responseContent.substring(startIndex + START_REWRITE_PROMPT.length, endIndex); +}; \ No newline at end of file diff --git a/app/api/ask-ai/route.ts b/app/api/ask-ai/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..25a34108f99e8e3d0c6ccc00f345055445abcb47 --- /dev/null +++ b/app/api/ask-ai/route.ts @@ -0,0 +1,510 @@ +/* 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"; + +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, previousPrompts, pages } = 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 DEFAULT_PROVIDER = PROVIDERS.novita; + const selectedProvider = + provider === "auto" + ? PROVIDERS[selectedModel.autoProvider as keyof typeof PROVIDERS] + : PROVIDERS[provider as keyof typeof PROVIDERS] ?? DEFAULT_PROVIDER; + + const rewrittenPrompt = prompt; + + // if (prompt?.length < 240) { + + //rewrittenPrompt = await callAiRewritePrompt(prompt, { token, billTo }); + // } + + 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.id as any, + messages: [ + { + role: "system", + content: INITIAL_SYSTEM_PROMPT, + }, + ...(pages?.length > 1 ? [{ + role: "assistant", + content: `Here are the current pages:\n\n${pages.map((p: Page) => `- ${p.path} \n${p.html}`).join("\n")}\n\nNow, please create a new page based on this code. Also here are the previous prompts:\n\n${previousPrompts.map((p: string) => `- ${p}`).join("\n")}` + }] : []), + { + role: "user", + content: redesignMarkdown + ? `Here is my current design as a markdown:\n\n${redesignMarkdown}\n\nNow, please create a new design based on this markdown.` + : rewrittenPrompt, + }, + ], + 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)); + } + } + } 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 { + await writer.write( + encoder.encode( + JSON.stringify({ + ok: false, + message: + error.message || + "An error occurred while processing your request.", + }) + ) + ); + } + } finally { + await writer?.close(); + } + })(); + + 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 authHeaders = await headers(); + const userToken = request.cookies.get(MY_TOKEN_KEY())?.value; + + const body = await request.json(); + const { prompt, previousPrompts, provider, selectedElementHtml, model, pages, files, } = + body; + + if (!prompt || pages.length === 0) { + 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 = 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 client = new InferenceClient(token); + + const DEFAULT_PROVIDER = PROVIDERS.novita; + const selectedProvider = + provider === "auto" + ? PROVIDERS[selectedModel.autoProvider as keyof typeof PROVIDERS] + : PROVIDERS[provider as keyof typeof PROVIDERS] ?? DEFAULT_PROVIDER; + + try { + const response = await client.chatCompletion( + { + model: selectedModel.value, + provider: selectedProvider.id as any, + 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; + } + } + + 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]/images/route.ts b/app/api/me/projects/[namespace]/[repoId]/images/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ab46939bdde0aaeb7163af0e96e94651bfccfd7 --- /dev/null +++ b/app/api/me/projects/[namespace]/[repoId]/images/route.ts @@ -0,0 +1,111 @@ +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"; + +// No longer need the ImageUpload interface since we're handling FormData with File objects + +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..feecdd6215cc28e8bf952448636d781dff372d5a --- /dev/null +++ b/app/api/me/projects/[namespace]/[repoId]/route.ts @@ -0,0 +1,276 @@ +import { NextRequest, NextResponse } from "next/server"; +import { RepoDesignation, spaceInfo, uploadFiles, listFiles } from "@huggingface/hub"; + +import { isAuthenticated } from "@/lib/auth"; +import Project from "@/models/Project"; +import dbConnect from "@/lib/mongodb"; +import { Page } from "@/types"; + +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 } + ); + } + + const repo: RepoDesignation = { + type: "space", + name: `${namespace}/${repoId}`, + }; + + const htmlFiles: Page[] = []; + const images: string[] = []; + + const allowedImagesExtensions = ["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 (allowedImagesExtensions.includes(imageInfo.path.split(".").pop() || "")) { + images.push(`https://huggingface.co/spaces/${namespace}/${repoId}/resolve/main/${imageInfo.path}`); + } + } + } + } + + if (htmlFiles.length === 0) { + return NextResponse.json( + { + ok: false, + error: "No HTML files found", + }, + { status: 404 } + ); + } + + return NextResponse.json( + { + project: { + ...project, + pages: htmlFiles, + images, + }, + 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 } + ); + } +} + +export async function PUT( + 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 { pages, prompts } = await req.json(); + + 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 } + ); + } + + const repo: RepoDesignation = { + type: "space", + name: `${namespace}/${repoId}`, + }; + + const files: File[] = []; + const promptsFile = new File([prompts.join("\n")], "prompts.txt", { + type: "text/plain", + }); + files.push(promptsFile); + pages.forEach((page: Page) => { + const file = new File([page.html], page.path, { type: "text/html" }); + files.push(file); + }); + await uploadFiles({ + repo, + files, + accessToken: user.token as string, + commitTitle: `${prompts[prompts.length - 1]} - Follow Up Deployment`, + }); + + await Project.updateOne( + { user_id: user.id, space_id: `${namespace}/${repoId}` }, + { + $set: { + prompts: [ + ...prompts, + ], + }, + } + ); + return NextResponse.json({ ok: true }, { status: 200 }); +} + +export async function POST( + 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 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 } + ); + } + + const project = await Project.findOne({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + }).lean(); + if (project) { + // redirect to the project page if it already exists + return NextResponse.json( + { + ok: false, + error: "Project already exists", + redirect: `/projects/${namespace}/${repoId}`, + }, + { status: 400 } + ); + } + + const newProject = new Project({ + user_id: user.id, + space_id: `${namespace}/${repoId}`, + prompts: [], + }); + + await newProject.save(); + return NextResponse.json( + { + ok: true, + project: { + id: newProject._id, + space_id: newProject.space_id, + prompts: newProject.prompts, + }, + }, + { status: 201 } + ); +} diff --git a/app/api/me/projects/route.ts b/app/api/me/projects/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc3a3907101a1c7c79a7e1968b508936fa0668e4 --- /dev/null +++ b/app/api/me/projects/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createRepo, RepoDesignation, uploadFiles } from "@huggingface/hub"; + +import { isAuthenticated } from "@/lib/auth"; +import Project from "@/models/Project"; +import dbConnect from "@/lib/mongodb"; +import { COLORS } from "@/lib/utils"; +import { Page } from "@/types"; + +export async function GET() { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + await dbConnect(); + + const projects = await Project.find({ + user_id: user?.id, + }) + .sort({ _createdAt: -1 }) + .limit(100) + .lean(); + if (!projects) { + return NextResponse.json( + { + ok: false, + projects: [], + }, + { status: 404 } + ); + } + return NextResponse.json( + { + ok: true, + projects, + }, + { status: 200 } + ); +} + +export async function POST(request: NextRequest) { + const user = await isAuthenticated(); + + if (user instanceof NextResponse || !user) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const { title, pages, prompts } = await request.json(); + + if (!title || !pages || pages.length === 0) { + return NextResponse.json( + { message: "Title and HTML content are required.", ok: false }, + { status: 400 } + ); + } + + await dbConnect(); + + try { + let readme = ""; + + const newTitle = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .split("-") + .filter(Boolean) + .join("-") + .slice(0, 96); + + const repo: RepoDesignation = { + type: "space", + name: `${user.name}/${newTitle}`, + }; + + const { repoUrl } = await createRepo({ + repo, + accessToken: user.token as string, + }); + const colorFrom = COLORS[Math.floor(Math.random() * COLORS.length)]; + const colorTo = COLORS[Math.floor(Math.random() * COLORS.length)]; + readme = `--- +title: ${newTitle} +emoji: 🐳 +colorFrom: ${colorFrom} +colorTo: ${colorTo} +sdk: static +pinned: false +tags: + - deepsite +--- + +Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference`; + + const readmeFile = new File([readme], "README.md", { + type: "text/markdown", + }); + const promptsFile = new File([prompts.join("\n")], "prompts.txt", { + type: "text/plain", + }); + const files = [readmeFile, promptsFile]; + pages.forEach((page: Page) => { + const file = new File([page.html], page.path, { type: "text/html" }); + files.push(file); + }); + await uploadFiles({ + repo, + files, + accessToken: user.token as string, + commitTitle: `${prompts[prompts.length - 1]} - Initial Deployment`, + }); + const path = repoUrl.split("/").slice(-2).join("/"); + const project = await Project.create({ + user_id: user.id, + space_id: path, + prompts, + }); + return NextResponse.json({ project, path, ok: true }, { status: 201 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return NextResponse.json( + { error: err.message, ok: false }, + { status: 500 } + ); + } +} 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/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..ac44f365b707908d6003a4266db1269d297a4336 --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,72 @@ +"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"; +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 // Show button after 5 seconds + ); + + 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/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d960a121272f5b976ae70dbd1c7656e181ca6e6d --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,112 @@ +/* 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 TanstackProvider from "@/components/providers/tanstack-query-provider"; +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 AppContext from "@/components/contexts/app-context"; +import Script from "next/script"; +import IframeDetector from "@/components/iframe-detector"; + +const inter = Inter({ + variable: "--font-inter-sans", + subsets: ["latin"], +}); + +const ptSans = PT_Sans({ + variable: "--font-ptSans-mono", + subsets: ["latin"], + weight: ["400", "700"], +}); + +export const metadata: Metadata = { + 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", +}; + +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 }; + } +} + +// if domain isn't deepsite.hf.co or enzostvs-deepsite.hf.space redirect to deepsite.hf.co + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const data = await getMe(); + return ( + + + + + + + {children} + + + + ); +} diff --git a/app/projects/[namespace]/[repoId]/page.tsx b/app/projects/[namespace]/[repoId]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e60c46e774de73ea30c6964ae49c5a32daf6c87d --- /dev/null +++ b/app/projects/[namespace]/[repoId]/page.tsx @@ -0,0 +1,42 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +import { apiServer } from "@/lib/api"; +import MY_TOKEN_KEY from "@/lib/get-cookie-name"; +import { AppEditor } from "@/components/editor"; + +async function getProject(namespace: string, repoId: string) { + // TODO replace with a server action + const cookieStore = await cookies(); + const token = cookieStore.get(MY_TOKEN_KEY())?.value; + if (!token) return {}; + try { + const { data } = await apiServer.get( + `/me/projects/${namespace}/${repoId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + return data.project; + } catch { + return {}; + } +} + +export default async function ProjectNamespacePage({ + params, +}: { + params: Promise<{ namespace: string; repoId: string }>; +}) { + const { namespace, repoId } = await params; + const data = await getProject(namespace, repoId); + if (!data?.pages) { + redirect("/projects"); + } + return ( + + ); +} diff --git a/app/projects/new/page.tsx b/app/projects/new/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9dabea0dcf9de7c30b66ff35588740ed59de3f0e --- /dev/null +++ b/app/projects/new/page.tsx @@ -0,0 +1,5 @@ +import { AppEditor } from "@/components/editor"; + +export default function ProjectsNewPage() { + return ; +} diff --git a/assets/globals.css b/assets/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..19dd59e7dcc34e453e9850a052ae8f039628e58a --- /dev/null +++ b/assets/globals.css @@ -0,0 +1,146 @@ +@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); +} + +@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; +} 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..335484f9424bf72b98e3b892275740bc8f014754 --- /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": "app/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/contexts/app-context.tsx b/components/contexts/app-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a97820d9e26fa6197ef583ce88aba66a3bc10082 --- /dev/null +++ b/components/contexts/app-context.tsx @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +"use client"; + +import { useUser } from "@/hooks/useUser"; +import { usePathname, useRouter } from "next/navigation"; +import { useMount } from "react-use"; +import { UserContext } from "@/components/contexts/user-context"; +import { User } from "@/types"; +import { toast } from "sonner"; +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/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/follow-up-tooltip.tsx b/components/editor/ask-ai/follow-up-tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ebb4a29de5de5cca175eb6795f1d069be6ba02b --- /dev/null +++ b/components/editor/ask-ai/follow-up-tooltip.tsx @@ -0,0 +1,36 @@ +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Info } from "lucide-react"; + +export const FollowUpTooltip = () => { + return ( + + + + + +
+

+ ⚡ Faster, Smarter Updates +

+
+
+

+ Using the Diff-Patch system, allow DeepSite to intelligently update + your project without rewritting the entire codebase. +

+

+ This means faster updates, less data usage, and a more efficient + development process. +

+
+
+
+ ); +}; diff --git a/components/editor/ask-ai/index.tsx b/components/editor/ask-ai/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2076173a327338cdc34a3d8c66c22cc3e72bc408 --- /dev/null +++ b/components/editor/ask-ai/index.tsx @@ -0,0 +1,500 @@ +"use client"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useState, useMemo, useRef } from "react"; +import classNames from "classnames"; +import { toast } from "sonner"; +import { useLocalStorage, useUpdateEffect } from "react-use"; +import { ArrowUp, ChevronDown, Crosshair } from "lucide-react"; +import { FaStopCircle } from "react-icons/fa"; + +import ProModal from "@/components/pro-modal"; +import { Button } from "@/components/ui/button"; +import { MODELS } from "@/lib/providers"; +import { HtmlHistory, Page, Project } from "@/types"; +// import { InviteFriends } from "@/components/invite-friends"; +import { Settings } from "@/components/editor/ask-ai/settings"; +import { LoginModal } from "@/components/login-modal"; +import { ReImagine } from "@/components/editor/ask-ai/re-imagine"; +import Loading from "@/components/loading"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip"; +import { TooltipContent } from "@radix-ui/react-tooltip"; +import { SelectedHtmlElement } from "./selected-html-element"; +import { FollowUpTooltip } from "./follow-up-tooltip"; +import { isTheSameHtml } from "@/lib/compare-html-diff"; +import { useCallAi } from "@/hooks/useCallAi"; +import { SelectedFiles } from "./selected-files"; +import { Uploader } from "./uploader"; + +export function AskAI({ + isNew, + project, + images, + currentPage, + previousPrompts, + onScrollToBottom, + isAiWorking, + setisAiWorking, + isEditableModeEnabled = false, + pages, + htmlHistory, + selectedElement, + setSelectedElement, + selectedFiles, + setSelectedFiles, + setIsEditableModeEnabled, + onNewPrompt, + onSuccess, + setPages, + setCurrentPage, +}: { + project?: Project | null; + currentPage: Page; + images?: string[]; + pages: Page[]; + onScrollToBottom: () => void; + previousPrompts: string[]; + isAiWorking: boolean; + onNewPrompt: (prompt: string) => void; + htmlHistory?: HtmlHistory[]; + setisAiWorking: React.Dispatch>; + isNew?: boolean; + onSuccess: (page: Page[], p: string, n?: number[][]) => void; + isEditableModeEnabled: boolean; + setIsEditableModeEnabled: React.Dispatch>; + selectedElement?: HTMLElement | null; + setSelectedElement: React.Dispatch>; + selectedFiles: string[]; + setSelectedFiles: React.Dispatch>; + setPages: React.Dispatch>; + setCurrentPage: React.Dispatch>; +}) { + const refThink = useRef(null); + + const [open, setOpen] = useState(false); + const [prompt, setPrompt] = useState(""); + const [provider, setProvider] = useLocalStorage("provider", "auto"); + const [model, setModel] = useLocalStorage("model", MODELS[0].value); + const [openProvider, setOpenProvider] = useState(false); + const [providerError, setProviderError] = useState(""); + const [openProModal, setOpenProModal] = useState(false); + const [openThink, setOpenThink] = useState(false); + const [isThinking, setIsThinking] = useState(true); + const [think, setThink] = useState(""); + const [isFollowUp, setIsFollowUp] = useState(true); + const [isUploading, setIsUploading] = useState(false); + const [files, setFiles] = useState(images ?? []); + + const { + callAiNewProject, + callAiFollowUp, + callAiNewPage, + stopController, + audio: hookAudio, + } = useCallAi({ + onNewPrompt, + onSuccess, + onScrollToBottom, + setPages, + setCurrentPage, + currentPage, + pages, + isAiWorking, + setisAiWorking, + }); + + const selectedModel = useMemo(() => { + return MODELS.find((m: { value: string }) => m.value === model); + }, [model]); + + const callAi = async (redesignMarkdown?: string) => { + if (isAiWorking) return; + if (!redesignMarkdown && !prompt.trim()) return; + + if (isFollowUp && !redesignMarkdown && !isSameHtml) { + // Use follow-up function for existing projects + const selectedElementHtml = selectedElement + ? selectedElement.outerHTML + : ""; + + const result = await callAiFollowUp( + prompt, + model, + provider, + previousPrompts, + selectedElementHtml, + selectedFiles + ); + + if (result?.error) { + handleError(result.error, result.message); + return; + } + + if (result?.success) { + setPrompt(""); + } + } else if (isFollowUp && pages.length > 1 && isSameHtml) { + const result = await callAiNewPage( + prompt, + model, + provider, + currentPage.path, + [ + ...(previousPrompts ?? []), + ...(htmlHistory?.map((h) => h.prompt) ?? []), + ] + ); + if (result?.error) { + handleError(result.error, result.message); + return; + } + + if (result?.success) { + setPrompt(""); + } + } else { + const result = await callAiNewProject( + prompt, + model, + provider, + 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 handleThink = (think: string) => { + setThink(think); + setIsThinking(true); + setOpenThink(true); + }; + + const handleError = (error: string, message?: string) => { + switch (error) { + case "login_required": + setOpen(true); + break; + case "provider_required": + setOpenProvider(true); + setProviderError(message || ""); + break; + case "pro_required": + setOpenProModal(true); + 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"); + } + }; + + useUpdateEffect(() => { + if (refThink.current) { + refThink.current.scrollTop = refThink.current.scrollHeight; + } + }, [think]); + + useUpdateEffect(() => { + if (!isThinking) { + setOpenThink(false); + } + }, [isThinking]); + + const isSameHtml = useMemo(() => { + return isTheSameHtml(currentPage.html); + }, [currentPage.html]); + + return ( +
+
+ {think && ( +
+
{ + setOpenThink(!openThink); + }} + > +

+ {isThinking ? "DeepSite is thinking..." : "DeepSite's plan"} +

+ +
+
+

+ {think} +

+
+
+ )} + + setSelectedFiles((prev) => prev.filter((f) => f !== file)) + } + /> + {selectedElement && ( +
+ setSelectedElement(null)} + /> +
+ )} +
+ {(isAiWorking || isUploading) && ( +
+
+ +

+ {isUploading ? ( + "Uploading images..." + ) : isAiWorking && !isSameHtml ? ( + "AI is working..." + ) : ( + + {[ + "D", + "e", + "e", + "p", + "S", + "i", + "t", + "e", + " ", + "i", + "s", + " ", + "T", + "h", + "i", + "n", + "k", + "i", + "n", + "g", + ".", + ".", + ".", + " ", + "W", + "a", + "i", + "t", + " ", + "a", + " ", + "m", + "o", + "m", + "e", + "n", + "t", + ".", + ".", + ".", + ].map((char, index) => ( + + {char === " " ? "\u00A0" : char} + + ))} + + )} +

+
+ {isAiWorking && ( +
+ + Stop generation +
+ )} +
+ )} +