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 (
+
+ );
+}
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 (
+
+
+
+
+
+
+
+ 🚀 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 (
+
+
+
+
+
+
+
+
+ If you are not redirected automatically in the next 5 seconds,
+ please click the button below
+
+ {showButton ? (
+
+
+ Go to Home
+
+
+ ) : (
+
+ 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 (
-
-
-
-
-
- Get started by editing{" "}
-
- app/page.tsx
-
- .
-
-
- Save and see your changes instantly.
-
-
-
-
-
-
-
- );
-}
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 && (
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+ {!isNew &&
}
+ {isNew &&
callAi(md)} />}
+ {!isNew && }
+
+
+
+
+
+
+ Your browser does not support the audio element.
+
+
+ );
+};
diff --git a/components/editor/ask-ai/loading.tsx b/components/editor/ask-ai/loading.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4b8310269ede05f05584cf566b97516c2f066dba
--- /dev/null
+++ b/components/editor/ask-ai/loading.tsx
@@ -0,0 +1,32 @@
+import Loading from "@/components/loading";
+
+export const AiLoading = ({
+ text = "Ai is working...",
+ className,
+}: {
+ text?: string;
+ className?: string;
+}) => {
+ return (
+
+
+
+
+ {text.split("").map((char, index) => (
+
+ {char === " " ? "\u00A0" : char}
+
+ ))}
+
+
+
+ );
+};
diff --git a/components/editor/ask-ai/prompt-builder/content-modal.tsx b/components/editor/ask-ai/prompt-builder/content-modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ca15bcc505dba76bedb2df7baec2492987d6002b
--- /dev/null
+++ b/components/editor/ask-ai/prompt-builder/content-modal.tsx
@@ -0,0 +1,196 @@
+import classNames from "classnames";
+import { ChevronRight, RefreshCcw } from "lucide-react";
+import { useState } from "react";
+import { TailwindColors } from "./tailwind-colors";
+import { Switch } from "@/components/ui/switch";
+import { EnhancedSettings } from ".";
+import { Button } from "@/components/ui/button";
+import { Themes } from "./themes";
+
+export const ContentModal = ({
+ enhancedSettings,
+ setEnhancedSettings,
+}: {
+ enhancedSettings: EnhancedSettings;
+ setEnhancedSettings: (settings: EnhancedSettings) => void;
+}) => {
+ const [collapsed, setCollapsed] = useState(["colors", "theme"]);
+ return (
+
+
+
+
+ Allow DeepSite to enhance your prompt
+
+
+ setEnhancedSettings({
+ ...enhancedSettings,
+ isActive: !enhancedSettings.isActive,
+ })
+ }
+ />
+
+
+ While using DeepSite enhanced prompt, you'll get better results. We'll
+ add more details and features to your request.
+
+
+
+ You can also use the custom properties below to set specific
+ information.
+
+
+
+
+
+ setCollapsed((prev) => {
+ if (prev.includes("colors")) {
+ return prev.filter((item) => item !== "colors");
+ }
+ return [...prev, "colors"];
+ })
+ }
+ >
+
+
Colors
+
+ {collapsed.includes("colors") && (
+
+
+
+
+ Primary Color
+
+
+ setEnhancedSettings({
+ ...enhancedSettings,
+ primaryColor: undefined,
+ })
+ }
+ >
+
+ Reset
+
+
+
+
+ setEnhancedSettings({
+ ...enhancedSettings,
+ primaryColor: value,
+ })
+ }
+ />
+
+
+
+
+
+ Secondary Color
+
+
+ setEnhancedSettings({
+ ...enhancedSettings,
+ secondaryColor: undefined,
+ })
+ }
+ >
+
+ Reset
+
+
+
+
+ setEnhancedSettings({
+ ...enhancedSettings,
+ secondaryColor: value,
+ })
+ }
+ />
+
+
+
+ )}
+
+
+
+ setCollapsed((prev) => {
+ if (prev.includes("theme")) {
+ return prev.filter((item) => item !== "theme");
+ }
+ return [...prev, "theme"];
+ })
+ }
+ >
+
+
Theme
+
+ {collapsed.includes("theme") && (
+
+
+
+ Theme
+
+
+ setEnhancedSettings({
+ ...enhancedSettings,
+ theme: undefined,
+ })
+ }
+ >
+
+ Reset
+
+
+
+
+ setEnhancedSettings({
+ ...enhancedSettings,
+ theme: value,
+ })
+ }
+ />
+
+
+ )}
+
+
+ );
+};
diff --git a/components/editor/ask-ai/prompt-builder/index.tsx b/components/editor/ask-ai/prompt-builder/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ea147c96d985ba6b1578ce56b0c014c6a36e263b
--- /dev/null
+++ b/components/editor/ask-ai/prompt-builder/index.tsx
@@ -0,0 +1,73 @@
+import { useState } from "react";
+import { WandSparkles } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useEditor } from "@/hooks/useEditor";
+import { useAi } from "@/hooks/useAi";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ContentModal } from "./content-modal";
+import { useLoginModal } from "@/components/contexts/login-context";
+import { useUser } from "@/hooks/useUser";
+import { EnhancedSettings } from "@/types";
+
+export const PromptBuilder = ({
+ enhancedSettings,
+ setEnhancedSettings,
+}: {
+ enhancedSettings: EnhancedSettings;
+ setEnhancedSettings: (settings: EnhancedSettings) => void;
+}) => {
+ const { user } = useUser();
+ const { openLoginModal } = useLoginModal();
+ const { globalAiLoading } = useAi();
+ const { globalEditorLoading } = useEditor();
+
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ {
+ if (!user) return openLoginModal();
+ setOpen(true);
+ }}
+ >
+
+
+ Enhance
+
+
+ setOpen(false)}>
+
+
+
+
+
+
+ setOpen(false)}
+ >
+ Close
+
+
+
+
+ >
+ );
+};
diff --git a/components/editor/ask-ai/prompt-builder/tailwind-colors.tsx b/components/editor/ask-ai/prompt-builder/tailwind-colors.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f04850ea6566bb6c2cabd70edd603b747f2fc120
--- /dev/null
+++ b/components/editor/ask-ai/prompt-builder/tailwind-colors.tsx
@@ -0,0 +1,58 @@
+import classNames from "classnames";
+import { useRef } from "react";
+
+import { TAILWIND_COLORS } from "@/lib/prompt-builder";
+import { useMount } from "react-use";
+
+export const TailwindColors = ({
+ value,
+ onChange,
+}: {
+ value: string | undefined;
+ onChange: (value: string) => void;
+}) => {
+ const ref = useRef(null);
+
+ useMount(() => {
+ if (ref.current) {
+ if (value) {
+ const color = ref.current.querySelector(`[data-color="${value}"]`);
+ if (color) {
+ color.scrollIntoView({ inline: "center" });
+ }
+ }
+ }
+ });
+ return (
+
+ {TAILWIND_COLORS.map((color) => (
+
onChange(color)}
+ >
+
+
+ {color}
+
+
+ ))}
+
+ );
+};
diff --git a/components/editor/ask-ai/prompt-builder/themes.tsx b/components/editor/ask-ai/prompt-builder/themes.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b419e8618153c8fd467dd3018fd521ac8930d487
--- /dev/null
+++ b/components/editor/ask-ai/prompt-builder/themes.tsx
@@ -0,0 +1,48 @@
+import { Theme } from "@/types";
+import classNames from "classnames";
+import { Moon, Sun } from "lucide-react";
+import { useRef } from "react";
+
+export const Themes = ({
+ value,
+ onChange,
+}: {
+ value: Theme;
+ onChange: (value: Theme) => void;
+}) => {
+ const ref = useRef(null);
+
+ return (
+
+
onChange("light")}
+ >
+
+
Light
+
+
onChange("dark")}
+ >
+
+
Dark
+
+
+ );
+};
diff --git a/components/editor/ask-ai/re-imagine.tsx b/components/editor/ask-ai/re-imagine.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..13a26ce2272f2a9a8fba5f98cb94ca4b83cf9eba
--- /dev/null
+++ b/components/editor/ask-ai/re-imagine.tsx
@@ -0,0 +1,169 @@
+import { useState } from "react";
+import { Paintbrush } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Input } from "@/components/ui/input";
+import Loading from "@/components/loading";
+import { api } from "@/lib/api";
+import { useAi } from "@/hooks/useAi";
+import { useEditor } from "@/hooks/useEditor";
+import { useUser } from "@/hooks/useUser";
+import { useLoginModal } from "@/components/contexts/login-context";
+
+export function ReImagine({
+ onRedesign,
+}: {
+ onRedesign: (md: string) => void;
+}) {
+ const [url, setUrl] = useState("");
+ const [open, setOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const { globalAiLoading } = useAi();
+ const { globalEditorLoading } = useEditor();
+ const { user } = useUser();
+ const { openLoginModal } = useLoginModal();
+
+ const checkIfUrlIsValid = (url: string) => {
+ const urlPattern = new RegExp(
+ /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/,
+ "i"
+ );
+ return urlPattern.test(url);
+ };
+
+ const handleClick = async () => {
+ if (isLoading) return; // Prevent multiple clicks while loading
+ if (!url) {
+ toast.error("Please enter a URL.");
+ return;
+ }
+ if (!checkIfUrlIsValid(url)) {
+ toast.error("Please enter a valid URL.");
+ return;
+ }
+ setIsLoading(true);
+ const response = await api.put("/re-design", {
+ url: url.trim(),
+ });
+ if (response?.data?.ok) {
+ setOpen(false);
+ setUrl("");
+ onRedesign(response.data.markdown);
+ toast.success("DeepSite is redesigning your site! Let him cook... 🔥");
+ } else {
+ toast.error(response?.data?.error || "Failed to redesign the site.");
+ }
+ setIsLoading(false);
+ };
+
+ if (!user)
+ return (
+ openLoginModal()}
+ >
+
+ Redesign
+
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/components/editor/ask-ai/selected-files.tsx b/components/editor/ask-ai/selected-files.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fa4901e3254b42fd4f9f7d93f2b4b13eaf31bd70
--- /dev/null
+++ b/components/editor/ask-ai/selected-files.tsx
@@ -0,0 +1,47 @@
+import Image from "next/image";
+
+import { Button } from "@/components/ui/button";
+import { Minus } from "lucide-react";
+
+export const SelectedFiles = ({
+ files,
+ isAiWorking,
+ onDelete,
+}: {
+ files: string[];
+ isAiWorking: boolean;
+ onDelete: (file: string) => void;
+}) => {
+ if (files.length === 0) return null;
+ return (
+
+
+ {files.map((file) => (
+
+
+ onDelete(file)}
+ >
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/components/editor/ask-ai/selected-html-element.tsx b/components/editor/ask-ai/selected-html-element.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a0a8930db4d067ef7adc46aa4917983bfcfd55f5
--- /dev/null
+++ b/components/editor/ask-ai/selected-html-element.tsx
@@ -0,0 +1,57 @@
+import classNames from "classnames";
+import { Code, XCircle } from "lucide-react";
+
+import { Collapsible, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { htmlTagToText } from "@/lib/html-tag-to-text";
+
+export const SelectedHtmlElement = ({
+ element,
+ isAiWorking = false,
+ onDelete,
+}: {
+ element: HTMLElement | null;
+ isAiWorking: boolean;
+ onDelete?: () => void;
+}) => {
+ if (!element) return null;
+
+ const tagName = element.tagName.toLowerCase();
+ return (
+ {
+ if (!isAiWorking && onDelete) {
+ onDelete();
+ }
+ }}
+ >
+
+
+
+
+
+ {element.textContent?.trim().split(/\s+/)[0]} {htmlTagToText(tagName)}
+
+
+
+ {/*
+
+
+ ID: {element.id || "No ID"}
+
+
+ Classes: {" "}
+ {element.className || "No classes"}
+
+
+ */}
+
+ );
+};
diff --git a/components/editor/ask-ai/selector.tsx b/components/editor/ask-ai/selector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0ce4a6bfd0a5d4a02bb20ad82b33ca3947662084
--- /dev/null
+++ b/components/editor/ask-ai/selector.tsx
@@ -0,0 +1,41 @@
+import classNames from "classnames";
+import { Crosshair } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useAi } from "@/hooks/useAi";
+import { useEditor } from "@/hooks/useEditor";
+
+export const Selector = () => {
+ const { globalEditorLoading } = useEditor();
+ const { isEditableModeEnabled, setIsEditableModeEnabled, globalAiLoading } =
+ useAi();
+ return (
+
+
+ {
+ setIsEditableModeEnabled?.(!isEditableModeEnabled);
+ }}
+ disabled={globalAiLoading || globalEditorLoading}
+ className="!rounded-md"
+ >
+
+ Edit
+
+
+
+ Select an element on the page to ask DeepSite edit it directly.
+
+
+ );
+};
diff --git a/components/editor/ask-ai/settings.tsx b/components/editor/ask-ai/settings.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..68bdb5f444dbd6b98e649142b873f0df50811e73
--- /dev/null
+++ b/components/editor/ask-ai/settings.tsx
@@ -0,0 +1,220 @@
+"use client";
+import classNames from "classnames";
+
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { PROVIDERS, MODELS } from "@/lib/providers";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useMemo, useState, useEffect } from "react";
+import { useUpdateEffect } from "react-use";
+import Image from "next/image";
+import { Brain, CheckCheck, ChevronDown } from "lucide-react";
+import { useAi } from "@/hooks/useAi";
+
+export function Settings({
+ open,
+ onClose,
+ error,
+ isFollowUp = false,
+}: {
+ open: boolean;
+ error?: string;
+ isFollowUp?: boolean;
+ onClose: React.Dispatch>;
+}) {
+ const {
+ model,
+ provider,
+ setProvider,
+ setModel,
+ selectedModel,
+ globalAiLoading,
+ } = useAi();
+ const [isMounted, setIsMounted] = useState(false);
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ const modelAvailableProviders = useMemo(() => {
+ const availableProviders = MODELS.find(
+ (m: { value: string }) => m.value === model
+ )?.providers;
+ if (!availableProviders) return Object.keys(PROVIDERS);
+ return Object.keys(PROVIDERS).filter((id) =>
+ availableProviders.includes(id)
+ );
+ }, [model]);
+
+ useUpdateEffect(() => {
+ if (
+ provider !== "auto" &&
+ !modelAvailableProviders.includes(provider as string)
+ ) {
+ setProvider("auto");
+ }
+ }, [model, provider]);
+
+ return (
+
+
+
+
+
+ {isMounted
+ ? selectedModel?.label?.split(" ").join("-").toLowerCase()
+ : "..."}
+
+
+
+
+
+
+
+ {error !== "" && (
+
+ {error}
+
+ )}
+
+ Choose a model
+
+
+
+
+
+
+ Models
+ {MODELS.map(
+ ({
+ value,
+ label,
+ isNew = false,
+ isThinker = false,
+ }: {
+ value: string;
+ label: string;
+ isNew?: boolean;
+ isThinker?: boolean;
+ }) => (
+
+ {label}
+ {isNew && (
+
+ New
+
+ )}
+
+ )
+ )}
+
+
+
+
+ {isFollowUp && (
+
+ Note: You can't use a Thinker model for follow-up requests.
+ We automatically switch to the default model for you.
+
+ )}
+
+
+
+
+ Use auto-provider
+
+
+ We'll automatically select the best provider for you
+ based on your prompt.
+
+
+
{
+ const foundModel = MODELS.find(
+ (m: { value: string }) => m.value === model
+ );
+ if (provider === "auto" && foundModel?.autoProvider) {
+ setProvider(foundModel.autoProvider);
+ } else {
+ setProvider("auto");
+ }
+ }}
+ >
+
+
+
+
+
+ Inference Provider
+
+
+ {modelAvailableProviders.map((id: string) => (
+ {
+ setProvider(id);
+ }}
+ >
+
+ {PROVIDERS[id as keyof typeof PROVIDERS].name}
+ {id === provider && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/components/editor/ask-ai/uploader.tsx b/components/editor/ask-ai/uploader.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cc963daacd30d2067de41596f45133b41b95d4d8
--- /dev/null
+++ b/components/editor/ask-ai/uploader.tsx
@@ -0,0 +1,165 @@
+import { useRef, useState } from "react";
+import {
+ CheckCircle,
+ ImageIcon,
+ Images,
+ Link,
+ Paperclip,
+ Upload,
+} from "lucide-react";
+import Image from "next/image";
+
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Project } from "@/types";
+import Loading from "@/components/loading";
+import { useUser } from "@/hooks/useUser";
+import { useEditor } from "@/hooks/useEditor";
+import { useAi } from "@/hooks/useAi";
+import { useLoginModal } from "@/components/contexts/login-context";
+
+export const Uploader = ({ project }: { project: Project | undefined }) => {
+ const { user } = useUser();
+ const { openLoginModal } = useLoginModal();
+ const { uploadFiles, isUploading, files, globalEditorLoading } = useEditor();
+ const { selectedFiles, setSelectedFiles, globalAiLoading } = useAi();
+
+ const [open, setOpen] = useState(false);
+ const fileInputRef = useRef(null);
+
+ if (!user)
+ return (
+ openLoginModal()}
+ >
+
+ Attach
+
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/components/editor/header/index.tsx b/components/editor/header/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ab9d472746668a26c4e919959413b3fd7caf8b44
--- /dev/null
+++ b/components/editor/header/index.tsx
@@ -0,0 +1,86 @@
+import { ArrowRight, HelpCircle, RefreshCcw } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+
+import Logo from "@/assets/logo.svg";
+import { Button } from "@/components/ui/button";
+import { useUser } from "@/hooks/useUser";
+import { ProTag } from "@/components/pro-modal";
+import { UserMenu } from "@/components/user-menu";
+import { SwitchDevice } from "@/components/editor/switch-devide";
+import { SwitchTab } from "./switch-tab";
+import { History } from "@/components/editor/history";
+
+export function Header() {
+ const { user, openLoginWindow } = useUser();
+ return (
+
+ );
+}
diff --git a/components/editor/header/switch-tab.tsx b/components/editor/header/switch-tab.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..30b0324a7c5ee14460b1f6119fc884a393ec3e4d
--- /dev/null
+++ b/components/editor/header/switch-tab.tsx
@@ -0,0 +1,58 @@
+import {
+ PanelLeftClose,
+ PanelLeftOpen,
+ Eye,
+ MessageCircleCode,
+} from "lucide-react";
+import classNames from "classnames";
+
+import { Button } from "@/components/ui/button";
+import { useEditor } from "@/hooks/useEditor";
+
+const TABS = [
+ {
+ value: "chat",
+ label: "Chat",
+ icon: MessageCircleCode,
+ },
+ {
+ value: "preview",
+ label: "Preview",
+ icon: Eye,
+ },
+];
+
+export const SwitchTab = ({ isMobile = false }: { isMobile?: boolean }) => {
+ const { currentTab, setCurrentTab } = useEditor();
+
+ if (isMobile) {
+ return (
+
+ {TABS.map((item) => (
+ setCurrentTab(item.value)}
+ >
+
+ {item.label}
+
+ ))}
+
+ );
+ }
+ return (
+ setCurrentTab(currentTab === "chat" ? "preview" : "chat")}
+ >
+ {currentTab === "chat" ? : }
+
+ );
+};
diff --git a/components/editor/history/index.tsx b/components/editor/history/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d78dfe3435e3a11c1feee27bcd643037f5d5e37
--- /dev/null
+++ b/components/editor/history/index.tsx
@@ -0,0 +1,91 @@
+import { History as HistoryIcon } from "lucide-react";
+import { useState } from "react";
+
+import { Commit } from "@/types";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { useEditor } from "@/hooks/useEditor";
+import classNames from "classnames";
+
+export function History() {
+ const { commits, currentCommit, setCurrentCommit } = useEditor();
+ const [open, setOpen] = useState(false);
+
+ if (commits.length === 0) return null;
+
+ return (
+
+
+
+
+ {commits?.length} edit{commits?.length !== 1 ? "s" : ""}
+
+
+
+
+
+
+ {commits?.map((item: Commit, index: number) => (
+
+ {item.title}
+
+
+ {new Date(item.date).toLocaleDateString("en-US", {
+ month: "2-digit",
+ day: "2-digit",
+ year: "2-digit",
+ }) +
+ " " +
+ new Date(item.date).toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ })}
+
+ {currentCommit !== item.oid ? (
+
{
+ setCurrentCommit(item.oid);
+ }}
+ >
+ See version
+
+ ) : (
+
+ Current version
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/editor/index.tsx b/components/editor/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4f96b059271e5d7174dc2744142b11bca3b4bf21
--- /dev/null
+++ b/components/editor/index.tsx
@@ -0,0 +1,106 @@
+"use client";
+import { useMemo, useRef, useState } from "react";
+import { useCopyToClipboard } from "react-use";
+import { CopyIcon } from "lucide-react";
+import { toast } from "sonner";
+import classNames from "classnames";
+import { editor } from "monaco-editor";
+import Editor from "@monaco-editor/react";
+
+import { useEditor } from "@/hooks/useEditor";
+import { Header } from "@/components/editor/header";
+import { useAi } from "@/hooks/useAi";
+
+import { ListPages } from "./pages";
+import { AskAi } from "./ask-ai";
+import { Preview } from "./preview";
+import Loading from "../loading";
+
+export const AppEditor = ({
+ namespace,
+ repoId,
+ isNew = false,
+}: {
+ namespace?: string;
+ repoId?: string;
+ isNew?: boolean;
+}) => {
+ const { project, setPages, files, currentPageData, currentTab } = useEditor(
+ namespace,
+ repoId
+ );
+ const [, copyToClipboard] = useCopyToClipboard();
+
+ const monacoRef = useRef(null);
+ const editor = useRef(null);
+ const editorRef = useRef(null);
+
+ return (
+
+
+
+
+
+
{
+ copyToClipboard(currentPageData.html);
+ toast.success("HTML copied to clipboard!");
+ }}
+ />
+ }
+ className="h-full absolute left-0 top-0 lg:min-w-[600px]"
+ options={{
+ colorDecorators: true,
+ fontLigatures: true,
+ theme: "vs-dark",
+ minimap: { enabled: false },
+ scrollbar: {
+ horizontal: "hidden",
+ },
+ wordWrap: "on",
+ }}
+ value={currentPageData.html}
+ onChange={(value) => {
+ const newValue = value ?? "";
+ setPages((prev) =>
+ prev.map((page) =>
+ page.path === currentPageData.path
+ ? { ...page, html: newValue }
+ : page
+ )
+ );
+ }}
+ onMount={(editor, monaco) => {
+ editorRef.current = editor;
+ monacoRef.current = monaco;
+ }}
+ />
+ {
+ editorRef.current?.revealLine(
+ editorRef.current?.getModel()?.getLineCount() ?? 0
+ );
+ }}
+ />
+
+
+
+
+ );
+};
diff --git a/components/editor/pages/index.tsx b/components/editor/pages/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..87cd6dafb7d88ffddcbd9de644abd93e3ede60ea
--- /dev/null
+++ b/components/editor/pages/index.tsx
@@ -0,0 +1,24 @@
+import { Page } from "@/types";
+import { ListPagesItem } from "./page";
+import { useEditor } from "@/hooks/useEditor";
+
+export function ListPages() {
+ const { pages, setPages, currentPage, setCurrentPage } = useEditor();
+ return (
+
+ {pages.map((page: Page, i: number) => (
+ {
+ setPages(pages.filter((page) => page.path !== path));
+ setCurrentPage("index.html");
+ }}
+ index={i}
+ />
+ ))}
+
+ );
+}
diff --git a/components/editor/pages/page.tsx b/components/editor/pages/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..23e3396092cfb1e3f9efcf0362b739ad1c692c1e
--- /dev/null
+++ b/components/editor/pages/page.tsx
@@ -0,0 +1,56 @@
+import classNames from "classnames";
+import { FileCode, XIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Page } from "@/types";
+
+export function ListPagesItem({
+ page,
+ currentPage,
+ onSelectPage,
+ onDeletePage,
+ index,
+}: {
+ page: Page;
+ currentPage: string;
+ onSelectPage: (path: string, newPath?: string) => void;
+ onDeletePage: (path: string) => void;
+ index: number;
+}) {
+ return (
+ onSelectPage(page.path)}
+ title={page.path}
+ >
+
+ {page.path}
+ {index > 0 && (
+ {
+ e.stopPropagation();
+ if (
+ window.confirm(
+ "Are you sure you want to delete this page? This action cannot be undone."
+ )
+ ) {
+ onDeletePage(page.path);
+ }
+ }}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/components/editor/preview/index.tsx b/components/editor/preview/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..abfd8dec7f495bcb33063f9a0dc2b3281709a852
--- /dev/null
+++ b/components/editor/preview/index.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import { useRef, useState, useEffect } from "react";
+import { useUpdateEffect } from "react-use";
+import classNames from "classnames";
+
+import { cn } from "@/lib/utils";
+import { GridPattern } from "@/components/magic-ui/grid-pattern";
+import { useEditor } from "@/hooks/useEditor";
+import { useAi } from "@/hooks/useAi";
+import { htmlTagToText } from "@/lib/html-tag-to-text";
+import { AnimatedBlobs } from "@/components/animated-blobs";
+import { AiLoading } from "../ask-ai/loading";
+import { defaultHTML } from "@/lib/consts";
+import { Button } from "@/components/ui/button";
+import {
+ MousePointerClick,
+ History,
+ AlertCircle,
+ ChevronDown,
+ ChevronUp,
+} from "lucide-react";
+import { api } from "@/lib/api";
+import { toast } from "sonner";
+import Loading from "@/components/loading";
+
+export const Preview = ({ isNew }: { isNew: boolean }) => {
+ const {
+ project,
+ device,
+ isLoadingProject,
+ currentTab,
+ currentCommit,
+ setCurrentCommit,
+ } = useEditor();
+ const {
+ isEditableModeEnabled,
+ setSelectedElement,
+ isAiWorking,
+ setIsEditableModeEnabled,
+ } = useAi();
+
+ const iframeSrc = project?.space_id
+ ? `/api/proxy/?spaceId=${encodeURIComponent(project.space_id)}${
+ currentCommit ? `&commitId=${currentCommit}` : ""
+ }`
+ : "";
+
+ const iframeRef = useRef(null);
+ const [hoveredElement, setHoveredElement] = useState<{
+ tagName: string;
+ rect: { top: number; left: number; width: number; height: number };
+ } | null>(null);
+ const [isHistoryNotificationCollapsed, setIsHistoryNotificationCollapsed] =
+ useState(false);
+ const [isPromotingVersion, setIsPromotingVersion] = useState(false);
+
+ // Handle PostMessage communication with iframe
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ // Verify origin for security
+ if (!event.origin.includes(window.location.origin)) {
+ return;
+ }
+
+ const { type, data } = event.data;
+ switch (type) {
+ case "PROXY_SCRIPT_READY":
+ if (iframeRef.current?.contentWindow) {
+ iframeRef.current.contentWindow.postMessage(
+ {
+ type: isEditableModeEnabled
+ ? "ENABLE_EDIT_MODE"
+ : "DISABLE_EDIT_MODE",
+ },
+ "*"
+ );
+ }
+ break;
+ case "ELEMENT_HOVERED":
+ if (isEditableModeEnabled) {
+ setHoveredElement(data);
+ }
+ break;
+ case "ELEMENT_MOUSE_OUT":
+ if (isEditableModeEnabled) {
+ setHoveredElement(null);
+ }
+ break;
+ case "ELEMENT_CLICKED":
+ if (isEditableModeEnabled) {
+ const mockElement = {
+ tagName: data.tagName,
+ getBoundingClientRect: () => data.rect,
+ };
+ setSelectedElement(mockElement as any);
+ setIsEditableModeEnabled(false);
+ }
+ break;
+ case "NAVIGATE_TO_PROXY":
+ // Handle navigation within the iframe while maintaining proxy context
+ if (iframeRef.current && data.proxyUrl) {
+ iframeRef.current.src = data.proxyUrl;
+ }
+ break;
+ }
+ };
+
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, [setSelectedElement, isEditableModeEnabled]);
+
+ // Send edit mode state to iframe and clear hover state when disabled
+ useUpdateEffect(() => {
+ if (iframeRef.current?.contentWindow) {
+ iframeRef.current.contentWindow.postMessage(
+ {
+ type: isEditableModeEnabled
+ ? "ENABLE_EDIT_MODE"
+ : "DISABLE_EDIT_MODE",
+ },
+ "*"
+ );
+ }
+
+ // Clear hover state when edit mode is disabled
+ if (!isEditableModeEnabled) {
+ setHoveredElement(null);
+ }
+ }, [isEditableModeEnabled, project?.space_id]);
+
+ const promoteVersion = async () => {
+ setIsPromotingVersion(true);
+ await api
+ .post(
+ `/me/projects/${project?.space_id}/commits/${currentCommit}/promote`
+ )
+ .then((res) => {
+ if (res.data.ok) {
+ setCurrentCommit(null);
+ toast.success("Version promoted successfully");
+ }
+ })
+ .catch((err) => {
+ toast.error(err.response.data.error);
+ });
+ setIsPromotingVersion(false);
+ };
+
+ return (
+
+
+ {!isAiWorking && hoveredElement && isEditableModeEnabled && (
+
+
+ {htmlTagToText(hoveredElement.tagName.toLowerCase())}
+
+
+ )}
+ {isNew && !isAiWorking ? (
+
+ ) : iframeSrc === "" ||
+ isLoadingProject ||
+ (isAiWorking && iframeSrc == "") ? (
+
+ ) : (
+ <>
+
+
+ {isHistoryNotificationCollapsed ? (
+ // Collapsed state
+
+
+
+ Historical Version
+
+ setIsHistoryNotificationCollapsed(false)}
+ >
+
+
+
+ ) : (
+ // Expanded state
+
+
+
+
+
+
+
+ Historical Version
+
+
+
setIsHistoryNotificationCollapsed(true)}
+ >
+
+
+
+
+ You're viewing a previous version of this project. Promote
+ this version to make it current and deploy it live.
+
+
+ promoteVersion()}
+ disabled={isPromotingVersion}
+ >
+ {isPromotingVersion ? (
+
+ ) : (
+
+ )}
+ Promote Version
+
+ setCurrentCommit(null)}
+ >
+ Go back to current
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+ );
+};
diff --git a/components/editor/switch-devide/index.tsx b/components/editor/switch-devide/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..934e75e0a84674a485c06386ae984818b1401bba
--- /dev/null
+++ b/components/editor/switch-devide/index.tsx
@@ -0,0 +1,39 @@
+import classNames from "classnames";
+import { Laptop, Smartphone } from "lucide-react";
+
+import { useEditor } from "@/hooks/useEditor";
+
+const DEVICES = [
+ {
+ name: "desktop",
+ icon: Laptop,
+ },
+ {
+ name: "mobile",
+ icon: Smartphone,
+ },
+];
+
+export const SwitchDevice = () => {
+ const { device, setDevice } = useEditor();
+ return (
+
+ {DEVICES.map((dvc) => (
+ setDevice(dvc.name)}
+ >
+
+ {dvc.name}
+
+ ))}
+
+ );
+};
diff --git a/components/iframe-detector/index.tsx b/components/iframe-detector/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..934841a92348f219830fc55af0ff92efbb238243
--- /dev/null
+++ b/components/iframe-detector/index.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import IframeWarningModal from "./modal";
+
+export default function IframeDetector() {
+ const [showWarning, setShowWarning] = useState(false);
+
+ useEffect(() => {
+ // Helper function to check if a hostname is from allowed domains
+ const isAllowedDomain = (hostname: string) => {
+ const host = hostname.toLowerCase();
+ return (
+ host.endsWith(".huggingface.co") ||
+ host.endsWith(".hf.co") ||
+ host === "huggingface.co" ||
+ host === "hf.co"
+ );
+ };
+
+ // Check if the current window is in an iframe
+ const isInIframe = () => {
+ try {
+ return window.self !== window.top;
+ } catch {
+ // If we can't access window.top due to cross-origin restrictions,
+ // we're likely in an iframe
+ return true;
+ }
+ };
+
+ // Additional check: compare window location with parent location
+ const isEmbedded = () => {
+ try {
+ return window.location !== window.parent.location;
+ } catch {
+ // Cross-origin iframe
+ return true;
+ }
+ };
+
+ // Check if we're in an iframe from a non-allowed domain
+ const shouldShowWarning = () => {
+ if (!isInIframe() && !isEmbedded()) {
+ return false; // Not in an iframe
+ }
+
+ try {
+ // Try to get the parent's hostname
+ const parentHostname = window.parent.location.hostname;
+ return !isAllowedDomain(parentHostname);
+ } catch {
+ // Cross-origin iframe - try to get referrer instead
+ try {
+ if (document.referrer) {
+ const referrerUrl = new URL(document.referrer);
+ return !isAllowedDomain(referrerUrl.hostname);
+ }
+ } catch {
+ // If we can't determine the parent domain, assume it's not allowed
+ }
+ return true;
+ }
+ };
+
+ if (shouldShowWarning()) {
+ // Show warning modal instead of redirecting immediately
+ setShowWarning(true);
+ }
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/components/iframe-detector/modal.tsx b/components/iframe-detector/modal.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..acb58b87ee0709e716954cfcd5b8a78c3bcebc67
--- /dev/null
+++ b/components/iframe-detector/modal.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ExternalLink, AlertTriangle } from "lucide-react";
+
+interface IframeWarningModalProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export default function IframeWarningModal({
+ isOpen,
+}: // onOpenChange,
+IframeWarningModalProps) {
+ const handleVisitSite = () => {
+ window.open("https://deepsite.hf.co", "_blank");
+ };
+
+ return (
+ {}}>
+
+
+
+
+
Unauthorized Embedding
+
+
+ You're viewing DeepSite through an unauthorized iframe. For the
+ best experience and security, please visit the official website
+ directly.
+
+
+
+
+
Why visit the official site?
+
+ • Better performance and security
+ • Full functionality access
+ • Latest features and updates
+ • Proper authentication support
+
+
+
+
+
+
+ Visit Deepsite.hf.co
+
+
+
+
+ );
+}
diff --git a/components/loading/full-loading.tsx b/components/loading/full-loading.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2ee9c53f9845b3c8b4e9ea073fbacbe26addc7c6
--- /dev/null
+++ b/components/loading/full-loading.tsx
@@ -0,0 +1,12 @@
+import Loading from ".";
+import { AnimatedBlobs } from "../animated-blobs";
+
+export const FullLoading = () => {
+ return (
+
+
+ {/*
Fetching user data...
*/}
+
+
+ );
+};
diff --git a/components/loading/index.tsx b/components/loading/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b55a26b0d0c47033eb70c73960c8e4d028f7f2a0
--- /dev/null
+++ b/components/loading/index.tsx
@@ -0,0 +1,41 @@
+import classNames from "classnames";
+
+function Loading({
+ overlay = true,
+ className,
+}: {
+ overlay?: boolean;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+export default Loading;
diff --git a/components/login-modal/index.tsx b/components/login-modal/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..076af6d3e7692ca307c9711da88d78dec981329b
--- /dev/null
+++ b/components/login-modal/index.tsx
@@ -0,0 +1,66 @@
+import { useLocalStorage } from "react-use";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { useUser } from "@/hooks/useUser";
+import { isTheSameHtml } from "@/lib/compare-html-diff";
+import { Page } from "@/types";
+
+export const LoginModal = ({
+ open,
+ pages,
+ onClose,
+ title = "Log In to use DeepSite for free",
+ description = "Log In through your Hugging Face account to continue using DeepSite and increase your monthly free limit.",
+}: {
+ open: boolean;
+ pages?: Page[];
+ onClose: React.Dispatch>;
+ title?: string;
+ description?: string;
+}) => {
+ const { openLoginWindow } = useUser();
+ const [, setStorage] = useLocalStorage("pages");
+ const handleClick = async () => {
+ if (pages && !isTheSameHtml(pages[0].html)) {
+ setStorage(pages);
+ }
+ openLoginWindow();
+ onClose(false);
+ };
+ return (
+
+
+
+
+
+
+ 💪
+
+
+ 😎
+
+
+ 🙌
+
+
+ {title}
+
+ {description}
+
+
+ Log In to Continue
+
+
+
+
+ );
+};
diff --git a/components/magic-ui/grid-pattern.tsx b/components/magic-ui/grid-pattern.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0ead903d1de84aba97f328e5686f3c631a33cef6
--- /dev/null
+++ b/components/magic-ui/grid-pattern.tsx
@@ -0,0 +1,69 @@
+import { useId } from "react";
+import { cn } from "@/lib/utils";
+
+interface GridPatternProps extends React.SVGProps {
+ width?: number;
+ height?: number;
+ x?: number;
+ y?: number;
+ squares?: Array<[x: number, y: number]>;
+ strokeDasharray?: string;
+ className?: string;
+ [key: string]: unknown;
+}
+
+export function GridPattern({
+ width = 40,
+ height = 40,
+ x = -1,
+ y = -1,
+ strokeDasharray = "0",
+ squares,
+ className,
+ ...props
+}: GridPatternProps) {
+ const id = useId();
+
+ return (
+
+
+
+
+
+
+
+ {squares && (
+
+ {squares.map(([x, y]) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/my-projects/index.tsx b/components/my-projects/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c742bc42fd3874a3ae2985886fc30dd256ddca8e
--- /dev/null
+++ b/components/my-projects/index.tsx
@@ -0,0 +1,112 @@
+"use client";
+import { Plus } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+
+import { useUser } from "@/hooks/useUser";
+import { Project, ProjectType } from "@/types";
+import { ProjectCard } from "./project-card";
+import { LoadProject } from "./load-project";
+import { MAX_FREE_PROJECTS } from "@/lib/utils";
+import { ProTag } from "@/components/pro-modal";
+import { Button } from "@/components/ui/button";
+import { useProModal } from "@/components/contexts/pro-context";
+import { api } from "@/lib/api";
+import { toast } from "sonner";
+
+export function MyProjects({
+ projects: initialProjects,
+}: {
+ projects: ProjectType[];
+}) {
+ const { user } = useUser();
+ const { openProModal } = useProModal();
+ const [projects, setProjects] = useState(
+ initialProjects || []
+ );
+
+ const onDelete = async (project: ProjectType) => {
+ const response = await api.delete(`/me/projects/${project.name}`);
+ if (response.data.ok) {
+ toast.success("Project deleted successfully!");
+ setProjects((prev) => prev.filter((p) => p.id !== project.id));
+ } else {
+ toast.error(response.data.error);
+ }
+ };
+ return (
+ <>
+
+
+
+ {projects.length < MAX_FREE_PROJECTS ? (
+
+
+ Create Project
+
+ ) : (
+
openProModal([])}
+ >
+
+ Create Project
+
+ )}
+ {projects.map((project: ProjectType) => (
+
onDelete(project)}
+ />
+ ))}
+
+
+ >
+ );
+}
diff --git a/components/my-projects/load-project.tsx b/components/my-projects/load-project.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a86a6f2d32095b046d614b2730c2b6d50811d48d
--- /dev/null
+++ b/components/my-projects/load-project.tsx
@@ -0,0 +1,200 @@
+"use client";
+import { useState } from "react";
+import { Import } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import Loading from "@/components/loading";
+import { Input } from "../ui/input";
+import { toast } from "sonner";
+import { api } from "@/lib/api";
+import { useUser } from "@/hooks/useUser";
+import { LoginModal } from "@/components/login-modal";
+import { useRouter } from "next/navigation";
+import { SpaceEntry } from "@huggingface/hub";
+
+export const LoadProject = ({
+ fullXsBtn = false,
+ onSuccess,
+}: {
+ fullXsBtn?: boolean;
+ onSuccess: (project: SpaceEntry) => void;
+}) => {
+ const { user } = useUser();
+ const router = useRouter();
+
+ const [openLoginModal, setOpenLoginModal] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [url, setUrl] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+
+ const checkIfUrlIsValid = (url: string) => {
+ // should match a hugging face spaces URL like: https://huggingface.co/spaces/username/project or https://hf.co/spaces/username/project
+ const urlPattern = new RegExp(
+ /^(https?:\/\/)?(huggingface\.co|hf\.co)\/spaces\/([\w-]+)\/([\w-]+)$/,
+ "i"
+ );
+ return urlPattern.test(url);
+ };
+
+ const handleClick = async () => {
+ if (isLoading) return; // Prevent multiple clicks while loading
+ if (!url) {
+ toast.error("Please enter a URL.");
+ return;
+ }
+ if (!checkIfUrlIsValid(url)) {
+ toast.error("Please enter a valid Hugging Face Spaces URL.");
+ return;
+ }
+
+ const [username, namespace] = url
+ .replace("https://huggingface.co/spaces/", "")
+ .replace("https://hf.co/spaces/", "")
+ .split("/");
+
+ setIsLoading(true);
+ try {
+ const response = await api.post(`/me/projects/${username}/${namespace}`);
+ toast.success("Project imported successfully!");
+ setOpen(false);
+ setUrl("");
+ onSuccess(response.data.project);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ if (error?.response?.data?.redirect) {
+ return router.push(error.response.data.redirect);
+ }
+ toast.error(
+ error?.response?.data?.error ?? "Failed to import the project."
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <>
+ {!user ? (
+ <>
+ setOpenLoginModal(true)}
+ >
+
+ Load existing Project
+
+ setOpenLoginModal(true)}
+ >
+ {fullXsBtn && }
+ Load
+ {fullXsBtn && " existing Project"}
+
+
+ >
+ ) : (
+
+
+
+
+
+ Load existing Project
+
+
+ {fullXsBtn && }
+ Load
+ {fullXsBtn && " existing Project"}
+
+
+
+
+
+
+
+
+
+ Enter your Hugging Face Space
+
+
setUrl(e.target.value)}
+ onBlur={(e) => {
+ const inputUrl = e.target.value.trim();
+ if (!inputUrl) {
+ setUrl("");
+ return;
+ }
+ if (!checkIfUrlIsValid(inputUrl)) {
+ toast.error("Please enter a valid URL.");
+ return;
+ }
+ setUrl(inputUrl);
+ }}
+ className="!bg-white !border-neutral-300 !text-neutral-800 !placeholder:text-neutral-400 selection:!bg-blue-100"
+ />
+
+
+
+ Then, let's import it!
+
+
+ {isLoading ? (
+ <>
+
+ Fetching your Space...
+ >
+ ) : (
+ <>Import your Space>
+ )}
+
+
+
+
+
+ )}
+ >
+ );
+};
diff --git a/components/my-projects/project-card.tsx b/components/my-projects/project-card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b8b43e7538d97a4f3cb4cd5f994562a5e7622a23
--- /dev/null
+++ b/components/my-projects/project-card.tsx
@@ -0,0 +1,102 @@
+import Link from "next/link";
+import { formatDistance } from "date-fns";
+import { EllipsisVertical, Settings, Trash } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { ProjectType } from "@/types";
+
+// from-red-500 to-red-500
+// from-yellow-500 to-yellow-500
+// from-green-500 to-green-500
+// from-purple-500 to-purple-500
+// from-blue-500 to-blue-500
+// from-pink-500 to-pink-500
+// from-gray-500 to-gray-500
+// from-indigo-500 to-indigo-500
+
+export function ProjectCard({
+ project,
+ onDelete,
+}: {
+ project: ProjectType;
+ onDelete: () => void;
+}) {
+ const handleDelete = () => {
+ if (
+ confirm(
+ "Are you sure you want to delete this project? This action cannot be undone."
+ )
+ ) {
+ onDelete();
+ }
+ };
+
+ return (
+
+
+
+
+
+ Open project
+
+
+
+
+
+ {project?.cardData?.title ?? project.name}
+
+
+ Updated{" "}
+ {formatDistance(
+ new Date(project.updatedAt || Date.now()),
+ new Date(),
+ {
+ addSuffix: true,
+ }
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Project Settings
+
+
+
+
+ Delete Project
+
+
+
+
+
+
+ );
+}
diff --git a/components/not-logged/not-logged.tsx b/components/not-logged/not-logged.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c3e0d17a10a7451461c3f5d870fa24a6f4017534
--- /dev/null
+++ b/components/not-logged/not-logged.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { useUser } from "@/hooks/useUser";
+import { Button } from "@/components/ui/button";
+import { AnimatedBlobs } from "../animated-blobs";
+
+export const NotLogged = () => {
+ const { openLoginWindow } = useUser();
+ return (
+
+
+
+
+ Oops! You must be logged to continue.
+
+
+ Unfortunately you cannot access DeepSite without being logged
+ through your Hugging Face account.
+
+
+
+ Log In to Continue
+
+
+
+
+ );
+};
diff --git a/components/pro-modal/index.tsx b/components/pro-modal/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8c9bf2f8e114d58dd81a261c53885072b9e84eca
--- /dev/null
+++ b/components/pro-modal/index.tsx
@@ -0,0 +1,102 @@
+import { useLocalStorage } from "react-use";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { CheckCheck } from "lucide-react";
+import { isTheSameHtml } from "@/lib/compare-html-diff";
+import { Page } from "@/types";
+
+export const ProModal = ({
+ open,
+ pages,
+ onClose,
+}: {
+ open: boolean;
+ pages?: Page[];
+ onClose: React.Dispatch>;
+}) => {
+ const [, setStorage] = useLocalStorage("pages");
+ const handleProClick = () => {
+ if (pages && !isTheSameHtml(pages?.[0].html)) {
+ setStorage(pages);
+ }
+ window.open("https://huggingface.co/subscribe/pro?from=DeepSite", "_blank");
+ onClose(false);
+ };
+ return (
+
+
+
+
+
+
+ 🚀
+
+
+ 🤩
+
+
+ 🥳
+
+
+
+ Only $9 to enhance your possibilities
+
+
+ It seems like you have reached the monthly free limit of DeepSite.
+
+
+
+ Upgrade to a Account, and unlock your
+ DeepSite high quota access ⚡
+
+
+
+ You'll also unlock some Hugging Face PRO features, like:
+
+
+
+ Get acces to thousands of AI app (ZeroGPU) with high quota
+
+
+
+ Get exclusive early access to new features and updates
+
+
+
+ Get free credits across all Inference Providers
+
+
+ ... and lots more!
+
+
+
+ Subscribe to PRO ($9/month)
+
+
+
+
+ );
+};
+
+export const ProTag = ({
+ className,
+ ...props
+}: {
+ className?: string;
+ onClick?: () => void;
+}) => (
+
+ PRO
+
+);
+export default ProModal;
diff --git a/components/public/navigation/index.tsx b/components/public/navigation/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dad8b947c88efa9b3cc3ffc09009d2b42a95ab99
--- /dev/null
+++ b/components/public/navigation/index.tsx
@@ -0,0 +1,168 @@
+"use client";
+
+import { useRef, useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { useMount, useUnmount } from "react-use";
+import classNames from "classnames";
+import { ArrowRight } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import Logo from "@/assets/logo.svg";
+import { useUser } from "@/hooks/useUser";
+import { UserMenu } from "@/components/user-menu";
+import { ProTag } from "@/components/pro-modal";
+
+const navigationLinks = [
+ {
+ name: "Create Website",
+ href: "/projects/new",
+ },
+ {
+ name: "Features",
+ href: "#features",
+ },
+ {
+ name: "Community",
+ href: "#community",
+ },
+ {
+ name: "Deploy",
+ href: "#deploy",
+ },
+];
+
+export default function Navigation() {
+ const { openLoginWindow, user, loading } = useUser();
+ const [hash, setHash] = useState("");
+
+ const selectorRef = useRef(null);
+ const linksRef = useRef(
+ new Array(navigationLinks.length).fill(null)
+ );
+ const [isScrolled, setIsScrolled] = useState(false);
+
+ useMount(() => {
+ const handleScroll = () => {
+ const scrollTop = window.scrollY;
+ setIsScrolled(scrollTop > 100);
+ };
+
+ const initialHash = window.location.hash;
+ if (initialHash) {
+ setHash(initialHash);
+ calculateSelectorPosition(initialHash);
+ }
+
+ window.addEventListener("scroll", handleScroll);
+ });
+
+ useUnmount(() => {
+ window.removeEventListener("scroll", () => {});
+ });
+
+ const handleClick = (href: string) => {
+ setHash(href);
+ calculateSelectorPosition(href);
+ };
+
+ const calculateSelectorPosition = (href: string) => {
+ if (selectorRef.current && linksRef.current) {
+ const index = navigationLinks.findIndex((l) => l.href === href);
+ const targetLink = linksRef.current[index];
+ if (targetLink) {
+ const targetRect = targetLink.getBoundingClientRect();
+ selectorRef.current.style.left = targetRect.left + "px";
+ selectorRef.current.style.width = targetRect.width + "px";
+ }
+ }
+ };
+
+ return (
+
+
+
+
+ DeepSite
+ {user?.isPro && }
+
+
+ {navigationLinks.map((link) => (
+ {
+ const index = navigationLinks.findIndex(
+ (l) => l.href === link.href
+ );
+ if (el && linksRef.current[index] !== el) {
+ linksRef.current[index] = el;
+ }
+ }}
+ className="inline-block font-sans text-sm"
+ >
+ {
+ handleClick(link.href);
+ }}
+ >
+ {link.name}
+
+
+ ))}
+
+
+
+ {loading ? (
+
+
+
+
+ ) : user ? (
+
+ ) : (
+ <>
+
+ Access to my Account
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..71e428b4ca6154811e8f569d5fdd971ead095996
--- /dev/null
+++ b/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/lib/utils"
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..558f89ebe0fc8f3e3173cc1ff22f4d8005194960
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,72 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-full text-sm font-sans font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 border border-primary",
+ destructive:
+ "bg-red-500 text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 [&_svg]:!text-white",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ bordered:
+ "border border-neutral-700/70 text-neutral-200 hover:brightness-120 !rounded-md bg-neutral-900",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ lightGray: "bg-neutral-200/60 hover:bg-neutral-200",
+ gray: "bg-neutral-800 !rounded-md text-neutral-300 border border-neutral-700/40 hover:brightness-120",
+ link: "text-primary underline-offset-4 hover:underline",
+ ghostDarker:
+ "text-white shadow-xs focus-visible:ring-black/40 bg-black/40 hover:bg-black/70",
+ black: "bg-neutral-950 text-neutral-300 hover:brightness-110",
+ sky: "bg-sky-500 text-white hover:brightness-110",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-full text-[13px] gap-1.5 px-3",
+ lg: "h-10 rounded-full px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ iconXs: "size-7",
+ iconXss: "size-6",
+ iconXsss: "size-5",
+ xs: "h-6 text-xs rounded-full pl-2 pr-2 gap-1",
+ xss: "h-5 text-[10px] rounded-full pl-1.5 pr-1.5 gap-1",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bd4167ec38b98869c4ad17d22cd6514ea4d85e3c
--- /dev/null
+++ b/components/ui/checkbox.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import * as React from "react";
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { CheckIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox };
diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ae9fad04a3716b5d6f6c957b75841737eb8ed7a8
--- /dev/null
+++ b/components/ui/collapsible.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
+
+function Collapsible({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function CollapsibleTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function CollapsibleContent({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a6f410cbff8a4524949b2cec5f3b05bcb459b6b7
--- /dev/null
+++ b/components/ui/dialog.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6bbd969389aa30c911b6388de5f22191eca62a32
--- /dev/null
+++ b/components/ui/dropdown-menu.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..03295ca6ac617de95b78b09e5e3a6de897a204f0
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..27576b28e331f9c4f42f91569c963bd99d97c598
--- /dev/null
+++ b/components/ui/popover.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+
+import { cn } from "@/lib/utils";
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function PopoverAnchor({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dcbbc0ca0c781dfa6d2fe4ee6f1c9c2cad905a9b
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,185 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "popper",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2922154241f1415b767c55ca77bbfa8a1569cefd
--- /dev/null
+++ b/components/ui/sonner.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { Toaster as Sonner, ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ return (
+
+ );
+};
+
+export { Toaster };
diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4864d4e8cfa54dc9ae2e29f9dfb636d8f541f5ce
--- /dev/null
+++ b/components/ui/switch.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as SwitchPrimitive from "@radix-ui/react-switch";
+
+import { cn } from "@/lib/utils";
+
+function Switch({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { Switch };
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..497ba5ea34247f6843e0c58ccd7da61b7c8edb46
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/components/ui/toggle-group.tsx b/components/ui/toggle-group.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5eed401b6c9c19f7b6f88e90d3cbe38783ef198b
--- /dev/null
+++ b/components/ui/toggle-group.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import * as React from "react"
+import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
+import { type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { toggleVariants } from "@/components/ui/toggle"
+
+const ToggleGroupContext = React.createContext<
+ VariantProps
+>({
+ size: "default",
+ variant: "default",
+})
+
+function ToggleGroup({
+ className,
+ variant,
+ size,
+ children,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function ToggleGroupItem({
+ className,
+ children,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ const context = React.useContext(ToggleGroupContext)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export { ToggleGroup, ToggleGroupItem }
diff --git a/components/ui/toggle.tsx b/components/ui/toggle.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..94ec8f589b345a8c33b165463dfda393c7255967
--- /dev/null
+++ b/components/ui/toggle.tsx
@@ -0,0 +1,47 @@
+"use client"
+
+import * as React from "react"
+import * as TogglePrimitive from "@radix-ui/react-toggle"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const toggleVariants = cva(
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline:
+ "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
+ },
+ size: {
+ default: "h-9 px-2 min-w-9",
+ sm: "h-8 px-1.5 min-w-8",
+ lg: "h-10 px-2.5 min-w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Toggle({
+ className,
+ variant,
+ size,
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+export { Toggle, toggleVariants }
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9a8f2af7839f0d4c3910431419695a70855ace37
--- /dev/null
+++ b/components/ui/tooltip.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "@/lib/utils";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/components/user-menu/index.tsx b/components/user-menu/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2d215fa61b2287780348ea0b3d35783885875b91
--- /dev/null
+++ b/components/user-menu/index.tsx
@@ -0,0 +1,83 @@
+import {
+ ChartSpline,
+ CirclePlus,
+ FolderCode,
+ Import,
+ LogOut,
+} from "lucide-react";
+import Link from "next/link";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { useUser } from "@/hooks/useUser";
+
+export const UserMenu = ({ className }: { className?: string }) => {
+ const { logout, user } = useUser();
+ return (
+
+
+
+
+
+
+ {user?.fullname?.charAt(0).toUpperCase() ?? "E"}
+
+
+ {user?.fullname}
+
+ {user?.fullname?.slice(0, 10)}
+ {(user?.fullname?.length ?? 0) > 10 ? "..." : ""}
+
+
+
+
+
+ My Account
+
+
+
+ (window.location.href = "/projects/new")}
+ >
+
+ New Project
+
+
+
+
+ View Projects
+
+
+
+
+
+ Usage Quota
+
+
+
+
+ {
+ if (confirm("Are you sure you want to log out?")) {
+ logout();
+ }
+ }}
+ >
+
+
+ Log out
+
+
+
+
+ );
+};
diff --git a/hooks/useAi.ts b/hooks/useAi.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b7b418a879cbd3a04e598df336474b450c42af33
--- /dev/null
+++ b/hooks/useAi.ts
@@ -0,0 +1,481 @@
+import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
+import { useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { useLocalStorage } from "react-use";
+
+import { MODELS } from "@/lib/providers";
+import { useEditor } from "./useEditor";
+import { Page, EnhancedSettings } from "@/types";
+import { api } from "@/lib/api";
+import { useRouter } from "next/navigation";
+
+export const useAi = (onScrollToBottom?: () => void) => {
+ const client = useQueryClient();
+ const audio = useRef(null);
+ const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject } = useEditor();
+ const [controller, setController] = useState(null);
+ const [storageProvider, setStorageProvider] = useLocalStorage("provider", "auto");
+ const [storageModel, setStorageModel] = useLocalStorage("model", MODELS[0].value);
+ const router = useRouter();
+
+ const { data: isAiWorking = false } = useQuery({
+ queryKey: ["ai.isAiWorking"],
+ queryFn: async () => false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+ const setIsAiWorking = (newIsAiWorking: boolean) => {
+ client.setQueryData(["ai.isAiWorking"], newIsAiWorking);
+ };
+
+ const { data: isThinking = false } = useQuery({
+ queryKey: ["ai.isThinking"],
+ queryFn: async () => false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+ const setIsThinking = (newIsThinking: boolean) => {
+ client.setQueryData(["ai.isThinking"], newIsThinking);
+ };
+
+ const { data: selectedElement } = useQuery({
+ queryKey: ["ai.selectedElement"],
+ queryFn: async () => null,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ initialData: null
+ });
+ const setSelectedElement = (newSelectedElement: HTMLElement | null) => {
+ client.setQueryData(["ai.selectedElement"], newSelectedElement);
+ };
+
+ const { data: isEditableModeEnabled = false } = useQuery({
+ queryKey: ["ai.isEditableModeEnabled"],
+ queryFn: async () => false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+ const setIsEditableModeEnabled = (newIsEditableModeEnabled: boolean) => {
+ client.setQueryData(["ai.isEditableModeEnabled"], newIsEditableModeEnabled);
+ };
+
+ const { data: selectedFiles } = useQuery({
+ queryKey: ["ai.selectedFiles"],
+ queryFn: async () => [],
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ initialData: []
+ });
+ const setSelectedFiles = (newFiles: string[]) => {
+ client.setQueryData(["ai.selectedFiles"], newFiles)
+ };
+
+ const { data: provider } = useQuery({
+ queryKey: ["ai.provider"],
+ queryFn: async () => storageProvider ?? "auto",
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ initialData: storageProvider ?? "auto"
+ });
+ const setProvider = (newProvider: string) => {
+ setStorageProvider(newProvider);
+ client.setQueryData(["ai.provider"], newProvider);
+ };
+
+ const { data: model } = useQuery({
+ queryKey: ["ai.model"],
+ queryFn: async () => storageModel ?? MODELS[0].value,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ initialData: storageModel ?? MODELS[0].value
+ });
+ const setModel = (newModel: string) => {
+ setStorageModel(newModel);
+ client.setQueryData(["ai.model"], newModel);
+ };
+
+ const createNewProject = async (prompt: string, htmlPages: Page[], projectName: string | undefined) => {
+ const response = await api.post("/me/projects", {
+ title: projectName,
+ pages: htmlPages,
+ prompt,
+ });
+ if (response.data.ok) {
+ setIsAiWorking(false);
+ router.replace(`/projects/${response.data.space.project.space_id}`);
+ setProject(response.data.space);
+ toast.success("AI responded successfully");
+ if (audio.current) audio.current.play();
+ }
+ }
+
+ const callAiNewProject = async (prompt: string, enhancedSettings?: EnhancedSettings, redesignMarkdown?: string, handleThink?: (think: string) => void, onFinishThink?: () => void) => {
+ if (isAiWorking) return;
+ if (!redesignMarkdown && !prompt.trim()) return;
+
+ setIsAiWorking(true);
+
+ const abortController = new AbortController();
+ setController(abortController);
+
+ try {
+ const request = await fetch("/api/ask", {
+ method: "POST",
+ body: JSON.stringify({
+ prompt,
+ provider,
+ model,
+ redesignMarkdown,
+ enhancedSettings,
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ "x-forwarded-for": window.location.hostname,
+ },
+ signal: abortController.signal,
+ });
+
+ if (request && request.body) {
+ const reader = request.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ const selectedModel = MODELS.find(
+ (m: { value: string }) => m.value === model
+ );
+ let contentResponse = "";
+
+ const read = async (): Promise => {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ // Check if the response is a JSON error message
+ const trimmedResponse = contentResponse.trim();
+ if (trimmedResponse.startsWith("{") && trimmedResponse.endsWith("}")) {
+ try {
+ const jsonResponse = JSON.parse(trimmedResponse);
+ if (jsonResponse && !jsonResponse.ok) {
+ setIsAiWorking(false);
+ if (jsonResponse.openLogin) {
+ return { error: "login_required" };
+ } else if (jsonResponse.openSelectProvider) {
+ return { error: "provider_required", message: jsonResponse.message };
+ } else if (jsonResponse.openProModal) {
+ return { error: "pro_required" };
+ } else {
+ toast.error(jsonResponse.message);
+ return { error: "api_error", message: jsonResponse.message };
+ }
+ }
+ } catch (e) {
+ // Not valid JSON, treat as normal content
+ }
+ }
+
+ const newPages = formatPages(contentResponse);
+ const projectName = contentResponse.match(/<<<<<<< PROJECT_NAME_START ([\s\S]*?) >>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
+ setPages(newPages);
+ createNewProject(prompt, newPages, projectName);
+ setPrompts([...prompts, prompt]);
+
+ return { success: true, pages: newPages };
+ }
+
+ const chunk = decoder.decode(value, { stream: true });
+ contentResponse += chunk;
+
+ const trimmedResponse = contentResponse.trim();
+ if (trimmedResponse.startsWith("{") && trimmedResponse.endsWith("}")) {
+ try {
+ const jsonResponse = JSON.parse(trimmedResponse);
+ if (jsonResponse && !jsonResponse.ok) {
+ setIsAiWorking(false);
+ if (jsonResponse.openLogin) {
+ return { error: "login_required" };
+ } else if (jsonResponse.openSelectProvider) {
+ return { error: "provider_required", message: jsonResponse.message };
+ } else if (jsonResponse.openProModal) {
+ return { error: "pro_required" };
+ } else {
+ toast.error(jsonResponse.message);
+ return { error: "api_error", message: jsonResponse.message };
+ }
+ }
+ } catch (e) {
+ // Not a complete JSON yet, continue reading
+ }
+ }
+
+ if (selectedModel?.isThinker) {
+ const thinkMatch = contentResponse.match(/[\s\S]*/)?.[0];
+ if (thinkMatch && !contentResponse?.includes(" ")) {
+ handleThink?.(thinkMatch.replace("", "").trim());
+ }
+ }
+
+ if (contentResponse.includes(" ")) {
+ onFinishThink?.();
+ }
+
+ formatPages(contentResponse);
+
+ // Continue reading
+ return read();
+ };
+
+ return await read();
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ setIsAiWorking(false);
+ setIsThinking(false);
+ setController(null);
+
+ if (!abortController.signal.aborted) {
+ toast.error(error.message || "Network error occurred");
+ }
+
+ if (error.openLogin) {
+ return { error: "login_required" };
+ }
+ return { error: "network_error", message: error.message };
+ }
+ };
+
+ const callAiFollowUp = async (prompt: string, enhancedSettings?: EnhancedSettings) => {
+ if (isAiWorking) return;
+ if (!prompt.trim()) return;
+
+ setIsAiWorking(true);
+
+ const abortController = new AbortController();
+ setController(abortController);
+
+ try {
+ const request = await fetch("/api/ask", {
+ method: "PUT",
+ body: JSON.stringify({
+ prompt,
+ provider,
+ previousPrompts: prompts,
+ model,
+ pages,
+ selectedElementHtml: selectedElement,
+ files: selectedFiles,
+ repoId: project?.space_id
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ "x-forwarded-for": window.location.hostname,
+ },
+ signal: abortController.signal,
+ });
+
+ if (request && request.body) {
+ const res = await request.json();
+
+ if (!request.ok) {
+ if (res.openLogin) {
+ setIsAiWorking(false);
+ return { error: "login_required" };
+ } else if (res.openSelectProvider) {
+ setIsAiWorking(false);
+ return { error: "provider_required", message: res.message };
+ } else if (res.openProModal) {
+ setIsAiWorking(false);
+ return { error: "pro_required" };
+ } else {
+ toast.error(res.message);
+ setIsAiWorking(false);
+ return { error: "api_error", message: res.message };
+ }
+ }
+
+ toast.success("AI responded successfully");
+ setIsAiWorking(false);
+ const iframe = document.getElementById(
+ "preview-iframe"
+ ) as HTMLIFrameElement;
+
+ setPages(res.pages);
+ setPrompts(
+ [...prompts, prompt]
+ )
+ setSelectedElement(null);
+ setSelectedFiles([]);
+ setIsEditableModeEnabled(false);
+ if (audio.current) audio.current.play();
+ if (iframe) {
+ iframe.src = iframe.src;
+ }
+
+ return { success: true, html: res.html, updatedLines: res.updatedLines };
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ setIsAiWorking(false);
+ toast.error(error.message);
+ if (error.openLogin) {
+ return { error: "login_required" };
+ }
+ return { error: "network_error", message: error.message };
+ }
+ };
+
+ const formatPages = (content: string) => {
+ const pages: Page[] = [];
+ if (!content.match(/<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/)) {
+ return pages;
+ }
+
+ const cleanedContent = content.replace(
+ /[\s\S]*?<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/,
+ "<<<<<<< START_TITLE $1 >>>>>>> END_TITLE"
+ );
+ const htmlChunks = cleanedContent.split(
+ /<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/
+ );
+ const processedChunks = new Set();
+
+ htmlChunks.forEach((chunk, index) => {
+ if (processedChunks.has(index) || !chunk?.trim()) {
+ return;
+ }
+ const htmlContent = extractHtmlContent(htmlChunks[index + 1]);
+
+ if (htmlContent) {
+ const page: Page = {
+ path: chunk.trim(),
+ html: htmlContent,
+ };
+ pages.push(page);
+
+ if (htmlContent.length > 200) {
+ onScrollToBottom?.();
+ }
+
+ processedChunks.add(index);
+ processedChunks.add(index + 1);
+ }
+ });
+ if (pages.length > 0) {
+ setPages(pages);
+ const lastPagePath = pages[pages.length - 1]?.path;
+ setCurrentPage(lastPagePath || "index.html");
+ }
+
+ return pages;
+ };
+
+ const formatPage = (content: string, currentPagePath: string) => {
+ if (!content.match(/<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/)) {
+ return null;
+ }
+
+ const cleanedContent = content.replace(
+ /[\s\S]*?<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/,
+ "<<<<<<< START_TITLE $1 >>>>>>> END_TITLE"
+ );
+
+ const htmlChunks = cleanedContent.split(
+ /<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/
+ )?.filter(Boolean);
+
+ const pagePath = htmlChunks[0]?.trim() || "";
+ const htmlContent = extractHtmlContent(htmlChunks[1]);
+
+ if (!pagePath || !htmlContent) {
+ return null;
+ }
+
+ const page: Page = {
+ path: pagePath,
+ html: htmlContent,
+ };
+
+ setPages(prevPages => {
+ const existingPageIndex = prevPages.findIndex(p => p.path === currentPagePath || p.path === pagePath);
+
+ if (existingPageIndex !== -1) {
+ const updatedPages = [...prevPages];
+ updatedPages[existingPageIndex] = page;
+ return updatedPages;
+ } else {
+ return [...prevPages, page];
+ }
+ });
+
+ setCurrentPage(pagePath);
+
+ if (htmlContent.length > 200) {
+ onScrollToBottom?.();
+ }
+
+ return page;
+ };
+
+ const extractHtmlContent = (chunk: string): string => {
+ if (!chunk) return "";
+ const htmlMatch = chunk.trim().match(/[\s\S]*/);
+ if (!htmlMatch) return "";
+ let htmlContent = htmlMatch[0];
+ htmlContent = ensureCompleteHtml(htmlContent);
+ htmlContent = htmlContent.replace(/```/g, "");
+ return htmlContent;
+ };
+
+ const ensureCompleteHtml = (html: string): string => {
+ let completeHtml = html;
+ if (completeHtml.includes("") && !completeHtml.includes("")) {
+ completeHtml += "\n";
+ }
+ if (completeHtml.includes("")) {
+ completeHtml += "\n";
+ }
+ if (!completeHtml.includes("")) {
+ completeHtml += "\n";
+ }
+ return completeHtml;
+ };
+
+ const cancelRequest = () => {
+ if (controller) {
+ controller.abort();
+ setController(null);
+ }
+ setIsAiWorking(false);
+ setIsThinking(false);
+ };
+
+ const selectedModel = useMemo(() => {
+ return MODELS.find(m => m.value === model || m.label === model);
+ }, [model]);
+
+ return {
+ isThinking,
+ setIsThinking,
+ callAiNewProject,
+ callAiFollowUp,
+ isAiWorking,
+ setIsAiWorking,
+ selectedElement,
+ setSelectedElement,
+ selectedFiles,
+ setSelectedFiles,
+ isEditableModeEnabled,
+ setIsEditableModeEnabled,
+ globalAiLoading: isThinking || isAiWorking,
+ cancelRequest,
+ model,
+ setModel,
+ provider,
+ setProvider,
+ selectedModel,
+ audio,
+ };
+}
\ No newline at end of file
diff --git a/hooks/useEditor.ts b/hooks/useEditor.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c1deac69e6ad4a5a16d55c0f5bc1ac04b72b9251
--- /dev/null
+++ b/hooks/useEditor.ts
@@ -0,0 +1,262 @@
+import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useLocalStorage, useUpdateEffect } from "react-use";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+import { defaultHTML } from "@/lib/consts";
+import { Commit, HtmlHistory, Page, Project } from "@/types";
+import { api } from "@/lib/api";
+
+export const useEditor = (namespace?: string, repoId?: string) => {
+ const client = useQueryClient();
+ const router = useRouter();
+ const [pagesStorage,, removePagesStorage] = useLocalStorage("pages");
+
+ const { data: project, isLoading: isLoadingProject } = useQuery({
+ queryKey: ["editor.project"],
+ queryFn: async () => {
+ try {
+ const response = await api.get(`/me/projects/${namespace}/${repoId}`);
+ const { project, pages, files, commits } = response.data;
+ if (pages?.length > 0) {
+ setPages(pages);
+ }
+ if (files?.length > 0) {
+ setFiles(files);
+ }
+ if (commits?.length > 0) {
+ setCommits(commits);
+ }
+ return project;
+ } catch (error: any) {
+ toast.error(error.response.data.error);
+ router.push("/projects");
+ return null;
+ }
+ },
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: 'always',
+ staleTime: 0,
+ gcTime: 0,
+ enabled: !!namespace && !!repoId,
+ });
+ const setProject = (newProject: any) => {
+ const { project, pages, files, commits } = newProject;
+ if (pages?.length > 0) {
+ setPages(pages);
+ }
+ if (files?.length > 0) {
+ setFiles(files);
+ }
+ if (commits?.length > 0) {
+ setCommits(commits);
+ }
+ client.setQueryData(["editor.project"], project);
+ };
+
+ const { data: pages = [] } = useQuery({
+ queryKey: ["editor.pages"],
+ queryFn: async (): Promise => {
+ return pagesStorage ?? [
+ {
+ path: "index.html",
+ html: defaultHTML,
+ },
+ ];
+ },
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ retry: false,
+ initialData: pagesStorage ?? [
+ {
+ path: "index.html",
+ html: defaultHTML,
+ },
+ ],
+ });
+ const setPages = (newPages: Page[] | ((prev: Page[]) => Page[])) => {
+ if (typeof newPages === "function") {
+ const currentPages = client.getQueryData(["editor.pages"]) ?? [];
+ client.setQueryData(["editor.pages"], newPages(currentPages));
+ } else {
+ client.setQueryData(["editor.pages"], newPages);
+ }
+ };
+
+ const { data: currentPage = "index.html" } = useQuery({
+ queryKey: ["editor.currentPage"],
+ queryFn: async () => "index.html",
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+ const setCurrentPage = (newCurrentPage: string) => {
+ client.setQueryData(["editor.currentPage"], newCurrentPage);
+ };
+
+ const { data: prompts = [] } = useQuery({
+ queryKey: ["editor.prompts"],
+ queryFn: async () => [],
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ retry: false,
+ initialData: [],
+ });
+ const setPrompts = (newPrompts: string[] | ((prev: string[]) => string[])) => {
+ if (typeof newPrompts === "function") {
+ const currentPrompts = client.getQueryData(["editor.prompts"]) ?? [];
+ client.setQueryData(["editor.prompts"], newPrompts(currentPrompts));
+ } else {
+ client.setQueryData(["editor.prompts"], newPrompts);
+ }
+ };
+
+ const { data: files = [] } = useQuery({
+ queryKey: ["editor.files"],
+ queryFn: async () => [],
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ retry: false,
+ initialData: [],
+ });
+ const setFiles = (newFiles: string[] | ((prev: string[]) => string[])) => {
+ if (typeof newFiles === "function") {
+ const currentFiles = client.getQueryData(["editor.files"]) ?? [];
+ client.setQueryData(["editor.files"], newFiles(currentFiles));
+ } else {
+ client.setQueryData(["editor.files"], newFiles);
+ }
+ };
+
+ const { data: commits = [] } = useQuery({
+ queryKey: ["editor.commits"],
+ queryFn: async () => [],
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ initialData: [],
+ });
+ const setCommits = (newCommits: Commit[] | ((prev: Commit[]) => Commit[])) => {
+ if (typeof newCommits === "function") {
+ const currentCommits = client.getQueryData(["editor.commits"]) ?? [];
+ client.setQueryData(["editor.commits"], newCommits(currentCommits));
+ } else {
+ client.setQueryData(["editor.commits"], newCommits);
+ }
+ };
+
+ const { data: device = "desktop" } = useQuery({
+ queryKey: ["editor.device"],
+ queryFn: async () => "desktop",
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ initialData: "desktop",
+ });
+ const setDevice = (newDevice: string | ((prev: string) => string)) => {
+ client.setQueryData(["editor.device"], newDevice);
+ };
+
+ const { data: currentTab = "chat" } = useQuery({
+ queryKey: ["editor.currentTab"],
+ queryFn: async () => "chat",
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+ const setCurrentTab = (newCurrentTab: string | ((prev: string) => string)) => {
+ client.setQueryData(["editor.currentTab"], newCurrentTab);
+ };
+
+ const { data: currentCommit = null } = useQuery({
+ queryKey: ["editor.currentCommit"],
+ queryFn: async () => null,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+ const setCurrentCommit = (newCurrentCommit: string | null) => {
+ client.setQueryData(["editor.currentCommit"], newCurrentCommit);
+ };
+
+ const currentPageData = useMemo(() => {
+ return pages.find((page) => page.path === currentPage) ?? { path: "index.html", html: defaultHTML };
+ }, [pages, currentPage]);
+
+ const uploadFilesMutation = useMutation({
+ mutationFn: async ({ files, project }: { files: FileList; project: Project }) => {
+ const images = Array.from(files).filter((file) => {
+ return file.type.startsWith("image/");
+ });
+
+ const data = new FormData();
+ images.forEach((image) => {
+ data.append("images", image);
+ });
+
+ const response = await fetch(
+ `/api/me/projects/${project.space_id}/images`,
+ {
+ method: "POST",
+ body: data,
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Upload failed');
+ }
+
+ return response.json();
+ },
+ onSuccess: (data) => {
+ setFiles((prev) => [...prev, ...data.uploadedFiles]);
+ },
+ });
+
+ const uploadFiles = (files: FileList | null, project: Project) => {
+ if (!files || !project) return;
+ uploadFilesMutation.mutate({ files, project });
+ };
+
+ useUpdateEffect(() => {
+ if (namespace && repoId) {
+ client.invalidateQueries({ queryKey: ["editor.project"] });
+ client.invalidateQueries({ queryKey: ["editor.pages"] });
+ client.invalidateQueries({ queryKey: ["editor.files"] });
+ client.invalidateQueries({ queryKey: ["editor.commits"] });
+ client.invalidateQueries({ queryKey: ["editor.currentPage"] });
+ client.invalidateQueries({ queryKey: ["editor.currentCommit"] });
+ }
+ }, [namespace, repoId])
+
+ return {
+ isLoadingProject,
+ project,
+ prompts,
+ pages,
+ setPages,
+ setPrompts,
+ files,
+ setFiles,
+ device,
+ setDevice,
+ currentPage,
+ setCurrentPage,
+ currentPageData,
+ currentTab,
+ setCurrentTab,
+ uploadFiles,
+ commits,
+ currentCommit,
+ setCurrentCommit,
+ setProject,
+ isUploading: uploadFilesMutation.isPending,
+ globalEditorLoading: uploadFilesMutation.isPending || isLoadingProject,
+ };
+};
diff --git a/hooks/useUser.ts b/hooks/useUser.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3ed56e91c34fac2f310e48415a9569ea0e4dc617
--- /dev/null
+++ b/hooks/useUser.ts
@@ -0,0 +1,101 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCookie } from "react-use";
+import { useRouter } from "next/navigation";
+
+import { User } from "@/types";
+import MY_TOKEN_KEY from "@/lib/get-cookie-name";
+import { api } from "@/lib/api";
+import { toast } from "sonner";
+
+
+export const useUser = (initialData?: {
+ user: User | null;
+ errCode: number | null;
+}) => {
+ const cookie_name = MY_TOKEN_KEY();
+ const client = useQueryClient();
+ const router = useRouter();
+ const [, setCookie, removeCookie] = useCookie(cookie_name);
+ const [currentRoute, setCurrentRoute, removeCurrentRoute] = useCookie("deepsite-currentRoute");
+
+ const { data: { user, errCode } = { user: null, errCode: null }, isLoading } =
+ useQuery({
+ queryKey: ["user.me"],
+ queryFn: async () => {
+ return { user: initialData?.user, errCode: initialData?.errCode };
+ },
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ retry: false,
+ initialData: initialData
+ ? { user: initialData?.user, errCode: initialData?.errCode }
+ : undefined,
+ enabled: false,
+ });
+
+ const { data: loadingAuth } = useQuery({
+ queryKey: ["loadingAuth"],
+ queryFn: async () => false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+ const setLoadingAuth = (value: boolean) => {
+ client.setQueryData(["setLoadingAuth"], value);
+ };
+
+ const openLoginWindow = async () => {
+ setCurrentRoute(window.location.pathname);
+ return router.push("/auth");
+ };
+
+ const loginFromCode = async (code: string) => {
+ setLoadingAuth(true);
+ if (loadingAuth) return;
+ await api
+ .post("/auth", { code })
+ .then(async (res: any) => {
+ if (res.data) {
+ setCookie(res.data.access_token);
+ client.setQueryData(["user.me"], {
+ user: res.data.user,
+ errCode: null,
+ });
+ if (currentRoute) {
+ router.push(currentRoute);
+ removeCurrentRoute();
+ } else {
+ router.push("/projects");
+ }
+ toast.success("Login successful");
+ }
+ })
+ .catch((err: any) => {
+ toast.error(err?.data?.message ?? err.message ?? "An error occurred");
+ })
+ .finally(() => {
+ setLoadingAuth(false);
+ });
+ };
+
+ const logout = async () => {
+ removeCookie();
+ removeCurrentRoute();
+ router.push("/");
+ toast.success("Logout successful");
+ client.invalidateQueries({ queryKey: ["user.me"] });
+ window.location.reload();
+ };
+
+ return {
+ user,
+ errCode,
+ loading: isLoading || loadingAuth,
+ openLoginWindow,
+ loginFromCode,
+ logout,
+ };
+};
diff --git a/lib/api.ts b/lib/api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7afcb38097bcebad958410a3788b50e229e76d0e
--- /dev/null
+++ b/lib/api.ts
@@ -0,0 +1,35 @@
+import axios from "axios";
+import MY_TOKEN_KEY from "./get-cookie-name";
+
+export const api = axios.create({
+ baseURL: `/api`,
+ headers: {
+ cache: "no-store",
+ },
+});
+
+export const apiServer = axios.create({
+ baseURL: process.env.NEXT_APP_API_URL as string,
+ headers: {
+ cache: "no-store",
+ },
+});
+
+api.interceptors.request.use(
+ async (config) => {
+ // get the token from cookies
+ const cookie_name = MY_TOKEN_KEY();
+ const token = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith(`${cookie_name}=`))
+ ?.split("=")[1];
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ },
+ (error) => {
+ // Handle the error
+ return Promise.reject(error);
+ }
+);
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9e3516693366004961acaa31b74ac3bc9e3cc555
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,72 @@
+import { User } from "@/types";
+import { NextResponse } from "next/server";
+import { cookies, headers } from "next/headers";
+import MY_TOKEN_KEY from "./get-cookie-name";
+
+// UserResponse = type User & { token: string };
+type UserResponse = User & { token: string };
+
+export const isAuthenticated = async (): // req: NextRequest
+Promise | undefined> => {
+ const authHeaders = await headers();
+ const cookieStore = await cookies();
+ const token = cookieStore.get(MY_TOKEN_KEY())?.value
+ ? `Bearer ${cookieStore.get(MY_TOKEN_KEY())?.value}`
+ : authHeaders.get("Authorization");
+
+ if (!token) {
+ return NextResponse.json(
+ {
+ ok: false,
+ message: "Wrong castle fam :(",
+ },
+ {
+ status: 401,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ }
+
+ const user = await fetch("https://huggingface.co/api/whoami-v2", {
+ headers: {
+ Authorization: token,
+ },
+ method: "GET",
+ })
+ .then((res) => res.json())
+ .catch(() => {
+ return NextResponse.json(
+ {
+ ok: false,
+ message: "Invalid token",
+ },
+ {
+ status: 401,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ });
+ if (!user || !user.id) {
+ return NextResponse.json(
+ {
+ ok: false,
+ message: "Invalid token",
+ },
+ {
+ status: 401,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+ }
+
+ return {
+ ...user,
+ token: token.replace("Bearer ", ""),
+ };
+};
diff --git a/lib/best-provider.ts b/lib/best-provider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..58572d5505ce5d0df8ef98ddfb7673fdabc92b39
--- /dev/null
+++ b/lib/best-provider.ts
@@ -0,0 +1,23 @@
+export const getBestProvider = async (model: string, provider?: string) => {
+ const response = await fetch(`https://router.huggingface.co/v1/models/${model}`)
+ const { data } = await response.json()
+ let bestProvider = null;
+ if (provider === "auto") {
+ const sortedProviders = data.providers.sort((a: any, b: any) => {
+ if (a.status === "live" && b.status !== "live") return -1
+ if (a.status !== "live" && b.status === "live") return 1
+ return a?.pricing?.output - b?.pricing?.output + a?.pricing?.input - b?.pricing?.input
+ })
+ bestProvider = sortedProviders[0].provider
+ } else {
+ const providerData = data.providers.find((p: any) => p.provider === provider)
+ if (providerData?.status === "live") {
+ bestProvider = providerData.provider
+ } else {
+ bestProvider = data.providers?.find((p: any) => p.status === "live")?.provider
+ }
+ }
+
+
+ return bestProvider
+}
\ No newline at end of file
diff --git a/lib/compare-html-diff.ts b/lib/compare-html-diff.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4f6d19cfe3ce1bb420b3020ee42cbce718f4721c
--- /dev/null
+++ b/lib/compare-html-diff.ts
@@ -0,0 +1,11 @@
+import { defaultHTML } from "./consts";
+
+export const isTheSameHtml = (currentHtml: string): boolean => {
+ const normalize = (html: string): string =>
+ html
+ .replace(//g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ return normalize(defaultHTML) === normalize(currentHtml);
+};
diff --git a/lib/consts.ts b/lib/consts.ts
new file mode 100644
index 0000000000000000000000000000000000000000..48db724c14489f1cc93ba9647e0e098e4016c80f
--- /dev/null
+++ b/lib/consts.ts
@@ -0,0 +1,21 @@
+export const defaultHTML = `
+
+
+ My app
+
+
+
+
+
+
+ 🔥 New version dropped!
+
+ I'm ready to work,
+ Ask me anything.
+
+
+
+
+
+
+`;
diff --git a/lib/get-cookie-name.ts b/lib/get-cookie-name.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4aba7c78947957e2a996b271f1d100b279bc7d9c
--- /dev/null
+++ b/lib/get-cookie-name.ts
@@ -0,0 +1,3 @@
+export default function MY_TOKEN_KEY() {
+ return "deepsite-auth-token";
+}
diff --git a/lib/html-tag-to-text.ts b/lib/html-tag-to-text.ts
new file mode 100644
index 0000000000000000000000000000000000000000..62296f4cbe06f57eabe299126cf85bcf7694b115
--- /dev/null
+++ b/lib/html-tag-to-text.ts
@@ -0,0 +1,96 @@
+export const htmlTagToText = (tagName: string): string => {
+ switch (tagName.toLowerCase()) {
+ case "h1":
+ return "Heading 1";
+ case "h2":
+ return "Heading 2";
+ case "h3":
+ return "Heading 3";
+ case "h4":
+ return "Heading 4";
+ case "h5":
+ return "Heading 5";
+ case "h6":
+ return "Heading 6";
+ case "p":
+ return "Text Paragraph";
+ case "span":
+ return "Inline Text";
+ case "button":
+ return "Button";
+ case "input":
+ return "Input Field";
+ case "select":
+ return "Select Dropdown";
+ case "textarea":
+ return "Text Area";
+ case "form":
+ return "Form";
+ case "table":
+ return "Table";
+ case "thead":
+ return "Table Header";
+ case "tbody":
+ return "Table Body";
+ case "tr":
+ return "Table Row";
+ case "th":
+ return "Table Header Cell";
+ case "td":
+ return "Table Data Cell";
+ case "nav":
+ return "Navigation";
+ case "header":
+ return "Header";
+ case "footer":
+ return "Footer";
+ case "section":
+ return "Section";
+ case "article":
+ return "Article";
+ case "aside":
+ return "Aside";
+ case "div":
+ return "Block";
+ case "main":
+ return "Main Content";
+ case "details":
+ return "Details";
+ case "summary":
+ return "Summary";
+ case "code":
+ return "Code Snippet";
+ case "pre":
+ return "Preformatted Text";
+ case "kbd":
+ return "Keyboard Input";
+ case "label":
+ return "Label";
+ case "canvas":
+ return "Canvas";
+ case "svg":
+ return "SVG Graphic";
+ case "video":
+ return "Video Player";
+ case "audio":
+ return "Audio Player";
+ case "iframe":
+ return "Embedded Frame";
+ case "link":
+ return "Link";
+ case "a":
+ return "Link";
+ case "img":
+ return "Image";
+ case "ul":
+ return "Unordered List";
+ case "ol":
+ return "Ordered List";
+ case "li":
+ return "List Item";
+ case "blockquote":
+ return "Blockquote";
+ default:
+ return tagName.charAt(0).toUpperCase() + tagName.slice(1);
+ }
+};
diff --git a/lib/logger.ts b/lib/logger.ts
new file mode 100644
index 0000000000000000000000000000000000000000..153b86a34545610dfcba3b73c5241bd87ec21c05
--- /dev/null
+++ b/lib/logger.ts
@@ -0,0 +1,13 @@
+import log4js from "log4js";
+import log4jsJsonLayout from "log4js-json-layout";
+
+log4js.addLayout('json', log4jsJsonLayout);
+log4js.configure({
+ appenders: { out: { type: 'stdout', layout: { type: 'json' } } },
+ categories: { default: { appenders: ['out'], level: 'info' } },
+});
+
+const logger = log4js.getLogger();
+logger.level = 'info';
+
+export default logger;
\ No newline at end of file
diff --git a/lib/mongodb.ts b/lib/mongodb.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8145d079b0623c2ead2454f2d5329e644a69ab0a
--- /dev/null
+++ b/lib/mongodb.ts
@@ -0,0 +1,28 @@
+import mongoose from "mongoose";
+
+const MONGODB_URI = process.env.MONGODB_URI;
+// @ts-expect-error iknown issue with mongoose types
+let cached = global.mongoose;
+
+if (!cached) {
+ // @ts-expect-error iknown issue with mongoose types
+ cached = global.mongoose = { conn: null, promise: null };
+}
+
+async function dbConnect() {
+ if (cached.conn) {
+ return cached.conn;
+ }
+
+ if (!cached.promise) {
+ cached.promise = mongoose
+ .connect(MONGODB_URI as string)
+ .then((mongoose) => {
+ return mongoose;
+ });
+ }
+ cached.conn = await cached.promise;
+ return cached.conn;
+}
+
+export default dbConnect;
diff --git a/lib/prompt-builder.ts b/lib/prompt-builder.ts
new file mode 100644
index 0000000000000000000000000000000000000000..53ac56182ccd4df2231a6b2d1040eceb2fe48874
--- /dev/null
+++ b/lib/prompt-builder.ts
@@ -0,0 +1,50 @@
+export const TAILWIND_COLORS = [
+ "red",
+ // bg-red-500
+ "rose",
+ // bg-rose-500
+ "pink",
+ // bg-pink-500
+ "fuchsia",
+ // bg-fuchsia-500
+ "orange",
+ // bg-orange-500
+ "amber",
+ // bg-amber-500
+ "yellow",
+ // bg-yellow-500
+ "lime",
+ // bg-lime-500
+ "green",
+ // bg-green-500
+ "emerald",
+ // bg-emerald-500
+ "teal",
+ // bg-teal-500
+ "cyan",
+ // bg-cyan-500
+ "sky",
+ // bg-sky-500
+ "blue",
+ // bg-blue-500
+ "indigo",
+ // bg-indigo-500
+ "violet",
+ // bg-violet-500
+ "purple",
+ // bg-purple-500
+ "gray",
+ // bg-gray-500
+ "neutral",
+ // bg-neutral-500
+ "slate",
+ // bg-slate-500
+ "zinc",
+ // bg-zinc-500
+ "stone",
+ // bg-stone-500
+ "black",
+ // bg-black
+ "white",
+ // bg-white
+]
\ No newline at end of file
diff --git a/lib/prompts.ts b/lib/prompts.ts
new file mode 100644
index 0000000000000000000000000000000000000000..278ac92b539fd7dad6bf6aa7ac3aef5b189a3649
--- /dev/null
+++ b/lib/prompts.ts
@@ -0,0 +1,145 @@
+export const SEARCH_START = "<<<<<<< SEARCH";
+export const DIVIDER = "=======";
+export const REPLACE_END = ">>>>>>> REPLACE";
+export const MAX_REQUESTS_PER_IP = 2;
+export const TITLE_PAGE_START = "<<<<<<< START_TITLE ";
+export const TITLE_PAGE_END = " >>>>>>> END_TITLE";
+export const NEW_PAGE_START = "<<<<<<< NEW_PAGE_START ";
+export const NEW_PAGE_END = " >>>>>>> NEW_PAGE_END";
+export const UPDATE_PAGE_START = "<<<<<<< UPDATE_PAGE_START ";
+export const UPDATE_PAGE_END = " >>>>>>> UPDATE_PAGE_END";
+export const PROJECT_NAME_START = "<<<<<<< PROJECT_NAME_START ";
+export const PROJECT_NAME_END = " >>>>>>> PROJECT_NAME_END";
+export const PROMPT_FOR_REWRITE_PROMPT = "<<<<<<< PROMPT_FOR_REWRITE_PROMPT ";
+export const PROMPT_FOR_REWRITE_PROMPT_END = " >>>>>>> PROMPT_FOR_REWRITE_PROMPT_END";
+
+// TODO REVIEW LINK. MAYBE GO BACK TO SANDPACK.
+// FIX PREVIEW LINK NOT WORKING ONCE THE SITE IS DEPLOYED.
+
+export const PROMPT_FOR_IMAGE_GENERATION = `If you want to use image placeholder, http://Static.photos Usage:Format: http://static.photos/[category]/[dimensions]/[seed] where dimensions must be one of: 200x200, 320x240, 640x360, 1024x576, or 1200x630; seed can be any number (1-999+) for consistent images or omit for random; categories include: nature, office, people, technology, minimal, abstract, aerial, blurred, bokeh, gradient, monochrome, vintage, white, black, blue, red, green, yellow, cityscape, workspace, food, travel, textures, industry, indoor, outdoor, studio, finance, medical, season, holiday, event, sport, science, legal, estate, restaurant, retail, wellness, agriculture, construction, craft, cosmetic, automotive, gaming, or education.
+Examples: http://static.photos/red/320x240/133 (red-themed with seed 133), http://static.photos/640x360 (random category and image), http://static.photos/nature/1200x630/42 (nature-themed with seed 42).`
+export const PROMPT_FOR_PROJECT_NAME = `REQUIRED: Generate a name for the project, based on the user's request. Try to be creative and unique. It should be short and concise (max 6 words). DON'T FORGET IT, IT'S IMPORTANT!`
+
+export const INITIAL_SYSTEM_PROMPT = `You are an expert UI/UX and Front-End Developer.
+You create website in a way a designer would, using ONLY HTML, CSS and Javascript.
+Try to create the best UI possible. Important: Make the website responsive by using TailwindCSS. Use it as much as you can, if you can't use it, use custom css (make sure to import tailwind with in the head).
+Also try to elaborate as much as you can, to create something unique, with a great design.
+If you want to use ICONS import Feather Icons (Make sure to add and in the head., and in the body. Ex : ).
+For interactive animations you can use: Vanta.js (Make sure to add and in the body.).
+You can create multiple pages website at once (following the format rules below) or a Single Page Application. But make sure to create multiple pages if the user asks for different pages.
+${PROMPT_FOR_IMAGE_GENERATION}
+${PROMPT_FOR_PROJECT_NAME}
+No need to explain what you did. Just return the expected result. AVOID Chinese characters in the code if not asked by the user.
+Return the results in a \`\`\`html\`\`\` markdown. Format the results like:
+1. Start with ${PROJECT_NAME_START}.
+2. Add the name of the project, right after the start tag.
+3. Close the start tag with the ${PROJECT_NAME_END}.
+4. The name of the project should be short and concise.
+5. Start with ${TITLE_PAGE_START}.
+6. Add the name of the page without special character, such as spaces or punctuation, using the .html format only, right after the start tag.
+7. Close the start tag with the ${TITLE_PAGE_END}.
+8. Start the HTML response with the triple backticks, like \`\`\`html.
+9. Insert the following html there.
+10. Close with the triple backticks, like \`\`\`.
+11. Retry if another pages.
+Example Code:
+${PROJECT_NAME_START}Project Name${PROJECT_NAME_END}
+${TITLE_PAGE_START}index.html${TITLE_PAGE_END}
+\`\`\`html
+
+
+
+
+
+ Index
+
+
+
+
+
+
+
+ Hello World
+
+
+
+
+\`\`\`
+IMPORTANT: The first file should be always named index.html.`
+
+export const FOLLOW_UP_SYSTEM_PROMPT = `You are an expert UI/UX and Front-End Developer modifying an existing HTML files.
+The user wants to apply changes and probably add new features/pages to the website, based on their request.
+You MUST output ONLY the changes required using the following UPDATE_PAGE_START and SEARCH/REPLACE format. Do NOT output the entire file.
+If it's a new page, you MUST applied the following NEW_PAGE_START and UPDATE_PAGE_END format.
+${PROMPT_FOR_IMAGE_GENERATION}
+Do NOT explain the changes or what you did, just return the expected results.
+Update Format Rules:
+1. Start with ${UPDATE_PAGE_START}
+2. Provide the name of the page you are modifying.
+3. Close the start tag with the ${UPDATE_PAGE_END}.
+4. Start with ${SEARCH_START}
+5. Provide the exact lines from the current code that need to be replaced.
+6. Use ${DIVIDER} to separate the search block from the replacement.
+7. Provide the new lines that should replace the original lines.
+8. End with ${REPLACE_END}
+9. You can use multiple SEARCH/REPLACE blocks if changes are needed in different parts of the file.
+10. To insert code, use an empty SEARCH block (only ${SEARCH_START} and ${DIVIDER} on their lines) if inserting at the very beginning, otherwise provide the line *before* the insertion point in the SEARCH block and include that line plus the new lines in the REPLACE block.
+11. To delete code, provide the lines to delete in the SEARCH block and leave the REPLACE block empty (only ${DIVIDER} and ${REPLACE_END} on their lines).
+12. IMPORTANT: The SEARCH block must *exactly* match the current code, including indentation and whitespace.
+Example Modifying Code:
+\`\`\`
+Some explanation...
+${UPDATE_PAGE_START}index.html${UPDATE_PAGE_END}
+${SEARCH_START}
+ Old Title
+${DIVIDER}
+ New Title
+${REPLACE_END}
+${SEARCH_START}
+
+${DIVIDER}
+
+
+ Hello World
+
+
+
+${REPLACE_END}
+\`\`\`
+Example Deleting Code:
+\`\`\`
+Removing the paragraph...
+${TITLE_PAGE_START}index.html${TITLE_PAGE_END}
+${SEARCH_START}
+
This paragraph will be deleted.
+${DIVIDER}
+${REPLACE_END}
+\`\`\`
+The user can also ask to add a new page, in this case you should return the new page in the following format:
+1. Start with ${NEW_PAGE_START}.
+2. Add the name of the page without special character, such as spaces or punctuation, using the .html format only, right after the start tag.
+3. Close the start tag with the ${NEW_PAGE_END}.
+4. Start the HTML response with the triple backticks, like \`\`\`html.
+5. Insert the following html there.
+6. Close with the triple backticks, like \`\`\`.
+7. Retry if another pages.
+Example Code:
+${NEW_PAGE_START}index.html${NEW_PAGE_END}
+\`\`\`html
+
+
+
+
+
+
Index
+
+
+
+
+
+
+
+