diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..5ef6a520780202a1d6addd833d800ccb1ecac0bb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,41 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..02be57824afcb65a06f9765ebd9f8d669b1e4f01
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "chatgpt.openOnStartup": true
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..cbe0188aaee92186937765d2c85d76f7b212c537
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+FROM node:20-alpine
+USER root
+
+USER 1000
+WORKDIR /usr/src/app
+# Copy package.json and package-lock.json to the container
+COPY --chown=1000 package.json package-lock.json ./
+
+# Copy the rest of the application files to the container
+COPY --chown=1000 . .
+
+RUN npm install
+RUN npm run build
+
+# Expose the application port (assuming your app runs on port 3000)
+EXPOSE 3000
+
+# Start the application
+CMD ["npm", "start"]
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..44ce8397377a2d59c2747673c7c0182f80127468
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+---
+title: DeepSite v3
+emoji: π³
+colorFrom: blue
+colorTo: blue
+sdk: docker
+pinned: true
+app_port: 3000
+license: mit
+short_description: Generate any application by Vibe Coding
+models:
+ - deepseek-ai/DeepSeek-V3-0324
+ - deepseek-ai/DeepSeek-R1-0528
+ - deepseek-ai/DeepSeek-V3.1
+ - deepseek-ai/DeepSeek-V3.1-Terminus
+ - deepseek-ai/DeepSeek-V3.2-Exp
+ - Qwen/Qwen3-Coder-480B-A35B-Instruct
+ - moonshotai/Kimi-K2-Instruct
+ - moonshotai/Kimi-K2-Instruct-0905
+ - zai-org/GLM-4.6
+---
+
+# DeepSite π³
+
+DeepSite is a Vibe Coding Platform designed to make coding smarter and more efficient. Tailored for developers, data scientists, and AI engineers, it integrates generative AI into your coding projects to enhance creativity and productivity.
+
+## How to use it locally
+
+Follow [this discussion](https://huggingface.co/spaces/enzostvs/deepsite/discussions/74)
+
+## Google Gemini Integration
+
+This fork adds native support for Google AI Gemini models using `@google/genai` with server-side streaming.
+
+Supported model IDs:
+
+- `gemini-2.5-pro`
+- `gemini-2.5-flash`
+- `gemini-flash-lite-latest`
+- `gemini-2.0-flash`
+- `gemini-2.0-flash-lite`
+- `learnlm-2.0-flash-experimental`
+- `gemini-robotics-er-1.5-preview`
+- `gemma-3-27b-it`
+
+Environment variable required on the server:
+
+- `GEMINI_API_KEY` β your Google AI Studio API key
+
+Notes:
+
+- The key is only accessed in server routes (e.g., `app/api/ask/route.ts`).
+- On Hugging Face Spaces, add the secret in Settings β Variables as `GEMINI_API_KEY`.
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..e2d5c88a4977801d6acabd904535b00ad622a267
--- /dev/null
+++ b/app/(public)/page.tsx
@@ -0,0 +1,5 @@
+import { MyProjects } from "@/components/my-projects";
+
+export default async function HomePage() {
+ return ;
+}
diff --git a/app/[namespace]/[repoId]/page.tsx b/app/[namespace]/[repoId]/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..19a09d72477dd437fa8a786601cbc584676609ee
--- /dev/null
+++ b/app/[namespace]/[repoId]/page.tsx
@@ -0,0 +1,28 @@
+import { AppEditor } from "@/components/editor";
+import { generateSEO } from "@/lib/seo";
+import { Metadata } from "next";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ namespace: string; repoId: string }>;
+}): Promise {
+ const { namespace, repoId } = await params;
+
+ return generateSEO({
+ title: `${namespace}/${repoId} - DeepSite Editor`,
+ description: `Edit and build ${namespace}/${repoId} with AI-powered tools on DeepSite. Create stunning websites with no code required.`,
+ path: `/${namespace}/${repoId}`,
+ // Prevent indexing of individual project editor pages if they contain sensitive content
+ noIndex: false, // Set to true if you want to keep project pages private
+ });
+}
+
+export default async function ProjectNamespacePage({
+ params,
+}: {
+ params: Promise<{ namespace: string; repoId: string }>;
+}) {
+ const { namespace, repoId } = await params;
+ 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..5f99d85273352e21e22efb85b03bced1cf210897
--- /dev/null
+++ b/app/actions/projects.ts
@@ -0,0 +1,47 @@
+"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: [],
+ };
+ }
+
+ const projects = [];
+ 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..0a1f3ea53a96a4ad1162ea690ceb272f7d6f1773
--- /dev/null
+++ b/app/api/ask/route.ts
@@ -0,0 +1,648 @@
+/* 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 { GoogleGenAI } from "@google/genai";
+
+import { MODELS } 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,
+ PROMPT_FOR_PROJECT_NAME,
+} from "@/lib/prompts";
+import { calculateMaxTokens, estimateInputTokens, getProviderSpecificConfig } from "@/lib/max-tokens";
+import MY_TOKEN_KEY from "@/lib/get-cookie-name";
+import { Page } from "@/types";
+import { createRepo, RepoDesignation, uploadFiles } from "@huggingface/hub";
+import { isAuthenticated } from "@/lib/auth";
+import { getBestProvider } from "@/lib/best-provider";
+// import { rewritePrompt } from "@/lib/rewrite-prompt";
+import { COLORS } from "@/lib/utils";
+import { templates } from "@/lib/templates";
+
+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, pages } = body;
+
+ if (!model || (!prompt && !redesignMarkdown)) {
+ return NextResponse.json(
+ { ok: false, error: "Missing required fields" },
+ { status: 400 }
+ );
+ }
+
+ const selectedModel = MODELS.find(
+ (m) => m.value === model || m.label === model
+ );
+
+ if (!selectedModel) {
+ return NextResponse.json(
+ { ok: false, error: "Invalid model selected" },
+ { status: 400 }
+ );
+ }
+
+ let token: string | null = null;
+ if (userToken) 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 = redesignMarkdown ? `Here 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.` : prompt;
+
+ if (enhancedSettings.isActive) {
+ // rewrittenPrompt = await rewritePrompt(rewrittenPrompt, enhancedSettings, { token, billTo }, selectedModel.value, selectedProvider.provider);
+ }
+
+ 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 systemPrompt = INITIAL_SYSTEM_PROMPT;
+ const userPrompt = rewrittenPrompt + (enhancedSettings.isActive ? `\n\n1. I want to use the following primary color: ${enhancedSettings.primaryColor} (eg: bg-${enhancedSettings.primaryColor}-500).\n2. I want to use the following secondary color: ${enhancedSettings.secondaryColor} (eg: bg-${enhancedSettings.secondaryColor}-500).\n3. I want to use the following theme: ${enhancedSettings.theme} mode.` : "");
+
+ // Special handling for Google Gemini
+ if (selectedProvider.provider === "google") {
+ if (!process.env.GEMINI_API_KEY) {
+ throw new Error("Missing GEMINI_API_KEY in environment");
+ }
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
+ const contents = [
+ { role: "user", parts: [{ text: systemPrompt }] },
+ { role: "user", parts: [{ text: userPrompt }] },
+ ];
+ const estimatedInputTokens = estimateInputTokens(systemPrompt, userPrompt);
+ const dynamicMaxTokens = calculateMaxTokens(selectedProvider, estimatedInputTokens, true);
+ const config: any = {
+ generationConfig: { maxOutputTokens: dynamicMaxTokens },
+ };
+ const response = await ai.models.generateContentStream({
+ model: selectedModel.value,
+ config,
+ contents,
+ });
+
+ // Newer SDKs return an async iterable of chunks with `.text`
+ // Some versions expose `.stream` iterable; support both.
+ const iterable: any = (response as any)?.stream ?? response;
+ for await (const chunk of iterable as AsyncIterable) {
+ const text = (chunk && (chunk.text ?? chunk?.candidates?.[0]?.content?.parts?.[0]?.text)) || "";
+ if (text) {
+ await writer.write(encoder.encode(text));
+ }
+ }
+ } else {
+ // Default path via Hugging Face Inference Router
+ const client = new InferenceClient(token);
+ const estimatedInputTokens = estimateInputTokens(systemPrompt, userPrompt);
+ const dynamicMaxTokens = calculateMaxTokens(selectedProvider, estimatedInputTokens, true);
+ const providerConfig = getProviderSpecificConfig(selectedProvider, dynamicMaxTokens);
+ const chatCompletion = client.chatCompletionStream(
+ {
+ model: selectedModel.value,
+ provider: selectedProvider.provider,
+ messages: [
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userPrompt },
+ ],
+ ...providerConfig,
+ },
+ 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) {
+ console.log("PUT request received");
+ 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: repoIdFromBody, isNew, enhancedSettings } =
+ body;
+
+ let repoId = repoIdFromBody;
+
+ if (!prompt || pages.length === 0) {
+ return NextResponse.json(
+ { ok: false, error: "Missing required fields" },
+ { status: 400 }
+ );
+ }
+
+ const selectedModel = MODELS.find(
+ (m) => m.value === model || m.label === model
+ );
+ if (!selectedModel) {
+ return NextResponse.json(
+ { ok: false, error: "Invalid model selected" },
+ { status: 400 }
+ );
+ }
+
+ let token = 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 escapeRegExp = (string: string) => {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ };
+
+ const createFlexibleHtmlRegex = (searchBlock: string) => {
+ let searchRegex = escapeRegExp(searchBlock)
+ .replace(/\s+/g, '\\s*')
+ .replace(/>\s*\\s*<')
+ .replace(/\s*>/g, '\\s*>');
+
+ return new RegExp(searchRegex, 'g');
+ };
+
+ const selectedProvider = await getBestProvider(selectedModel.value, provider)
+
+ try {
+ const systemPrompt = FOLLOW_UP_SYSTEM_PROMPT + (isNew ? PROMPT_FOR_PROJECT_NAME : "");
+ const userContext = "You are modifying the HTML file based on the user's request.";
+
+ // Send all pages without filtering
+ const allPages = pages || [];
+ const pagesContext = allPages
+ .map((p: Page) => `- ${p.path}\n${p.html}`)
+ .join("\n\n");
+
+ const assistantContext = `${
+ selectedElementHtml
+ ? `\n\nYou have to update ONLY the following element, NOTHING ELSE: \n\n\`\`\`html\n${selectedElementHtml}\n\`\`\` Could be in multiple pages, if so, update all the pages.`
+ : ""
+ }. Current pages (${allPages.length} total): ${pagesContext}. ${files?.length > 0 ? `Available images: ${files?.map((f: string) => f).join(', ')}.` : ""}`;
+
+ const estimatedInputTokens = estimateInputTokens(systemPrompt, prompt, userContext + assistantContext);
+ const dynamicMaxTokens = calculateMaxTokens(selectedProvider, estimatedInputTokens, false);
+
+ let chunk = "";
+
+ if (selectedProvider.provider === "google") {
+ if (!process.env.GEMINI_API_KEY) {
+ return NextResponse.json({ ok: false, message: "Missing GEMINI_API_KEY in environment" }, { status: 500 });
+ }
+ const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
+ const contents = [
+ { role: "user", parts: [{ text: systemPrompt }] },
+ { role: "user", parts: [{ text: userContext }] },
+ { role: "model", parts: [{ text: assistantContext }] },
+ { role: "user", parts: [{ text: prompt }] },
+ ];
+ const config: any = { generationConfig: { maxOutputTokens: dynamicMaxTokens } };
+ const response = await ai.models.generateContentStream({ model: selectedModel.value, config, contents });
+ const iterable: any = (response as any)?.stream ?? response;
+ for await (const c of iterable as AsyncIterable) {
+ const text = (c && (c.text ?? c?.candidates?.[0]?.content?.parts?.[0]?.text)) || "";
+ if (text) chunk += text;
+ }
+ } else {
+ const providerConfig = getProviderSpecificConfig(selectedProvider, dynamicMaxTokens);
+ const chatCompletion = client.chatCompletionStream(
+ {
+ model: selectedModel.value,
+ provider: selectedProvider.provider,
+ messages: [
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userContext },
+ { role: "assistant", content: assistantContext },
+ { role: "user", content: prompt },
+ ],
+ ...providerConfig,
+ },
+ billTo ? { billTo } : {}
+ );
+
+ while (true) {
+ const { done, value } = await chatCompletion.next();
+ if (done) break;
+ const deltaContent = value.choices[0]?.delta?.content;
+ if (deltaContent) chunk += deltaContent;
+ }
+ }
+ 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 regex = createFlexibleHtmlRegex(searchBlock);
+ const match = regex.exec(pageHtml);
+
+ if (match) {
+ const matchedText = match[0];
+ const beforeText = pageHtml.substring(0, match.index);
+ const startLineNumber = beforeText.split("\n").length;
+ const replaceLines = replaceBlock.split("\n").length;
+ const endLineNumber = startLineNumber + replaceLines - 1;
+
+ updatedLines.push([startLineNumber, endLineNumber]);
+ pageHtml = pageHtml.replace(matchedText, 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 regex = createFlexibleHtmlRegex(searchBlock);
+ const match = regex.exec(newHtml);
+
+ if (match) {
+ const matchedText = match[0];
+ const beforeText = newHtml.substring(0, match.index);
+ const startLineNumber = beforeText.split("\n").length;
+ const replaceLines = replaceBlock.split("\n").length;
+ const endLineNumber = startLineNumber + replaceLines - 1;
+
+ updatedLines.push([startLineNumber, endLineNumber]);
+ newHtml = newHtml.replace(matchedText, 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);
+ });
+
+ if (isNew) {
+ const projectName = chunk.match(/<<<<<<< PROJECT_NAME_START ([\s\S]*?) >>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
+ const formattedTitle = projectName?.toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .split("-")
+ .filter(Boolean)
+ .join("-")
+ .slice(0, 96);
+ const repo: RepoDesignation = {
+ type: "space",
+ name: `${user.name}/${formattedTitle}`,
+ };
+ const { repoUrl} = await createRepo({
+ repo,
+ accessToken: user.token as string,
+ });
+ repoId = repoUrl.split("/").slice(-2).join("/");
+ const colorFrom = COLORS[Math.floor(Math.random() * COLORS.length)];
+ const colorTo = COLORS[Math.floor(Math.random() * COLORS.length)];
+ const README = `---
+title: ${projectName}
+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).
+ `;
+ files.push(new File([README], "README.md", { type: "text/markdown" }));
+ }
+
+ const response = await uploadFiles({
+ repo: {
+ type: "space",
+ name: repoId,
+ },
+ files,
+ commitTitle: prompt,
+ accessToken: user.token as string,
+ });
+
+ return NextResponse.json({
+ ok: true,
+ updatedLines,
+ pages: updatedPages,
+ repoId,
+ commit: {
+ ...response.commit,
+ title: prompt,
+ }
+ });
+ } 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/login-url/route.ts b/app/api/auth/login-url/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ec05f2f8a66a5d85e9f7f615300871bebe9f8171
--- /dev/null
+++ b/app/api/auth/login-url/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function GET(req: NextRequest) {
+ const host = req.headers.get("host") ?? "localhost:3000";
+
+ let url: string;
+ if (host.includes("localhost")) {
+ url = host;
+ } else if (host.includes("hf.space") || host.includes("/spaces/enzostvs")) {
+ url = "enzostvs-deepsite.hf.space";
+ } else {
+ url = "deepsite.hf.co";
+ }
+
+ 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 NextResponse.json({ loginUrl: loginRedirectUrl });
+}
diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..01feb588119d8472e23d61fffbc6f43d8f5a508c
--- /dev/null
+++ b/app/api/auth/logout/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import MY_TOKEN_KEY from "@/lib/get-cookie-name";
+
+export async function POST() {
+ const cookieName = MY_TOKEN_KEY();
+ const isProduction = process.env.NODE_ENV === "production";
+
+ const response = NextResponse.json(
+ { message: "Logged out successfully" },
+ { status: 200 }
+ );
+
+ // Clear the HTTP-only cookie
+ const cookieOptions = [
+ `${cookieName}=`,
+ "Max-Age=0",
+ "Path=/",
+ "HttpOnly",
+ ...(isProduction ? ["Secure", "SameSite=None"] : ["SameSite=Lax"])
+ ].join("; ");
+
+ response.headers.set("Set-Cookie", cookieOptions);
+
+ return response;
+}
diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a57a0447aeea3d9f9f526762aaf7d4ddab16b663
--- /dev/null
+++ b/app/api/auth/route.ts
@@ -0,0 +1,106 @@
+import { NextRequest, NextResponse } from "next/server";
+import MY_TOKEN_KEY from "@/lib/get-cookie-name";
+
+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();
+
+ const cookieName = MY_TOKEN_KEY();
+ const isProduction = process.env.NODE_ENV === "production";
+
+ // Create response with user data
+ const nextResponse = NextResponse.json(
+ {
+ access_token: response.access_token,
+ expires_in: response.expires_in,
+ user,
+ // Include fallback flag for iframe contexts
+ useLocalStorageFallback: true,
+ },
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ // Set HTTP-only cookie with proper attributes for iframe support
+ const cookieOptions = [
+ `${cookieName}=${response.access_token}`,
+ `Max-Age=${response.expires_in || 3600}`, // Default 1 hour if not provided
+ "Path=/",
+ "HttpOnly",
+ ...(isProduction ? ["Secure", "SameSite=None"] : ["SameSite=Lax"])
+ ].join("; ");
+
+ nextResponse.headers.set("Set-Cookie", cookieOptions);
+
+ return nextResponse;
+}
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..1e070e50ad1654ddd200786d4feac6d6919c8502
--- /dev/null
+++ b/app/api/me/projects/[namespace]/[repoId]/commits/[commitId]/promote/route.ts
@@ -0,0 +1,190 @@
+import { NextRequest, NextResponse } from "next/server";
+import { RepoDesignation, listFiles, spaceInfo, uploadFiles, deleteFiles } from "@huggingface/hub";
+
+import { isAuthenticated } from "@/lib/auth";
+import { Page } from "@/types";
+
+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 });
+ }
+
+ const param = await params;
+ const { namespace, repoId, commitId } = param;
+
+ try {
+ const repo: RepoDesignation = {
+ type: "space",
+ name: `${namespace}/${repoId}`,
+ };
+
+ 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 }
+ );
+ }
+
+ // Fetch files from the specific commit
+ const files: File[] = [];
+ const pages: Page[] = [];
+ const allowedExtensions = ["html", "md", "css", "js", "json", "txt"];
+ const commitFilePaths: Set = new Set();
+
+ // 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 || "")) {
+ commitFilePaths.add(fileInfo.path);
+
+ // 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";
+ // Add HTML files to pages array for client-side setPages
+ pages.push({
+ path: fileInfo.path,
+ html: content,
+ });
+ 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);
+ }
+ }
+ }
+
+ // Get files currently in main branch to identify files to delete
+ const mainBranchFilePaths: Set = new Set();
+ for await (const fileInfo of listFiles({
+ repo,
+ accessToken: user.token as string,
+ revision: "main",
+ })) {
+ const fileExtension = fileInfo.path.split('.').pop()?.toLowerCase();
+
+ if (allowedExtensions.includes(fileExtension || "")) {
+ mainBranchFilePaths.add(fileInfo.path);
+ }
+ }
+
+ // Identify files to delete (exist in main but not in commit)
+ const filesToDelete: string[] = [];
+ for (const mainFilePath of mainBranchFilePaths) {
+ if (!commitFilePaths.has(mainFilePath)) {
+ filesToDelete.push(mainFilePath);
+ }
+ }
+
+ if (files.length === 0 && filesToDelete.length === 0) {
+ return NextResponse.json(
+ { ok: false, error: "No files found in the specified commit and no files to delete" },
+ { status: 404 }
+ );
+ }
+
+ // Delete files that exist in main but not in the commit being promoted
+ if (filesToDelete.length > 0) {
+ await deleteFiles({
+ repo,
+ paths: filesToDelete,
+ accessToken: user.token as string,
+ commitTitle: `Removed files from promoting ${commitId.slice(0, 7)}`,
+ commitDescription: `Removed files that don't exist in commit ${commitId}:\n${filesToDelete.map(path => `- ${path}`).join('\n')}`,
+ });
+ }
+
+ // Upload the files to the main branch with a promotion commit message
+ if (files.length > 0) {
+ 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,
+ pages: pages,
+ },
+ { status: 200 }
+ );
+
+ } catch (error: any) {
+
+ // 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..6ac05307e5bc176942f949d69bbe945da7a70323
--- /dev/null
+++ b/app/api/me/projects/[namespace]/[repoId]/images/route.ts
@@ -0,0 +1,125 @@
+import { NextRequest, NextResponse } from "next/server";
+import { RepoDesignation, spaceInfo, 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 });
+ }
+
+ const param = await params;
+ const { namespace, repoId } = param;
+
+ const space = await spaceInfo({
+ name: `${namespace}/${repoId}`,
+ accessToken: user.token as string,
+ additionalFields: ["author"],
+ });
+
+ if (!space || space.sdk !== "static") {
+ return NextResponse.json(
+ { ok: false, error: "Space is not a static space." },
+ { status: 404 }
+ );
+ }
+
+ if (space.author !== user.name) {
+ return NextResponse.json(
+ { ok: false, error: "Space does not belong to the authenticated user." },
+ { status: 403 }
+ );
+ }
+
+ // Parse the FormData to get the media files
+ const formData = await req.formData();
+ const mediaFiles = formData.getAll("images") as File[];
+
+ if (!mediaFiles || mediaFiles.length === 0) {
+ return NextResponse.json(
+ {
+ ok: false,
+ error: "At least one media file is required under the 'images' key",
+ },
+ { status: 400 }
+ );
+ }
+
+ const files: File[] = [];
+ for (const file of mediaFiles) {
+ if (!(file instanceof File)) {
+ return NextResponse.json(
+ {
+ ok: false,
+ error: "Invalid file format - all items under 'images' key must be files",
+ },
+ { status: 400 }
+ );
+ }
+
+ // Check if file is a supported media type
+ const isImage = file.type.startsWith('image/');
+ const isVideo = file.type.startsWith('video/');
+ const isAudio = file.type.startsWith('audio/');
+
+ if (!isImage && !isVideo && !isAudio) {
+ return NextResponse.json(
+ {
+ ok: false,
+ error: `File ${file.name} is not a supported media type (image, video, or audio)`,
+ },
+ { status: 400 }
+ );
+ }
+
+ // Create File object with appropriate folder prefix
+ let folderPrefix = 'images/';
+ if (isVideo) {
+ folderPrefix = 'videos/';
+ } else if (isAudio) {
+ folderPrefix = 'audio/';
+ }
+
+ const fileName = `${folderPrefix}${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} media file(s)`,
+ });
+
+ return NextResponse.json({
+ ok: true,
+ message: `Successfully uploaded ${files.length} media file(s) to ${namespace}/${repoId}/`,
+ uploadedFiles: files.map((file) => `https://huggingface.co/spaces/${namespace}/${repoId}/resolve/main/${file.name}`),
+ }, { status: 200 });
+
+ } catch (error) {
+ console.error('Error uploading media files:', error);
+ return NextResponse.json(
+ {
+ ok: false,
+ error: "Failed to upload media files",
+ },
+ { 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..dfe5b80fd351e436e5a37e9ae7da61664b7c3313
--- /dev/null
+++ b/app/api/me/projects/[namespace]/[repoId]/route.ts
@@ -0,0 +1,187 @@
+import { NextRequest, NextResponse } from "next/server";
+import { RepoDesignation, spaceInfo, listFiles, deleteRepo, listCommits, downloadFile } from "@huggingface/hub";
+
+import { isAuthenticated } from "@/lib/auth";
+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 });
+ }
+
+ const param = await params;
+ const { namespace, repoId } = param;
+
+ try {
+ const space = await spaceInfo({
+ name: `${namespace}/${repoId}`,
+ accessToken: user.token as string,
+ additionalFields: ["author"],
+ });
+
+ if (!space || space.sdk !== "static") {
+ return NextResponse.json(
+ { ok: false, error: "Space is not a static space." },
+ { status: 404 }
+ );
+ }
+
+ if (space.author !== user.name) {
+ return NextResponse.json(
+ { ok: false, error: "Space does not belong to the authenticated user." },
+ { status: 403 }
+ );
+ }
+
+ const repo: RepoDesignation = {
+ type: "space",
+ name: `${namespace}/${repoId}`,
+ };
+
+ await deleteRepo({
+ repo,
+ accessToken: user.token as string,
+ });
+
+
+ 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 });
+ }
+
+ const param = await params;
+ const { namespace, repoId } = param;
+
+ try {
+ const space = await spaceInfo({
+ name: namespace + "/" + repoId,
+ accessToken: user.token as string,
+ additionalFields: ["author"],
+ });
+
+ if (!space || space.sdk !== "static") {
+ return NextResponse.json(
+ {
+ ok: false,
+ error: "Space is not a static space",
+ },
+ { status: 404 }
+ );
+ }
+ if (space.author !== user.name) {
+ return NextResponse.json(
+ {
+ ok: false,
+ error: "Space does not belong to the authenticated user",
+ },
+ { status: 403 }
+ );
+ }
+
+ const repo: RepoDesignation = {
+ type: "space",
+ name: `${namespace}/${repoId}`,
+ };
+
+ const htmlFiles: Page[] = [];
+ const files: string[] = [];
+
+ const allowedFilesExtensions = ["jpg", "jpeg", "png", "gif", "svg", "webp", "avif", "heic", "heif", "ico", "bmp", "tiff", "tif", "mp4", "webm", "ogg", "avi", "mov", "mp3", "wav", "ogg", "aac", "m4a"];
+
+ for await (const fileInfo of listFiles({repo, accessToken: user.token as string})) {
+ if (fileInfo.path.endsWith(".html")) {
+ const blob = await downloadFile({ repo, accessToken: user.token as string, path: fileInfo.path, raw: true });
+ const html = await blob?.text();
+ if (!html) {
+ continue;
+ }
+ if (fileInfo.path === "index.html") {
+ htmlFiles.unshift({
+ path: fileInfo.path,
+ html,
+ });
+ } else {
+ htmlFiles.push({
+ path: fileInfo.path,
+ html,
+ });
+ }
+ }
+ if (fileInfo.type === "directory" && ["videos", "images", "audio"].includes(fileInfo.path)) {
+ 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("Removed files from promoting")) {
+ 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: {
+ id: space.id,
+ space_id: space.name,
+ private: space.private,
+ _updatedAt: space.updatedAt,
+ },
+ pages: htmlFiles,
+ files,
+ commits,
+ ok: true,
+ },
+ { status: 200 }
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ if (error.statusCode === 404) {
+ 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/[namespace]/[repoId]/save/route.ts b/app/api/me/projects/[namespace]/[repoId]/save/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c129dae5508f9a5e50650f7872ae4c9cd8c062a5
--- /dev/null
+++ b/app/api/me/projects/[namespace]/[repoId]/save/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from "next/server";
+import { uploadFiles } from "@huggingface/hub";
+
+import { isAuthenticated } from "@/lib/auth";
+import { Page } from "@/types";
+
+export async function PUT(
+ req: NextRequest,
+ { params }: { params: Promise<{ namespace: string; repoId: string }> }
+) {
+ const user = await isAuthenticated();
+ if (user instanceof NextResponse || !user) {
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
+ }
+
+ const param = await params;
+ const { namespace, repoId } = param;
+ const { pages, commitTitle = "Manual changes saved" } = await req.json();
+
+ if (!pages || !Array.isArray(pages) || pages.length === 0) {
+ return NextResponse.json(
+ { ok: false, error: "Pages are required" },
+ { status: 400 }
+ );
+ }
+
+ try {
+ // Prepare files for upload
+ const files: File[] = [];
+ pages.forEach((page: Page) => {
+ const file = new File([page.html], page.path, { type: "text/html" });
+ files.push(file);
+ });
+
+ // Upload files to HuggingFace Hub
+ const response = await uploadFiles({
+ repo: {
+ type: "space",
+ name: `${namespace}/${repoId}`,
+ },
+ files,
+ commitTitle,
+ accessToken: user.token as string,
+ });
+
+ return NextResponse.json({
+ ok: true,
+ pages,
+ commit: {
+ ...response.commit,
+ title: commitTitle,
+ }
+ });
+ } catch (error: any) {
+ console.error("Error saving manual changes:", error);
+ return NextResponse.json(
+ {
+ ok: false,
+ error: error.message || "Failed to save changes",
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/me/projects/route.ts b/app/api/me/projects/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a37afbc9dd93320d2bd808ca9f3981eca3936555
--- /dev/null
+++ b/app/api/me/projects/route.ts
@@ -0,0 +1,107 @@
+import { NextRequest, NextResponse } from "next/server";
+import { RepoDesignation, createRepo, listCommits, spaceInfo, uploadFiles } from "@huggingface/hub";
+
+import { isAuthenticated } from "@/lib/auth";
+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 });
+ }
+
+ 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,
+ });
+ const commitTitle = !prompt || prompt.trim() === "" ? "Redesign my website" : prompt;
+ await uploadFiles({
+ repo,
+ files,
+ accessToken: user.token as string,
+ commitTitle
+ });
+
+ const path = repoUrl.split("/").slice(-2).join("/");
+
+ 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,
+ });
+ }
+
+ const space = await spaceInfo({
+ name: repo.name,
+ accessToken: user.token as string,
+ });
+
+ let newProject = {
+ files,
+ pages,
+ commits,
+ project: {
+ id: space.id,
+ space_id: space.name,
+ _updatedAt: space.updatedAt,
+ }
+ }
+
+ 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..25bc6c81aa826d65cac703e43c5d4647ae5cd141
--- /dev/null
+++ b/app/api/me/route.ts
@@ -0,0 +1,46 @@
+import { listSpaces } from "@huggingface/hub";
+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();
+ const projects = [];
+ for await (const space of listSpaces({
+ accessToken: token.replace("Bearer ", "") as string,
+ additionalFields: ["author", "cardData"],
+ search: {
+ owner: user.name,
+ }
+ })) {
+ if (
+ 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 NextResponse.json({ user, projects, errCode: null }, { status: 200 });
+}
diff --git a/app/api/re-design/route.ts b/app/api/re-design/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..777c2cbd18f95592080c0fe1ad2cab23a5264397
--- /dev/null
+++ b/app/api/re-design/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from "next/server";
+
+export async function PUT(request: NextRequest) {
+ const body = await request.json();
+ const { url } = body;
+
+ if (!url) {
+ return NextResponse.json({ error: "URL is required" }, { status: 400 });
+ }
+
+ try {
+ const response = await fetch(
+ `https://r.jina.ai/${encodeURIComponent(url)}`,
+ {
+ method: "POST",
+ }
+ );
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch redesign" },
+ { status: 500 }
+ );
+ }
+ const markdown = await response.text();
+ return NextResponse.json(
+ {
+ ok: true,
+ markdown,
+ },
+ { status: 200 }
+ );
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ return NextResponse.json(
+ { error: error.message || "An error occurred" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6cf420cea009bbe322d10d7fc9a9d7badee467f6
--- /dev/null
+++ b/app/auth/callback/page.tsx
@@ -0,0 +1,97 @@
+"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";
+import { useBroadcastChannel } from "@/lib/useBroadcastChannel";
+export default function AuthCallback({
+ searchParams,
+}: {
+ searchParams: Promise<{ code: string }>;
+}) {
+ const [showButton, setShowButton] = useState(false);
+ const [isPopupAuth, setIsPopupAuth] = useState(false);
+ const { code } = use(searchParams);
+ const { loginFromCode } = useUser();
+ const { postMessage } = useBroadcastChannel("auth", () => {});
+
+ useMount(async () => {
+ if (code) {
+ const isPopup = window.opener || window.parent !== window;
+ setIsPopupAuth(isPopup);
+
+ if (isPopup) {
+ postMessage({
+ type: "user-oauth",
+ code: code,
+ });
+
+ setTimeout(() => {
+ if (window.opener) {
+ window.close();
+ }
+ }, 1000);
+ } else {
+ await loginFromCode(code);
+ }
+ }
+ });
+
+ useTimeoutFn(() => setShowButton(true), 7000);
+
+ return (
+
+
+
+
+
+
+
+ π
+
+
+ π
+
+
+ π
+
+
+
+ {isPopupAuth
+ ? "Authentication Complete!"
+ : "Login In Progress..."}
+
+
+ {isPopupAuth
+ ? "You can now close this tab and return to the previous page."
+ : "Wait a moment while we log you in with your code."}
+
+
+
+
+
+ If you are not redirected automatically in the next 5 seconds,
+ please click the button below
+
+ {showButton ? (
+
+
+ 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/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c
Binary files /dev/null and b/app/favicon.ico differ
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6b47502e83638b72e7c4bcec73865ed846b62036
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,128 @@
+/* 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";
+
+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";
+import { generateSEO, generateStructuredData } from "@/lib/seo";
+
+const inter = Inter({
+ variable: "--font-inter-sans",
+ subsets: ["latin"],
+});
+
+const ptSans = PT_Sans({
+ variable: "--font-ptSans-mono",
+ subsets: ["latin"],
+ weight: ["400", "700"],
+});
+
+export const metadata: Metadata = {
+ ...generateSEO({
+ 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.",
+ path: "/",
+ }),
+ appleWebApp: {
+ capable: true,
+ title: "DeepSite",
+ statusBarStyle: "black-translucent",
+ },
+ icons: {
+ icon: "/logo.svg",
+ shortcut: "/logo.svg",
+ apple: "/logo.svg",
+ },
+ verification: {
+ google: process.env.GOOGLE_SITE_VERIFICATION,
+ },
+};
+
+export const viewport: Viewport = {
+ initialScale: 1,
+ maximumScale: 1,
+ themeColor: "#000000",
+};
+
+async function getMe() {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(MY_TOKEN_KEY())?.value;
+ if (!token) return { user: null, projects: [], errCode: null };
+ try {
+ const res = await apiServer.get("/me", {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ return { user: res.data.user, projects: res.data.projects, errCode: null };
+ } catch (err: any) {
+ return { user: null, projects: [], errCode: err.status };
+ }
+}
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const data = await getMe();
+
+ // Generate structured data
+ const structuredData = generateStructuredData("WebApplication", {
+ name: "DeepSite",
+ description: "Build websites with AI, no code required",
+ url: "https://deepsite.hf.co",
+ });
+
+ const organizationData = generateStructuredData("Organization", {
+ name: "DeepSite",
+ url: "https://deepsite.hf.co",
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/app/new/page.tsx b/app/new/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6f9a3e815dd4df433d10c4b0f850ade9d6fb38fb
--- /dev/null
+++ b/app/new/page.tsx
@@ -0,0 +1,14 @@
+import { AppEditor } from "@/components/editor";
+import { Metadata } from "next";
+import { generateSEO } from "@/lib/seo";
+
+export const metadata: Metadata = generateSEO({
+ title: "Create New Project - DeepSite",
+ description:
+ "Start building your next website with AI. Create a new project on DeepSite and experience the power of AI-driven web development.",
+ path: "/new",
+});
+
+export default function NewProjectPage() {
+ return ;
+}
diff --git a/app/sitemap.ts b/app/sitemap.ts
new file mode 100644
index 0000000000000000000000000000000000000000..42b4b3583695e12a62159778c441ff8e0e7cf637
--- /dev/null
+++ b/app/sitemap.ts
@@ -0,0 +1,28 @@
+import { MetadataRoute } from 'next';
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const baseUrl = 'https://deepsite.hf.co';
+
+ return [
+ {
+ url: baseUrl,
+ lastModified: new Date(),
+ changeFrequency: 'daily',
+ priority: 1,
+ },
+ {
+ url: `${baseUrl}/new`,
+ lastModified: new Date(),
+ changeFrequency: 'weekly',
+ priority: 0.8,
+ },
+ {
+ url: `${baseUrl}/auth`,
+ lastModified: new Date(),
+ changeFrequency: 'monthly',
+ priority: 0.5,
+ },
+ // Note: Dynamic project routes will be handled by Next.js automatically
+ // but you can add specific high-priority project pages here if needed
+ ];
+}
diff --git a/assets/deepseek.svg b/assets/deepseek.svg
new file mode 100644
index 0000000000000000000000000000000000000000..dc224e43a4d68070ca6eed494476c8ddd900bf80
--- /dev/null
+++ b/assets/deepseek.svg
@@ -0,0 +1 @@
+DeepSeek
\ No newline at end of file
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/kimi.svg b/assets/kimi.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4355c522a2dece99e187d9e5c898a66313f4a374
--- /dev/null
+++ b/assets/kimi.svg
@@ -0,0 +1 @@
+Kimi
\ No newline at end of file
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/qwen.svg b/assets/qwen.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a4bb382a6359b82c581fd3e7fb7169fe8fba1657
--- /dev/null
+++ b/assets/qwen.svg
@@ -0,0 +1 @@
+Qwen
\ No newline at end of file
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/assets/zai.svg b/assets/zai.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2adcac387aaca06eb362a177e45c28ae3429b0d0
--- /dev/null
+++ b/assets/zai.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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..361ae64bdad3243019f9a711a2bf31843e9675e2
--- /dev/null
+++ b/components/contexts/app-context.tsx
@@ -0,0 +1,53 @@
+/* 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 { ProjectType, User } from "@/types";
+import { useBroadcastChannel } from "@/lib/useBroadcastChannel";
+
+export default function AppContext({
+ children,
+ me: initialData,
+}: {
+ children: React.ReactNode;
+ me?: {
+ user: User | null;
+ projects: ProjectType[];
+ 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..2aa4842b55a7f878bc75aa39a71d7b0d68e83945
--- /dev/null
+++ b/components/contexts/login-context.tsx
@@ -0,0 +1,62 @@
+"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;
+ prompt?: 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/fake-ask.tsx b/components/editor/ask-ai/fake-ask.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..41962ca7644fbd674870ed9adb16553bffdfa5a3
--- /dev/null
+++ b/components/editor/ask-ai/fake-ask.tsx
@@ -0,0 +1,97 @@
+import { useState } from "react";
+import { useLocalStorage } from "react-use";
+import { ArrowUp, Dice6 } from "lucide-react";
+import { useRouter } from "next/navigation";
+
+import { Button } from "@/components/ui/button";
+import { PromptBuilder } from "./prompt-builder";
+import { EnhancedSettings } from "@/types";
+import { Settings } from "./settings";
+import classNames from "classnames";
+import { PROMPTS_FOR_AI } from "@/lib/prompts";
+
+export const FakeAskAi = () => {
+ const router = useRouter();
+ const [prompt, setPrompt] = useState("");
+ const [openProvider, setOpenProvider] = useState(false);
+ const [enhancedSettings, setEnhancedSettings, removeEnhancedSettings] =
+ useLocalStorage("deepsite-enhancedSettings", {
+ isActive: true,
+ primaryColor: undefined,
+ secondaryColor: undefined,
+ theme: undefined,
+ });
+ const [, setPromptStorage] = useLocalStorage("prompt", "");
+ const [randomPromptLoading, setRandomPromptLoading] = useState(false);
+
+ const callAi = async () => {
+ setPromptStorage(prompt);
+ router.push("/new");
+ };
+
+ const randomPrompt = () => {
+ setRandomPromptLoading(true);
+ setTimeout(() => {
+ setPrompt(
+ PROMPTS_FOR_AI[Math.floor(Math.random() * PROMPTS_FOR_AI.length)]
+ );
+ setRandomPromptLoading(false);
+ }, 400);
+ };
+
+ return (
+
+ );
+};
diff --git a/components/editor/ask-ai/index.tsx b/components/editor/ask-ai/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3c37a353efa131b03c7588853fdcceabbb51b2cf
--- /dev/null
+++ b/components/editor/ask-ai/index.tsx
@@ -0,0 +1,322 @@
+import { useRef, useState } from "react";
+import classNames from "classnames";
+import { ArrowUp, ChevronDown, CircleStop, Dice6 } from "lucide-react";
+import { useLocalStorage, useUpdateEffect, useMount } from "react-use";
+import { toast } from "sonner";
+
+import { useAi } from "@/hooks/useAi";
+import { useEditor } from "@/hooks/useEditor";
+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 { MAX_FREE_PROJECTS } from "@/lib/utils";
+import { PROMPTS_FOR_AI } from "@/lib/prompts";
+
+export const AskAi = ({
+ project,
+ isNew,
+ onScrollToBottom,
+}: {
+ project?: Project;
+ files?: string[];
+ isNew?: boolean;
+ onScrollToBottom?: () => void;
+}) => {
+ const { user, projects } = useUser();
+ const { isSameHtml, 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 refThink = useRef(null);
+
+ const [enhancedSettings, setEnhancedSettings, removeEnhancedSettings] =
+ useLocalStorage("deepsite-enhancedSettings", {
+ isActive: false,
+ primaryColor: undefined,
+ secondaryColor: undefined,
+ theme: undefined,
+ });
+ const [promptStorage, , removePromptStorage] = useLocalStorage("prompt", "");
+
+ const [isFollowUp, setIsFollowUp] = useState(true);
+ const [prompt, setPrompt] = useState(
+ promptStorage && promptStorage.trim() !== "" ? promptStorage : ""
+ );
+ const [think, setThink] = useState("");
+ const [openThink, setOpenThink] = useState(false);
+ const [randomPromptLoading, setRandomPromptLoading] = useState(false);
+
+ useMount(() => {
+ if (promptStorage && promptStorage.trim() !== "") {
+ callAi();
+ }
+ });
+
+ const callAi = async (redesignMarkdown?: string) => {
+ removePromptStorage();
+ if (user && !user.isPro && projects.length >= MAX_FREE_PROJECTS)
+ return openProModal([]);
+ if (isAiWorking) return;
+ if (!redesignMarkdown && !prompt.trim()) return;
+
+ if (isFollowUp && !redesignMarkdown && !isSameHtml) {
+ if (!user) return openLoginModal({ prompt });
+ const result = await callAiFollowUp(prompt, enhancedSettings, isNew);
+
+ if (result?.error) {
+ handleError(result.error, result.message);
+ return;
+ }
+
+ if (result?.success) {
+ setPrompt("");
+ }
+ } else {
+ const result = await callAiNewProject(
+ prompt,
+ enhancedSettings,
+ redesignMarkdown,
+ !!user
+ );
+
+ 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");
+ }
+ };
+
+ useUpdateEffect(() => {
+ if (refThink.current) {
+ refThink.current.scrollTop = refThink.current.scrollHeight;
+ }
+ }, [think]);
+
+ const randomPrompt = () => {
+ setRandomPromptLoading(true);
+ setTimeout(() => {
+ setPrompt(
+ PROMPTS_FOR_AI[Math.floor(Math.random() * PROMPTS_FOR_AI.length)]
+ );
+ setRandomPromptLoading(false);
+ }, 400);
+ };
+
+ return (
+
+
+ {think && (
+
+ )}
+
+ setSelectedFiles(selectedFiles.filter((f) => f !== file))
+ }
+ />
+ {selectedElement && (
+
+ setSelectedElement(null)}
+ />
+
+ )}
+
+ {(isAiWorking || isUploading || isThinking || isLoadingProject) && (
+
+
+ {isAiWorking && (
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+ {!isNew &&
}
+ {isNew &&
callAi(md)} />}
+ {!isNew && !isSameHtml && }
+
+
+
+
+
+
+ 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..c22740e34cadceb16604dce377d823bf69ecc6ac
--- /dev/null
+++ b/components/editor/ask-ai/loading.tsx
@@ -0,0 +1,68 @@
+"use client";
+import Loading from "@/components/loading";
+import { useState, useEffect } from "react";
+import { useInterval } from "react-use";
+
+const TEXTS = [
+ "Teaching pixels to dance with style...",
+ "AI is having a creative breakthrough...",
+ "Channeling digital vibes into pure code...",
+ "Summoning the website spirits...",
+ "Brewing some algorithmic magic...",
+ "Composing a symphony of divs and spans...",
+ "Riding the wave of computational creativity...",
+ "Aligning the stars for perfect design...",
+ "Training circus animals to write CSS...",
+ "Launching ideas into the digital stratosphere...",
+];
+
+export const AiLoading = ({
+ text,
+ className,
+}: {
+ text?: string;
+ className?: string;
+}) => {
+ const [selectedText, setSelectedText] = useState(
+ text ?? TEXTS[0] // Start with first text to avoid hydration issues
+ );
+
+ // Set random text on client-side only to avoid hydration mismatch
+ useEffect(() => {
+ if (!text) {
+ setSelectedText(TEXTS[Math.floor(Math.random() * TEXTS.length)]);
+ }
+ }, [text]);
+
+ useInterval(() => {
+ if (!text) {
+ if (selectedText === TEXTS[TEXTS.length - 1]) {
+ setSelectedText(TEXTS[0]);
+ } else {
+ setSelectedText(TEXTS[TEXTS.indexOf(selectedText) + 1]);
+ }
+ }
+ }, 12000);
+ return (
+
+
+
+
+ {selectedText.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..d422a952a13daa275c11b292e2edaf4d4adc66be
--- /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 { Button } from "@/components/ui/button";
+import { Themes } from "./themes";
+import { EnhancedSettings } from "@/types";
+
+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..f59e8f8a95f47bba52520923f7f86e6e425a39f9
--- /dev/null
+++ b/components/editor/ask-ai/prompt-builder/index.tsx
@@ -0,0 +1,68 @@
+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 { EnhancedSettings } from "@/types";
+
+export const PromptBuilder = ({
+ enhancedSettings,
+ setEnhancedSettings,
+}: {
+ enhancedSettings: EnhancedSettings;
+ setEnhancedSettings: (settings: EnhancedSettings) => void;
+}) => {
+ const { globalAiLoading } = useAi();
+ const { globalEditorLoading } = useEditor();
+
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ {
+ 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..03c1f54c26e9455004829b385b14a7c9cf2f3df0
--- /dev/null
+++ b/components/editor/ask-ai/re-imagine.tsx
@@ -0,0 +1,152 @@
+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";
+
+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 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);
+ };
+
+ 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..7dd4acbccc7a3d2bc88c95277155acb4ea045522
--- /dev/null
+++ b/components/editor/ask-ai/selected-files.tsx
@@ -0,0 +1,54 @@
+import Image from "next/image";
+
+import { Button } from "@/components/ui/button";
+import { Minus, Video, Music } from "lucide-react";
+import { getFileType } from "./uploader";
+
+export const SelectedFiles = ({
+ files,
+ isAiWorking,
+ onDelete,
+}: {
+ files: string[];
+ isAiWorking: boolean;
+ onDelete: (file: string) => void;
+}) => {
+ if (files.length === 0) return null;
+ return (
+
+
+ {files.map((file) => (
+
+ {getFileType(file) === "image" ? (
+
+ ) : getFileType(file) === "video" ? (
+
+ ) : getFileType(file) === "audio" ? (
+
+ ) : null}
+ 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..eaac1e36930b8af68e32cd21b43e6c5a8d5db61e
--- /dev/null
+++ b/components/editor/ask-ai/settings.tsx
@@ -0,0 +1,289 @@
+"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, BrainIcon, CheckCheck, ChevronDown } from "lucide-react";
+import { useAi } from "@/hooks/useAi";
+import { getProviders } from "@/lib/get-providers";
+import Loading from "@/components/loading";
+
+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);
+ const [loadingProviders, setLoadingProviders] = 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" && !providers.includes(provider as string)) {
+ setProvider("auto");
+ }
+ }, [model, provider]);
+
+ const formattedModels = useMemo(() => {
+ const lists: ((typeof MODELS)[0] | { isCategory: true; name: string })[] =
+ [];
+ const keys = new Set();
+ MODELS.forEach((model) => {
+ if (!keys.has(model.companyName)) {
+ lists.push({
+ isCategory: true,
+ name: model.companyName,
+ logo: model.logo,
+ });
+ keys.add(model.companyName);
+ }
+ lists.push(model);
+ });
+ return lists;
+ }, [MODELS]);
+
+ const [providers, setProviders] = useState([]);
+ const [failedImages, setFailedImages] = useState>(new Set());
+
+ useEffect(() => {
+ const loadProviders = async () => {
+ setLoadingProviders(true);
+ if (!model) {
+ setProviders([]);
+ return;
+ }
+ try {
+ const result = await getProviders(model);
+ setProviders(result);
+ } catch (error) {
+ console.error("Failed to load providers:", error);
+ setProviders([]);
+ } finally {
+ setLoadingProviders(false);
+ }
+ };
+
+ loadProviders();
+ }, [model]);
+
+ const handleImageError = (providerId: string) => {
+ setFailedImages((prev) => new Set([...prev, providerId]));
+ };
+
+ return (
+
+
+
+ {/* */}
+ {selectedModel?.logo && (
+
+ )}
+
+ {isMounted
+ ? selectedModel?.label?.split(" ").join("-").toLowerCase()
+ : "..."}
+
+
+
+
+
+
+
+ {error !== "" && (
+
+ {error}
+
+ )}
+
+ Choose a model
+
+
+
+
+
+
+ {formattedModels.map((item: any) => {
+ if ("isCategory" in item) {
+ return (
+
+ {item.name}
+
+ );
+ }
+ const {
+ value,
+ label,
+ isNew = false,
+ isThinker = false,
+ } = item;
+ return (
+
+ {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
+
+
+ {loadingProviders ? (
+
+ ) : (
+ providers.map((id: string) => (
+ {
+ setProvider(id);
+ }}
+ >
+ {failedImages.has(id) ? (
+
+ ) : (
+ handleImageError(id)}
+ />
+ )}
+ {PROVIDERS?.[id as keyof typeof PROVIDERS]?.name || id}
+ {id === provider && (
+
+ )}
+
+ ))
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/components/editor/ask-ai/uploader.tsx b/components/editor/ask-ai/uploader.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..81d462a86d68afca27156e9d52dedaa7d07caedf
--- /dev/null
+++ b/components/editor/ask-ai/uploader.tsx
@@ -0,0 +1,219 @@
+import { useRef, useState } from "react";
+import {
+ CheckCircle,
+ ImageIcon,
+ Images,
+ Link,
+ Paperclip,
+ Upload,
+ Video,
+ Music,
+ FileVideo,
+} 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 getFileType = (url: string) => {
+ const extension = url.split(".").pop()?.toLowerCase();
+ if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(extension || "")) {
+ return "image";
+ } else if (["mp4", "webm", "ogg", "avi", "mov"].includes(extension || "")) {
+ return "video";
+ } else if (["mp3", "wav", "ogg", "aac", "m4a"].includes(extension || "")) {
+ return "audio";
+ }
+ return "unknown";
+};
+
+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);
+
+ const getFileIcon = (url: string) => {
+ const fileType = getFileType(url);
+ switch (fileType) {
+ case "image":
+ return ;
+ case "video":
+ return ;
+ case "audio":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ 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..6f50f1ee3db6e5ccd4c316e35fd6ea0f792ff718
--- /dev/null
+++ b/components/editor/header/index.tsx
@@ -0,0 +1,127 @@
+import { ArrowRight, HelpCircle, RefreshCcw, Lock } 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";
+import { useEditor } from "@/hooks/useEditor";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export function Header() {
+ const { project } = useEditor();
+ 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-notification/index.tsx b/components/editor/history-notification/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..12d71fc351a5209f71f2954aaefe832bb2a85bdc
--- /dev/null
+++ b/components/editor/history-notification/index.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { useState } from "react";
+import classNames from "classnames";
+import { Button } from "@/components/ui/button";
+import Loading from "@/components/loading";
+import {
+ History,
+ ChevronUp,
+ ChevronDown,
+ MousePointerClick,
+} from "lucide-react";
+
+interface HistoryNotificationProps {
+ /** Whether the historical version notification should be visible */
+ isVisible: boolean;
+ /** Whether the version promotion is in progress */
+ isPromotingVersion: boolean;
+ /** Function to promote the current historical version */
+ onPromoteVersion: () => void;
+ /** Function to go back to the current version */
+ onGoBackToCurrent: () => void;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+export const HistoryNotification = ({
+ isVisible,
+ isPromotingVersion,
+ onPromoteVersion,
+ onGoBackToCurrent,
+ className,
+}: HistoryNotificationProps) => {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+
+ {isCollapsed ? (
+ // Collapsed state
+
+
+
+ Historical Version
+
+ setIsCollapsed(false)}
+ >
+
+
+
+ ) : (
+ // Expanded state
+
+
+
+
+
+
+
+ Historical Version
+
+
+
setIsCollapsed(true)}
+ >
+
+
+
+
+ You're viewing a previous version of this project. Promote this
+ version to make it current and deploy it live.
+
+
+
+ {isPromotingVersion ? (
+
+ ) : (
+
+ )}
+ Promote Version
+
+
+ Go back to current
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/components/editor/history/index.tsx b/components/editor/history/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..179f47a5d7268f1e3403ba6aec16c4f541607643
--- /dev/null
+++ b/components/editor/history/index.tsx
@@ -0,0 +1,108 @@
+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, project } = useEditor();
+ const [open, setOpen] = useState(false);
+
+ if (commits.length === 0) return null;
+
+ return (
+
+
+
+
+ {commits?.length} edit{commits?.length !== 1 ? "s" : ""}
+
+
+
+
+
+ {project?.private && (
+
+
+ As this project is private, you can't see the history of
+ changes.
+
+
+ )}
+
+ {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 ||
+ (index === 0 && currentCommit === null) ? (
+
+ Current version
+
+ ) : (
+ !project?.private && (
+
{
+ if (index === 0) {
+ setCurrentCommit(null);
+ } else {
+ setCurrentCommit(item.oid);
+ }
+ }}
+ >
+ See version
+
+ )
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/editor/index.tsx b/components/editor/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..807d046910b99c7fcda068554b2200872d7b76d9
--- /dev/null
+++ b/components/editor/index.tsx
@@ -0,0 +1,151 @@
+"use client";
+import { useMemo, useRef, useState, useEffect } from "react";
+import { useCopyToClipboard, useLocalStorage, useMount } 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 { defaultHTML } from "@/lib/consts";
+
+import { ListPages } from "./pages";
+import { AskAi } from "./ask-ai";
+import { Preview } from "./preview";
+import { SaveChangesPopup } from "./save-changes-popup";
+import Loading from "../loading";
+import { Page } from "@/types";
+
+export const AppEditor = ({
+ namespace,
+ repoId,
+ isNew = false,
+}: {
+ namespace?: string;
+ repoId?: string;
+ isNew?: boolean;
+}) => {
+ const {
+ project,
+ setPages,
+ files,
+ currentPageData,
+ currentTab,
+ currentCommit,
+ hasUnsavedChanges,
+ saveChanges,
+ pages,
+ } = useEditor(namespace, repoId);
+ const { isAiWorking } = useAi();
+ const [, copyToClipboard] = useCopyToClipboard();
+ const [showSavePopup, setShowSavePopup] = useState(false);
+ const [pagesStorage, , removePagesStorage] = useLocalStorage("pages");
+
+ const monacoRef = useRef(null);
+ const editor = useRef(null);
+ const editorRef = useRef(null);
+
+ useMount(() => {
+ if (isNew && pagesStorage) {
+ setPages(pagesStorage);
+ removePagesStorage();
+ }
+ });
+
+ useEffect(() => {
+ if (hasUnsavedChanges && !isAiWorking && project?.space_id) {
+ setShowSavePopup(true);
+ } else {
+ setShowSavePopup(false);
+ }
+ }, [hasUnsavedChanges, isAiWorking]);
+
+ 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",
+ readOnly: !!isAiWorking || !!currentCommit,
+ readOnlyMessage: {
+ value: currentCommit
+ ? "You can't edit the code, as this is an old version of the project."
+ : "Wait for DeepSite to finish working...",
+ isTrusted: true,
+ },
+ }}
+ 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
+ );
+ }}
+ />
+
+
+
+
+ {/* Save Changes Popup */}
+ setShowSavePopup(false)}
+ onSave={saveChanges}
+ hasUnsavedChanges={hasUnsavedChanges}
+ pages={pages}
+ project={project}
+ />
+
+ );
+};
diff --git a/components/editor/pages/index.tsx b/components/editor/pages/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..20451664558f97770d9f58f67d3abc6d9653e2fc
--- /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..ba3008313f787966774aed441951497128a398de
--- /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..8f60e844336a4ea807a5401ec39f5f85358ba366
--- /dev/null
+++ b/components/editor/preview/index.tsx
@@ -0,0 +1,358 @@
+"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 { HistoryNotification } from "../history-notification";
+import { api } from "@/lib/api";
+import { toast } from "sonner";
+
+export const Preview = ({ isNew }: { isNew: boolean }) => {
+ const {
+ project,
+ device,
+ isLoadingProject,
+ currentTab,
+ currentCommit,
+ setCurrentCommit,
+ currentPageData,
+ pages,
+ setPages,
+ setCurrentPage,
+ isSameHtml,
+ } = useEditor();
+ const {
+ isEditableModeEnabled,
+ setSelectedElement,
+ isAiWorking,
+ globalAiLoading,
+ } = useAi();
+
+ const iframeRef = useRef(null);
+
+ const [hoveredElement, setHoveredElement] = useState<{
+ tagName: string;
+ rect: { top: number; left: number; width: number; height: number };
+ } | null>(null);
+ const [isPromotingVersion, setIsPromotingVersion] = useState(false);
+ const [stableHtml, setStableHtml] = useState("");
+ const [throttledHtml, setThrottledHtml] = useState("");
+ const lastUpdateTimeRef = useRef(0);
+
+ // For new projects, throttle HTML updates to every 3 seconds
+ useEffect(() => {
+ if (isNew && currentPageData?.html) {
+ const now = Date.now();
+ const timeSinceLastUpdate = now - lastUpdateTimeRef.current;
+
+ // If this is the first update or 3 seconds have passed, update immediately
+ if (lastUpdateTimeRef.current === 0 || timeSinceLastUpdate >= 3000) {
+ setThrottledHtml(currentPageData.html);
+ lastUpdateTimeRef.current = now;
+ } else {
+ // Otherwise, schedule an update for when 3 seconds will have passed
+ const timeUntilNextUpdate = 3000 - timeSinceLastUpdate;
+ const timer = setTimeout(() => {
+ setThrottledHtml(currentPageData.html);
+ lastUpdateTimeRef.current = Date.now();
+ }, timeUntilNextUpdate);
+ return () => clearTimeout(timer);
+ }
+ }
+ }, [isNew, currentPageData?.html]);
+
+ useEffect(() => {
+ if (!isAiWorking && !globalAiLoading && currentPageData?.html) {
+ setStableHtml(currentPageData.html);
+ }
+ }, [isAiWorking, globalAiLoading, currentPageData?.html]);
+
+ useEffect(() => {
+ if (
+ currentPageData?.html &&
+ !stableHtml &&
+ !isAiWorking &&
+ !globalAiLoading
+ ) {
+ setStableHtml(currentPageData.html);
+ }
+ }, [currentPageData?.html, stableHtml, isAiWorking, globalAiLoading]);
+
+ useUpdateEffect(() => {
+ const cleanupListeners = () => {
+ if (iframeRef?.current?.contentDocument) {
+ const iframeDocument = iframeRef.current.contentDocument;
+ iframeDocument.removeEventListener("mouseover", handleMouseOver);
+ iframeDocument.removeEventListener("mouseout", handleMouseOut);
+ iframeDocument.removeEventListener("click", handleClick);
+ }
+ };
+
+ if (iframeRef?.current) {
+ const iframeDocument = iframeRef.current.contentDocument;
+ if (iframeDocument) {
+ cleanupListeners();
+
+ if (isEditableModeEnabled) {
+ iframeDocument.addEventListener("mouseover", handleMouseOver);
+ iframeDocument.addEventListener("mouseout", handleMouseOut);
+ iframeDocument.addEventListener("click", handleClick);
+ }
+ }
+ }
+
+ return cleanupListeners;
+ }, [iframeRef, isEditableModeEnabled]);
+
+ const promoteVersion = async () => {
+ setIsPromotingVersion(true);
+ await api
+ .post(
+ `/me/projects/${project?.space_id}/commits/${currentCommit}/promote`
+ )
+ .then((res) => {
+ if (res.data.ok) {
+ setCurrentCommit(null);
+ setPages(res.data.pages);
+ setCurrentPage(res.data.pages[0].path);
+ toast.success("Version promoted successfully");
+ }
+ })
+ .catch((err) => {
+ toast.error(err.response.data.error);
+ });
+ setIsPromotingVersion(false);
+ };
+
+ const handleMouseOver = (event: MouseEvent) => {
+ if (iframeRef?.current) {
+ const iframeDocument = iframeRef.current.contentDocument;
+ if (iframeDocument) {
+ const targetElement = event.target as HTMLElement;
+ if (
+ hoveredElement?.tagName !== targetElement.tagName ||
+ hoveredElement?.rect.top !==
+ targetElement.getBoundingClientRect().top ||
+ hoveredElement?.rect.left !==
+ targetElement.getBoundingClientRect().left ||
+ hoveredElement?.rect.width !==
+ targetElement.getBoundingClientRect().width ||
+ hoveredElement?.rect.height !==
+ targetElement.getBoundingClientRect().height
+ ) {
+ if (targetElement !== iframeDocument.body) {
+ const rect = targetElement.getBoundingClientRect();
+ setHoveredElement({
+ tagName: targetElement.tagName,
+ rect: {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ },
+ });
+ targetElement.classList.add("hovered-element");
+ } else {
+ return setHoveredElement(null);
+ }
+ }
+ }
+ }
+ };
+ const handleMouseOut = () => {
+ setHoveredElement(null);
+ };
+ const handleClick = (event: MouseEvent) => {
+ if (iframeRef?.current) {
+ const iframeDocument = iframeRef.current.contentDocument;
+ if (iframeDocument) {
+ const targetElement = event.target as HTMLElement;
+ if (targetElement !== iframeDocument.body) {
+ setSelectedElement(targetElement);
+ }
+ }
+ }
+ };
+
+ const handleCustomNavigation = (event: MouseEvent) => {
+ if (iframeRef?.current) {
+ const iframeDocument = iframeRef.current.contentDocument;
+ if (iframeDocument) {
+ const findClosestAnchor = (
+ element: HTMLElement
+ ): HTMLAnchorElement | null => {
+ let current = element;
+ while (current && current !== iframeDocument.body) {
+ if (current.tagName === "A") {
+ return current as HTMLAnchorElement;
+ }
+ current = current.parentElement as HTMLElement;
+ }
+ return null;
+ };
+
+ const anchorElement = findClosestAnchor(event.target as HTMLElement);
+ if (anchorElement) {
+ let href = anchorElement.getAttribute("href");
+ if (href) {
+ event.stopPropagation();
+ event.preventDefault();
+
+ if (href.includes("#") && !href.includes(".html")) {
+ const targetElement = iframeDocument.querySelector(href);
+ if (targetElement) {
+ targetElement.scrollIntoView({ behavior: "smooth" });
+ }
+ return;
+ }
+
+ href = href.split(".html")[0] + ".html";
+ const isPageExist = pages.some((page) => page.path === href);
+ if (isPageExist) {
+ setCurrentPage(href);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ return (
+
+
+ {!isAiWorking && hoveredElement && isEditableModeEnabled && (
+
+
+ {htmlTagToText(hoveredElement.tagName.toLowerCase())}
+
+
+ )}
+ {isLoadingProject ? (
+
+ ) : (
+ <>
+
+ );
+};
diff --git a/components/editor/save-changes-popup/index.tsx b/components/editor/save-changes-popup/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..76c9a9153835ce859ccfd64320e4e725ad9e1b1b
--- /dev/null
+++ b/components/editor/save-changes-popup/index.tsx
@@ -0,0 +1,133 @@
+"use client";
+import { useState } from "react";
+import { toast } from "sonner";
+import { Save, X, ChevronUp, ChevronDown } from "lucide-react";
+import { motion, AnimatePresence } from "framer-motion";
+import classNames from "classnames";
+
+import { Page } from "@/types";
+import { api } from "@/lib/api";
+import { Button } from "@/components/ui/button";
+import Loading from "@/components/loading";
+
+interface SaveChangesPopupProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: () => Promise;
+ hasUnsavedChanges: boolean;
+ pages: Page[];
+ project?: any;
+}
+
+export const SaveChangesPopup = ({
+ isOpen,
+ onClose,
+ onSave,
+ hasUnsavedChanges,
+ pages,
+ project,
+}: SaveChangesPopupProps) => {
+ const [isSaving, setIsSaving] = useState(false);
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ const handleSave = async () => {
+ if (!project || !hasUnsavedChanges) return;
+
+ setIsSaving(true);
+ try {
+ await onSave();
+ toast.success("Changes saved successfully!");
+ onClose();
+ } catch (error: any) {
+ toast.error(error.message || "Failed to save changes");
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (!hasUnsavedChanges || !isOpen) return null;
+
+ return (
+
+
+ {isCollapsed ? (
+ // Collapsed state
+
+
+
+ Unsaved Changes
+
+ setIsCollapsed(false)}
+ >
+
+
+
+ ) : (
+ // Expanded state
+
+
+
+
+
+
+
setIsCollapsed(true)}
+ >
+
+
+
+
+ You have unsaved changes in your project. Save them to
+ preserve your work.
+
+
+
+ {isSaving ? (
+
+ ) : (
+
+ )}
+ Save Changes
+
+
+ Later
+
+
+
+
+
+ )}
+
+
+ );
+};
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..75b8fe845aa99c1f7cf704ca528b7666538844a4
--- /dev/null
+++ b/components/iframe-detector/index.tsx
@@ -0,0 +1,116 @@
+"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" ||
+ host === "enzostvs-deepsite.hf.space" ||
+ host === "deepsite.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;
+ }
+ };
+
+ // SEO: Add canonical URL meta tag when in iframe
+ const addCanonicalUrl = () => {
+ // Remove existing canonical link if present
+ const existingCanonical = document.querySelector('link[rel="canonical"]');
+ if (existingCanonical) {
+ existingCanonical.remove();
+ }
+
+ // Add canonical URL pointing to the standalone version
+ const canonical = document.createElement("link");
+ canonical.rel = "canonical";
+ canonical.href = window.location.href;
+ document.head.appendChild(canonical);
+
+ // Add meta tag to indicate this page should not be indexed when in iframe from unauthorized domains
+ if (isInIframe() || isEmbedded()) {
+ try {
+ const parentHostname = document.referrer
+ ? new URL(document.referrer).hostname
+ : null;
+ if (parentHostname && !isAllowedDomain(parentHostname)) {
+ // Add noindex meta tag when embedded in unauthorized domains
+ const noIndexMeta = document.createElement("meta");
+ noIndexMeta.name = "robots";
+ noIndexMeta.content = "noindex, nofollow";
+ document.head.appendChild(noIndexMeta);
+ }
+ } catch (error) {
+ // Silently handle cross-origin errors
+ console.debug(
+ "SEO: Could not determine parent domain for iframe indexing rules"
+ );
+ }
+ }
+ };
+
+ // 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;
+ }
+ };
+
+ // Always add canonical URL for SEO, regardless of iframe status
+ addCanonicalUrl();
+
+ 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..aed8e8160b9c31ae8c5a799258ed27bb383d8e18
--- /dev/null
+++ b/components/login-modal/index.tsx
@@ -0,0 +1,72 @@
+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";
+import { useEditor } from "@/hooks/useEditor";
+
+export const LoginModal = ({
+ open,
+ 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.",
+ prompt,
+}: {
+ open: boolean;
+ onClose: React.Dispatch>;
+ title?: string;
+ description?: string;
+ prompt?: string;
+}) => {
+ const { openLoginWindow } = useUser();
+ const { pages } = useEditor();
+ const [, setStorage] = useLocalStorage("pages");
+ const [, setPromptStorage] = useLocalStorage("prompt", "");
+ const handleClick = async () => {
+ if (prompt) {
+ setPromptStorage(prompt);
+ }
+ 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..7d61ecede75ab7557b1dba9da1c9f74b57de52a4
--- /dev/null
+++ b/components/my-projects/index.tsx
@@ -0,0 +1,105 @@
+"use client";
+import { Plus } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { useUser } from "@/hooks/useUser";
+import { ProjectType } from "@/types";
+import { ProjectCard } from "./project-card";
+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 { NotLogged } from "../not-logged/not-logged";
+
+export function MyProjects() {
+ const { user, projects, setProjects } = useUser();
+ const { openProModal } = useProModal();
+
+ if (!user) {
+ return ;
+ }
+
+ const onDelete = async (project: ProjectType) => {
+ const response = await api.delete(`/me/projects/${project.name}`);
+ if (response.data.ok) {
+ toast.success("Project deleted successfully!");
+ const newProjects = projects.filter((p) => p.id !== project.id);
+ setProjects(newProjects);
+ } else {
+ toast.error(response.data.error);
+ }
+ };
+ return (
+ <>
+
+
+
+ {projects.length < MAX_FREE_PROJECTS || user?.isPro ? (
+
+
+ Create Project
+
+ ) : (
+
openProModal([])}
+ >
+
+ Create Project
+
+ )}
+ {projects.map((project: ProjectType, index: number) => (
+
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..739a21011fb77886132d354c5c3a1d863b38f7b9
--- /dev/null
+++ b/components/my-projects/project-card.tsx
@@ -0,0 +1,133 @@
+import Link from "next/link";
+import { formatDistance } from "date-fns";
+import { EllipsisVertical, Lock, 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();
+ }
+ };
+ // from-gray-600 to-gray-600
+ // from-blue-600 to-blue-600
+ // from-green-600 to-green-600
+ // from-yellow-600 to-yellow-600
+ // from-purple-600 to-purple-600
+ // from-pink-600 to-pink-600
+ // from-red-600 to-red-600
+ // from-orange-600 to-orange-600
+
+ return (
+
+
+ {project.private ? (
+
+
+
+
+ Private
+
+
+
{project.cardData?.emoji}
+
+ ) : (
+
+
+
+ )}
+
+
+ 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..330b45cea1064d1e47840eee3f52956441fdf980
--- /dev/null
+++ b/components/not-logged/not-logged.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { AnimatedBlobs } from "../animated-blobs";
+import { FakeAskAi } from "../editor/ask-ai/fake-ask";
+
+export const NotLogged = () => {
+ return (
+
+
+
+ β¨ DeepSite v3 is out!
+
+
+ Welcome to DeepSite
+
+
+ Code your website with AI in seconds.
+ Access the most simple and powerful AI Vibe Code Editor to create your
+ next project.
+
+
+
+
+
+
+
+
+ π 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.
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/pro-modal/index.tsx b/components/pro-modal/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8b40e0372131f7c1a148f3f91d33da43321b361e
--- /dev/null
+++ b/components/pro-modal/index.tsx
@@ -0,0 +1,106 @@
+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..c99f6ab91dcef9d71934b90f25938ae3211982ea
--- /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: "/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 ? (
+
+ ) : (
+ <>
+
+ Start Vibe Coding
+
+
+ >
+ )}
+
+
+
+ );
+}
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..a13ae96063c5e4b0eadcc5db34fb96ae37f3001a
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,73 @@
+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",
+ xl: "h-12 rounded-full px-8 has-[>svg]:px-5",
+ 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..b120919e2e03df8b39d0ba0321a1697c93a04f6a
--- /dev/null
+++ b/components/user-menu/index.tsx
@@ -0,0 +1,81 @@
+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 = "/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..6ff352f2ef45fd0b94ae2dfa7deabb642be642fb
--- /dev/null
+++ b/hooks/useAi.ts
@@ -0,0 +1,502 @@
+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";
+import { useUser } from "./useUser";
+import { isTheSameHtml } from "@/lib/compare-html-diff";
+
+export const useAi = (onScrollToBottom?: () => void) => {
+ const client = useQueryClient();
+ const audio = useRef(null);
+ const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages, isSameHtml } = useEditor();
+ const [controller, setController] = useState(null);
+ const [storageProvider, setStorageProvider] = useLocalStorage("provider", "auto");
+ const [storageModel, setStorageModel] = useLocalStorage("model", MODELS[0].value);
+ const router = useRouter();
+ const { projects, setProjects } = useUser();
+
+ 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 () => {
+ // check if the model exist in the MODELS array
+ const selectedModel = MODELS.find(m => m.value === storageModel || m.label === storageModel);
+ if (selectedModel) {
+ return selectedModel.value;
+ }
+ return MODELS[0].value;
+ },
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ initialData: undefined,
+ });
+ const setModel = (newModel: string) => {
+ setStorageModel(newModel);
+ client.setQueryData(["ai.model"], newModel);
+ };
+
+ const createNewProject = async (prompt: string, htmlPages: Page[], projectName: string | undefined, isLoggedIn?: boolean) => {
+ if (isLoggedIn) {
+ const response = await api.post("/me/projects", {
+ title: projectName,
+ pages: htmlPages,
+ prompt,
+ });
+ if (response.data.ok) {
+ setIsAiWorking(false);
+ router.replace(`/${response.data.space.project.space_id}`);
+ setProject(response.data.space);
+ setProjects([...projects, response.data.space]);
+ toast.success("AI responded successfully");
+ if (audio.current) audio.current.play();
+ }
+ } else {
+ setIsAiWorking(false);
+ toast.success("AI responded successfully");
+ if (audio.current) audio.current.play();
+ }
+ }
+
+ const callAiNewProject = async (prompt: string, enhancedSettings?: EnhancedSettings, redesignMarkdown?: string, isLoggedIn?: boolean) => {
+ 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");
+ let contentResponse = "";
+
+ const read = async (): Promise => {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ 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);
+ let projectName = contentResponse.match(/<<<<<<< PROJECT_NAME_START ([\s\S]*?) >>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
+ if (!projectName) {
+ projectName = prompt.substring(0, 40).replace(/[^a-zA-Z0-9]/g, "-").slice(0, 40);
+ }
+ setPages(newPages);
+ setLastSavedPages([...newPages]);
+ if (newPages.length > 0 && !isTheSameHtml(newPages[0].html)) {
+ createNewProject(prompt, newPages, projectName, isLoggedIn);
+ }
+ 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
+ }
+ }
+
+ 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, isNew?: boolean) => {
+ 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?.outerHTML,
+ files: selectedFiles,
+ repoId: project?.space_id,
+ isNew,
+ enhancedSettings,
+ }),
+ 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");
+ const iframe = document.getElementById(
+ "preview-iframe"
+ ) as HTMLIFrameElement;
+
+ if (isNew && res.repoId) {
+ router.push(`/${res.repoId}`);
+ setIsAiWorking(false);
+ } else {
+ setPages(res.pages);
+ setLastSavedPages([...res.pages]); // Mark AI changes as saved
+ setCommits([res.commit, ...commits]);
+ setPrompts(
+ [...prompts, prompt]
+ )
+ setSelectedElement(null);
+ setSelectedFiles([]);
+ setIsEditableModeEnabled(false);
+ setIsAiWorking(false); // This was missing!
+ }
+
+ if (audio.current) audio.current.play();
+ if (iframe) {
+ setTimeout(() => {
+ iframe.src = iframe.src;
+ }, 500);
+ }
+
+ 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("