ongudidan commited on
Commit
cececac
·
0 Parent(s):

Initial DeepSite v2 upload

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +41 -0
  2. Dockerfile +19 -0
  3. README.md +0 -0
  4. app/(public)/layout.tsx +15 -0
  5. app/(public)/page.tsx +44 -0
  6. app/(public)/projects/page.tsx +13 -0
  7. app/actions/auth.ts +18 -0
  8. app/actions/projects.ts +63 -0
  9. app/actions/rewrite-prompt.ts +35 -0
  10. app/api/ask-ai/route.ts +510 -0
  11. app/api/auth/route.ts +86 -0
  12. app/api/me/projects/[namespace]/[repoId]/images/route.ts +111 -0
  13. app/api/me/projects/[namespace]/[repoId]/route.ts +276 -0
  14. app/api/me/projects/route.ts +127 -0
  15. app/api/me/route.ts +25 -0
  16. app/api/re-design/route.ts +39 -0
  17. app/auth/callback/page.tsx +72 -0
  18. app/auth/page.tsx +28 -0
  19. app/favicon.ico +0 -0
  20. app/layout.tsx +112 -0
  21. app/projects/[namespace]/[repoId]/page.tsx +42 -0
  22. app/projects/new/page.tsx +5 -0
  23. assets/globals.css +146 -0
  24. assets/logo.svg +316 -0
  25. assets/space.svg +7 -0
  26. components.json +21 -0
  27. components/contexts/app-context.tsx +57 -0
  28. components/contexts/user-context.tsx +8 -0
  29. components/editor/ask-ai/follow-up-tooltip.tsx +36 -0
  30. components/editor/ask-ai/index.tsx +500 -0
  31. components/editor/ask-ai/re-imagine.tsx +146 -0
  32. components/editor/ask-ai/selected-files.tsx +47 -0
  33. components/editor/ask-ai/selected-html-element.tsx +57 -0
  34. components/editor/ask-ai/settings.tsx +202 -0
  35. components/editor/ask-ai/uploader.tsx +203 -0
  36. components/editor/deploy-button/content.tsx +111 -0
  37. components/editor/deploy-button/index.tsx +79 -0
  38. components/editor/footer/index.tsx +150 -0
  39. components/editor/header/index.tsx +69 -0
  40. components/editor/history/index.tsx +73 -0
  41. components/editor/index.tsx +392 -0
  42. components/editor/pages/index.tsx +30 -0
  43. components/editor/pages/page.tsx +82 -0
  44. components/editor/preview/index.tsx +231 -0
  45. components/editor/save-button/index.tsx +76 -0
  46. components/iframe-detector.tsx +75 -0
  47. components/iframe-warning-modal.tsx +61 -0
  48. components/invite-friends/index.tsx +85 -0
  49. components/loading/index.tsx +41 -0
  50. components/login-modal/index.tsx +62 -0
.gitignore ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+ USER root
3
+
4
+ USER 1000
5
+ WORKDIR /usr/src/app
6
+ # Copy package.json and package-lock.json to the container
7
+ COPY --chown=1000 package.json package-lock.json ./
8
+
9
+ # Copy the rest of the application files to the container
10
+ COPY --chown=1000 . .
11
+
12
+ RUN npm install
13
+ RUN npm run build
14
+
15
+ # Expose the application port (assuming your app runs on port 3000)
16
+ EXPOSE 3000
17
+
18
+ # Start the application
19
+ CMD ["npm", "start"]
README.md ADDED
Binary file (708 Bytes). View file
 
app/(public)/layout.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Navigation from "@/components/public/navigation";
2
+
3
+ export default async function PublicLayout({
4
+ children,
5
+ }: Readonly<{
6
+ children: React.ReactNode;
7
+ }>) {
8
+ return (
9
+ <div className="min-h-screen bg-black z-1 relative">
10
+ <div className="background__noisy" />
11
+ <Navigation />
12
+ {children}
13
+ </div>
14
+ );
15
+ }
app/(public)/page.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AskAi } from "@/components/space/ask-ai";
2
+ import { redirect } from "next/navigation";
3
+ export default function Home() {
4
+ redirect("/projects/new");
5
+ return (
6
+ <>
7
+ <header className="container mx-auto pt-20 px-6 relative flex flex-col items-center justify-center text-center">
8
+ <div className="rounded-full border border-neutral-100/10 bg-neutral-100/5 text-xs text-neutral-300 px-3 py-1 max-w-max mx-auto mb-2">
9
+ ✨ DeepSite Public Beta
10
+ </div>
11
+ <h1 className="text-8xl font-semibold text-white font-mono max-w-4xl">
12
+ Code your website with AI in seconds
13
+ </h1>
14
+ <p className="text-2xl text-neutral-300/80 mt-4 text-center max-w-2xl">
15
+ Vibe Coding has never been so easy.
16
+ </p>
17
+ <div className="mt-14 max-w-2xl w-full mx-auto">
18
+ <AskAi />
19
+ </div>
20
+ <div className="absolute inset-0 pointer-events-none -z-[1]">
21
+ <div className="w-full h-full bg-gradient-to-r from-purple-500 to-pink-500 opacity-10 blur-3xl rounded-full" />
22
+ <div className="w-2/3 h-3/4 bg-gradient-to-r from-blue-500 to-teal-500 opacity-24 blur-3xl absolute -top-20 right-10 transform rotate-12" />
23
+ <div className="w-1/2 h-1/2 bg-gradient-to-r from-amber-500 to-rose-500 opacity-20 blur-3xl absolute bottom-0 left-10 rounded-3xl" />
24
+ <div className="w-48 h-48 bg-gradient-to-r from-cyan-500 to-indigo-500 opacity-20 blur-3xl absolute top-1/3 right-1/3 rounded-lg transform -rotate-15" />
25
+ </div>
26
+ </header>
27
+ <div id="community" className="h-screen flex items-center justify-center">
28
+ <h1 className="text-7xl font-extrabold text-white font-mono">
29
+ Community Driven
30
+ </h1>
31
+ </div>
32
+ <div id="deploy" className="h-screen flex items-center justify-center">
33
+ <h1 className="text-7xl font-extrabold text-white font-mono">
34
+ Deploy your website in seconds
35
+ </h1>
36
+ </div>
37
+ <div id="features" className="h-screen flex items-center justify-center">
38
+ <h1 className="text-7xl font-extrabold text-white font-mono">
39
+ Features that make you smile
40
+ </h1>
41
+ </div>
42
+ </>
43
+ );
44
+ }
app/(public)/projects/page.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "next/navigation";
2
+
3
+ import { MyProjects } from "@/components/my-projects";
4
+ import { getProjects } from "@/app/actions/projects";
5
+
6
+ export default async function ProjectsPage() {
7
+ const { ok, projects } = await getProjects();
8
+ if (!ok) {
9
+ redirect("/");
10
+ }
11
+
12
+ return <MyProjects projects={projects} />;
13
+ }
app/actions/auth.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { headers } from "next/headers";
4
+
5
+ export async function getAuth() {
6
+ const authList = await headers();
7
+ const host = authList.get("host") ?? "localhost:3000";
8
+ const url = host.includes("/spaces/enzostvs")
9
+ ? "enzostvs-deepsite.hf.space"
10
+ : host;
11
+ const redirect_uri =
12
+ `${host.includes("localhost") ? "http://" : "https://"}` +
13
+ url +
14
+ "/auth/callback";
15
+
16
+ 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`;
17
+ return loginRedirectUrl;
18
+ }
app/actions/projects.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { isAuthenticated } from "@/lib/auth";
4
+ import { NextResponse } from "next/server";
5
+ import dbConnect from "@/lib/mongodb";
6
+ import Project from "@/models/Project";
7
+ import { Project as ProjectType } from "@/types";
8
+
9
+ export async function getProjects(): Promise<{
10
+ ok: boolean;
11
+ projects: ProjectType[];
12
+ }> {
13
+ const user = await isAuthenticated();
14
+
15
+ if (user instanceof NextResponse || !user) {
16
+ return {
17
+ ok: false,
18
+ projects: [],
19
+ };
20
+ }
21
+
22
+ await dbConnect();
23
+ const projects = await Project.find({
24
+ user_id: user?.id,
25
+ })
26
+ .sort({ _createdAt: -1 })
27
+ .limit(100)
28
+ .lean();
29
+ if (!projects) {
30
+ return {
31
+ ok: false,
32
+ projects: [],
33
+ };
34
+ }
35
+ return {
36
+ ok: true,
37
+ projects: JSON.parse(JSON.stringify(projects)) as ProjectType[],
38
+ };
39
+ }
40
+
41
+ export async function getProject(
42
+ namespace: string,
43
+ repoId: string
44
+ ): Promise<ProjectType | null> {
45
+ const user = await isAuthenticated();
46
+
47
+ if (user instanceof NextResponse || !user) {
48
+ return null;
49
+ }
50
+
51
+ await dbConnect();
52
+ const project = await Project.findOne({
53
+ user_id: user.id,
54
+ namespace,
55
+ repoId,
56
+ }).lean();
57
+
58
+ if (!project) {
59
+ return null;
60
+ }
61
+
62
+ return JSON.parse(JSON.stringify(project)) as ProjectType;
63
+ }
app/actions/rewrite-prompt.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { InferenceClient } from "@huggingface/inference";
2
+
3
+ const START_REWRITE_PROMPT = ">>>>>>> START PROMPT >>>>>>";
4
+ const END_REWRITE_PROMPT = ">>>>>>> END PROMPT >>>>>>";
5
+
6
+ export const callAiRewritePrompt = async (prompt: string, { token, billTo }: { token: string, billTo?: string | null }) => {
7
+ const client = new InferenceClient(token);
8
+ const response = await client.chatCompletion(
9
+ {
10
+ model: "deepseek-ai/DeepSeek-V3.1",
11
+ provider: "novita",
12
+ messages: [{
13
+ role: "system",
14
+ content: `You are a helpful assistant that rewrites prompts to make them better. All the prompts will be about creating a website or app.
15
+ Try to make the prompt more detailed and specific to create a good UI/UX Design and good code.
16
+ Format the result by following this format:
17
+ ${START_REWRITE_PROMPT}
18
+ new prompt here
19
+ ${END_REWRITE_PROMPT}
20
+ If you don't rewrite the prompt, return the original prompt.
21
+ Make sure to return the prompt in the same language as the prompt you are given. Also IMPORTANT: Make sure to keep the original intent of the prompt. Improve it it needed, but don't change the original intent.
22
+ `
23
+ },{ role: "user", content: prompt }],
24
+ },
25
+ billTo ? { billTo } : {}
26
+ );
27
+
28
+ const responseContent = response.choices[0]?.message?.content;
29
+ if (!responseContent) {
30
+ return prompt;
31
+ }
32
+ const startIndex = responseContent.indexOf(START_REWRITE_PROMPT);
33
+ const endIndex = responseContent.indexOf(END_REWRITE_PROMPT);
34
+ return responseContent.substring(startIndex + START_REWRITE_PROMPT.length, endIndex);
35
+ };
app/api/ask-ai/route.ts ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import type { NextRequest } from "next/server";
3
+ import { NextResponse } from "next/server";
4
+ import { headers } from "next/headers";
5
+ import { InferenceClient } from "@huggingface/inference";
6
+
7
+ import { MODELS, PROVIDERS } from "@/lib/providers";
8
+ import {
9
+ DIVIDER,
10
+ FOLLOW_UP_SYSTEM_PROMPT,
11
+ INITIAL_SYSTEM_PROMPT,
12
+ MAX_REQUESTS_PER_IP,
13
+ NEW_PAGE_END,
14
+ NEW_PAGE_START,
15
+ REPLACE_END,
16
+ SEARCH_START,
17
+ UPDATE_PAGE_START,
18
+ UPDATE_PAGE_END,
19
+ } from "@/lib/prompts";
20
+ import MY_TOKEN_KEY from "@/lib/get-cookie-name";
21
+ import { Page } from "@/types";
22
+
23
+ const ipAddresses = new Map();
24
+
25
+ export async function POST(request: NextRequest) {
26
+ const authHeaders = await headers();
27
+ const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
28
+
29
+ const body = await request.json();
30
+ const { prompt, provider, model, redesignMarkdown, previousPrompts, pages } = body;
31
+
32
+ if (!model || (!prompt && !redesignMarkdown)) {
33
+ return NextResponse.json(
34
+ { ok: false, error: "Missing required fields" },
35
+ { status: 400 }
36
+ );
37
+ }
38
+
39
+ const selectedModel = MODELS.find(
40
+ (m) => m.value === model || m.label === model
41
+ );
42
+
43
+ if (!selectedModel) {
44
+ return NextResponse.json(
45
+ { ok: false, error: "Invalid model selected" },
46
+ { status: 400 }
47
+ );
48
+ }
49
+
50
+ if (!selectedModel.providers.includes(provider) && provider !== "auto") {
51
+ return NextResponse.json(
52
+ {
53
+ ok: false,
54
+ error: `The selected model does not support the ${provider} provider.`,
55
+ openSelectProvider: true,
56
+ },
57
+ { status: 400 }
58
+ );
59
+ }
60
+
61
+ let token = userToken;
62
+ let billTo: string | null = null;
63
+
64
+ /**
65
+ * Handle local usage token, this bypass the need for a user token
66
+ * and allows local testing without authentication.
67
+ * This is useful for development and testing purposes.
68
+ */
69
+ if (process.env.HF_TOKEN && process.env.HF_TOKEN.length > 0) {
70
+ token = process.env.HF_TOKEN;
71
+ }
72
+
73
+ const ip = authHeaders.get("x-forwarded-for")?.includes(",")
74
+ ? authHeaders.get("x-forwarded-for")?.split(",")[1].trim()
75
+ : authHeaders.get("x-forwarded-for");
76
+
77
+ if (!token) {
78
+ ipAddresses.set(ip, (ipAddresses.get(ip) || 0) + 1);
79
+ if (ipAddresses.get(ip) > MAX_REQUESTS_PER_IP) {
80
+ return NextResponse.json(
81
+ {
82
+ ok: false,
83
+ openLogin: true,
84
+ message: "Log In to continue using the service",
85
+ },
86
+ { status: 429 }
87
+ );
88
+ }
89
+
90
+ token = process.env.DEFAULT_HF_TOKEN as string;
91
+ billTo = "huggingface";
92
+ }
93
+
94
+ const DEFAULT_PROVIDER = PROVIDERS.novita;
95
+ const selectedProvider =
96
+ provider === "auto"
97
+ ? PROVIDERS[selectedModel.autoProvider as keyof typeof PROVIDERS]
98
+ : PROVIDERS[provider as keyof typeof PROVIDERS] ?? DEFAULT_PROVIDER;
99
+
100
+ const rewrittenPrompt = prompt;
101
+
102
+ // if (prompt?.length < 240) {
103
+
104
+ //rewrittenPrompt = await callAiRewritePrompt(prompt, { token, billTo });
105
+ // }
106
+
107
+ try {
108
+ const encoder = new TextEncoder();
109
+ const stream = new TransformStream();
110
+ const writer = stream.writable.getWriter();
111
+
112
+ const response = new NextResponse(stream.readable, {
113
+ headers: {
114
+ "Content-Type": "text/plain; charset=utf-8",
115
+ "Cache-Control": "no-cache",
116
+ Connection: "keep-alive",
117
+ },
118
+ });
119
+
120
+ (async () => {
121
+ // let completeResponse = "";
122
+ try {
123
+ const client = new InferenceClient(token);
124
+ const chatCompletion = client.chatCompletionStream(
125
+ {
126
+ model: selectedModel.value,
127
+ provider: selectedProvider.id as any,
128
+ messages: [
129
+ {
130
+ role: "system",
131
+ content: INITIAL_SYSTEM_PROMPT,
132
+ },
133
+ ...(pages?.length > 1 ? [{
134
+ role: "assistant",
135
+ content: `Here are the current pages:\n\n${pages.map((p: Page) => `- ${p.path} \n${p.html}`).join("\n")}\n\nNow, please create a new page based on this code. Also here are the previous prompts:\n\n${previousPrompts.map((p: string) => `- ${p}`).join("\n")}`
136
+ }] : []),
137
+ {
138
+ role: "user",
139
+ content: redesignMarkdown
140
+ ? `Here is my current design as a markdown:\n\n${redesignMarkdown}\n\nNow, please create a new design based on this markdown.`
141
+ : rewrittenPrompt,
142
+ },
143
+ ],
144
+ max_tokens: selectedProvider.max_tokens,
145
+ },
146
+ billTo ? { billTo } : {}
147
+ );
148
+
149
+ while (true) {
150
+ const { done, value } = await chatCompletion.next();
151
+ if (done) {
152
+ break;
153
+ }
154
+
155
+ const chunk = value.choices[0]?.delta?.content;
156
+ if (chunk) {
157
+ await writer.write(encoder.encode(chunk));
158
+ }
159
+ }
160
+ } catch (error: any) {
161
+ if (error.message?.includes("exceeded your monthly included credits")) {
162
+ await writer.write(
163
+ encoder.encode(
164
+ JSON.stringify({
165
+ ok: false,
166
+ openProModal: true,
167
+ message: error.message,
168
+ })
169
+ )
170
+ );
171
+ } else {
172
+ await writer.write(
173
+ encoder.encode(
174
+ JSON.stringify({
175
+ ok: false,
176
+ message:
177
+ error.message ||
178
+ "An error occurred while processing your request.",
179
+ })
180
+ )
181
+ );
182
+ }
183
+ } finally {
184
+ await writer?.close();
185
+ }
186
+ })();
187
+
188
+ return response;
189
+ } catch (error: any) {
190
+ return NextResponse.json(
191
+ {
192
+ ok: false,
193
+ openSelectProvider: true,
194
+ message:
195
+ error?.message || "An error occurred while processing your request.",
196
+ },
197
+ { status: 500 }
198
+ );
199
+ }
200
+ }
201
+
202
+ export async function PUT(request: NextRequest) {
203
+ const authHeaders = await headers();
204
+ const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
205
+
206
+ const body = await request.json();
207
+ const { prompt, previousPrompts, provider, selectedElementHtml, model, pages, files, } =
208
+ body;
209
+
210
+ if (!prompt || pages.length === 0) {
211
+ return NextResponse.json(
212
+ { ok: false, error: "Missing required fields" },
213
+ { status: 400 }
214
+ );
215
+ }
216
+
217
+ const selectedModel = MODELS.find(
218
+ (m) => m.value === model || m.label === model
219
+ );
220
+ if (!selectedModel) {
221
+ return NextResponse.json(
222
+ { ok: false, error: "Invalid model selected" },
223
+ { status: 400 }
224
+ );
225
+ }
226
+
227
+ let token = userToken;
228
+ let billTo: string | null = null;
229
+
230
+ /**
231
+ * Handle local usage token, this bypass the need for a user token
232
+ * and allows local testing without authentication.
233
+ * This is useful for development and testing purposes.
234
+ */
235
+ if (process.env.HF_TOKEN && process.env.HF_TOKEN.length > 0) {
236
+ token = process.env.HF_TOKEN;
237
+ }
238
+
239
+ const ip = authHeaders.get("x-forwarded-for")?.includes(",")
240
+ ? authHeaders.get("x-forwarded-for")?.split(",")[1].trim()
241
+ : authHeaders.get("x-forwarded-for");
242
+
243
+ if (!token) {
244
+ ipAddresses.set(ip, (ipAddresses.get(ip) || 0) + 1);
245
+ if (ipAddresses.get(ip) > MAX_REQUESTS_PER_IP) {
246
+ return NextResponse.json(
247
+ {
248
+ ok: false,
249
+ openLogin: true,
250
+ message: "Log In to continue using the service",
251
+ },
252
+ { status: 429 }
253
+ );
254
+ }
255
+
256
+ token = process.env.DEFAULT_HF_TOKEN as string;
257
+ billTo = "huggingface";
258
+ }
259
+
260
+ const client = new InferenceClient(token);
261
+
262
+ const DEFAULT_PROVIDER = PROVIDERS.novita;
263
+ const selectedProvider =
264
+ provider === "auto"
265
+ ? PROVIDERS[selectedModel.autoProvider as keyof typeof PROVIDERS]
266
+ : PROVIDERS[provider as keyof typeof PROVIDERS] ?? DEFAULT_PROVIDER;
267
+
268
+ try {
269
+ const response = await client.chatCompletion(
270
+ {
271
+ model: selectedModel.value,
272
+ provider: selectedProvider.id as any,
273
+ messages: [
274
+ {
275
+ role: "system",
276
+ content: FOLLOW_UP_SYSTEM_PROMPT,
277
+ },
278
+ {
279
+ role: "user",
280
+ content: previousPrompts
281
+ ? `Also here are the previous prompts:\n\n${previousPrompts.map((p: string) => `- ${p}`).join("\n")}`
282
+ : "You are modifying the HTML file based on the user's request.",
283
+ },
284
+ {
285
+ role: "assistant",
286
+
287
+ content: `${
288
+ selectedElementHtml
289
+ ? `\n\nYou have to update ONLY the following element, NOTHING ELSE: \n\n\`\`\`html\n${selectedElementHtml}\n\`\`\``
290
+ : ""
291
+ }. Current pages: ${pages?.map((p: Page) => `- ${p.path} \n${p.html}`).join("\n")}. ${files?.length > 0 ? `Current images: ${files?.map((f: string) => `- ${f}`).join("\n")}.` : ""}`,
292
+ },
293
+ {
294
+ role: "user",
295
+ content: prompt,
296
+ },
297
+ ],
298
+ ...(selectedProvider.id !== "sambanova"
299
+ ? {
300
+ max_tokens: selectedProvider.max_tokens,
301
+ }
302
+ : {}),
303
+ },
304
+ billTo ? { billTo } : {}
305
+ );
306
+
307
+ const chunk = response.choices[0]?.message?.content;
308
+ if (!chunk) {
309
+ return NextResponse.json(
310
+ { ok: false, message: "No content returned from the model" },
311
+ { status: 400 }
312
+ );
313
+ }
314
+
315
+ if (chunk) {
316
+ const updatedLines: number[][] = [];
317
+ let newHtml = "";
318
+ const updatedPages = [...(pages || [])];
319
+
320
+ 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');
321
+ let updatePageMatch;
322
+
323
+ while ((updatePageMatch = updatePageRegex.exec(chunk)) !== null) {
324
+ const [, pagePath, pageContent] = updatePageMatch;
325
+
326
+ const pageIndex = updatedPages.findIndex(p => p.path === pagePath);
327
+ if (pageIndex !== -1) {
328
+ let pageHtml = updatedPages[pageIndex].html;
329
+
330
+ let processedContent = pageContent;
331
+ const htmlMatch = pageContent.match(/```html\s*([\s\S]*?)\s*```/);
332
+ if (htmlMatch) {
333
+ processedContent = htmlMatch[1];
334
+ }
335
+ let position = 0;
336
+ let moreBlocks = true;
337
+
338
+ while (moreBlocks) {
339
+ const searchStartIndex = processedContent.indexOf(SEARCH_START, position);
340
+ if (searchStartIndex === -1) {
341
+ moreBlocks = false;
342
+ continue;
343
+ }
344
+
345
+ const dividerIndex = processedContent.indexOf(DIVIDER, searchStartIndex);
346
+ if (dividerIndex === -1) {
347
+ moreBlocks = false;
348
+ continue;
349
+ }
350
+
351
+ const replaceEndIndex = processedContent.indexOf(REPLACE_END, dividerIndex);
352
+ if (replaceEndIndex === -1) {
353
+ moreBlocks = false;
354
+ continue;
355
+ }
356
+
357
+ const searchBlock = processedContent.substring(
358
+ searchStartIndex + SEARCH_START.length,
359
+ dividerIndex
360
+ );
361
+ const replaceBlock = processedContent.substring(
362
+ dividerIndex + DIVIDER.length,
363
+ replaceEndIndex
364
+ );
365
+
366
+ if (searchBlock.trim() === "") {
367
+ pageHtml = `${replaceBlock}\n${pageHtml}`;
368
+ updatedLines.push([1, replaceBlock.split("\n").length]);
369
+ } else {
370
+ const blockPosition = pageHtml.indexOf(searchBlock);
371
+ if (blockPosition !== -1) {
372
+ const beforeText = pageHtml.substring(0, blockPosition);
373
+ const startLineNumber = beforeText.split("\n").length;
374
+ const replaceLines = replaceBlock.split("\n").length;
375
+ const endLineNumber = startLineNumber + replaceLines - 1;
376
+
377
+ updatedLines.push([startLineNumber, endLineNumber]);
378
+ pageHtml = pageHtml.replace(searchBlock, replaceBlock);
379
+ }
380
+ }
381
+
382
+ position = replaceEndIndex + REPLACE_END.length;
383
+ }
384
+
385
+ updatedPages[pageIndex].html = pageHtml;
386
+
387
+ if (pagePath === '/' || pagePath === '/index' || pagePath === 'index') {
388
+ newHtml = pageHtml;
389
+ }
390
+ }
391
+ }
392
+
393
+ 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');
394
+ let newPageMatch;
395
+
396
+ while ((newPageMatch = newPageRegex.exec(chunk)) !== null) {
397
+ const [, pagePath, pageContent] = newPageMatch;
398
+
399
+ let pageHtml = pageContent;
400
+ const htmlMatch = pageContent.match(/```html\s*([\s\S]*?)\s*```/);
401
+ if (htmlMatch) {
402
+ pageHtml = htmlMatch[1];
403
+ }
404
+
405
+ const existingPageIndex = updatedPages.findIndex(p => p.path === pagePath);
406
+
407
+ if (existingPageIndex !== -1) {
408
+ updatedPages[existingPageIndex] = {
409
+ path: pagePath,
410
+ html: pageHtml.trim()
411
+ };
412
+ } else {
413
+ updatedPages.push({
414
+ path: pagePath,
415
+ html: pageHtml.trim()
416
+ });
417
+ }
418
+ }
419
+
420
+ if (updatedPages.length === pages?.length && !chunk.includes(UPDATE_PAGE_START)) {
421
+ let position = 0;
422
+ let moreBlocks = true;
423
+
424
+ while (moreBlocks) {
425
+ const searchStartIndex = chunk.indexOf(SEARCH_START, position);
426
+ if (searchStartIndex === -1) {
427
+ moreBlocks = false;
428
+ continue;
429
+ }
430
+
431
+ const dividerIndex = chunk.indexOf(DIVIDER, searchStartIndex);
432
+ if (dividerIndex === -1) {
433
+ moreBlocks = false;
434
+ continue;
435
+ }
436
+
437
+ const replaceEndIndex = chunk.indexOf(REPLACE_END, dividerIndex);
438
+ if (replaceEndIndex === -1) {
439
+ moreBlocks = false;
440
+ continue;
441
+ }
442
+
443
+ const searchBlock = chunk.substring(
444
+ searchStartIndex + SEARCH_START.length,
445
+ dividerIndex
446
+ );
447
+ const replaceBlock = chunk.substring(
448
+ dividerIndex + DIVIDER.length,
449
+ replaceEndIndex
450
+ );
451
+
452
+ if (searchBlock.trim() === "") {
453
+ newHtml = `${replaceBlock}\n${newHtml}`;
454
+ updatedLines.push([1, replaceBlock.split("\n").length]);
455
+ } else {
456
+ const blockPosition = newHtml.indexOf(searchBlock);
457
+ if (blockPosition !== -1) {
458
+ const beforeText = newHtml.substring(0, blockPosition);
459
+ const startLineNumber = beforeText.split("\n").length;
460
+ const replaceLines = replaceBlock.split("\n").length;
461
+ const endLineNumber = startLineNumber + replaceLines - 1;
462
+
463
+ updatedLines.push([startLineNumber, endLineNumber]);
464
+ newHtml = newHtml.replace(searchBlock, replaceBlock);
465
+ }
466
+ }
467
+
468
+ position = replaceEndIndex + REPLACE_END.length;
469
+ }
470
+
471
+ // Update the main HTML if it's the index page
472
+ const mainPageIndex = updatedPages.findIndex(p => p.path === '/' || p.path === '/index' || p.path === 'index');
473
+ if (mainPageIndex !== -1) {
474
+ updatedPages[mainPageIndex].html = newHtml;
475
+ }
476
+ }
477
+
478
+ return NextResponse.json({
479
+ ok: true,
480
+ updatedLines,
481
+ pages: updatedPages,
482
+ });
483
+ } else {
484
+ return NextResponse.json(
485
+ { ok: false, message: "No content returned from the model" },
486
+ { status: 400 }
487
+ );
488
+ }
489
+ } catch (error: any) {
490
+ if (error.message?.includes("exceeded your monthly included credits")) {
491
+ return NextResponse.json(
492
+ {
493
+ ok: false,
494
+ openProModal: true,
495
+ message: error.message,
496
+ },
497
+ { status: 402 }
498
+ );
499
+ }
500
+ return NextResponse.json(
501
+ {
502
+ ok: false,
503
+ openSelectProvider: true,
504
+ message:
505
+ error.message || "An error occurred while processing your request.",
506
+ },
507
+ { status: 500 }
508
+ );
509
+ }
510
+ }
app/api/auth/route.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ export async function POST(req: NextRequest) {
4
+ const body = await req.json();
5
+ const { code } = body;
6
+
7
+ if (!code) {
8
+ return NextResponse.json(
9
+ { error: "Code is required" },
10
+ {
11
+ status: 400,
12
+ headers: {
13
+ "Content-Type": "application/json",
14
+ },
15
+ }
16
+ );
17
+ }
18
+
19
+ const Authorization = `Basic ${Buffer.from(
20
+ `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`
21
+ ).toString("base64")}`;
22
+
23
+ const host =
24
+ req.headers.get("host") ?? req.headers.get("origin") ?? "localhost:3000";
25
+
26
+ const url = host.includes("/spaces/enzostvs")
27
+ ? "enzostvs-deepsite.hf.space"
28
+ : host;
29
+ const redirect_uri =
30
+ `${host.includes("localhost") ? "http://" : "https://"}` +
31
+ url +
32
+ "/auth/callback";
33
+ const request_auth = await fetch("https://huggingface.co/oauth/token", {
34
+ method: "POST",
35
+ headers: {
36
+ "Content-Type": "application/x-www-form-urlencoded",
37
+ Authorization,
38
+ },
39
+ body: new URLSearchParams({
40
+ grant_type: "authorization_code",
41
+ code,
42
+ redirect_uri,
43
+ }),
44
+ });
45
+
46
+ const response = await request_auth.json();
47
+ if (!response.access_token) {
48
+ return NextResponse.json(
49
+ { error: "Failed to retrieve access token" },
50
+ {
51
+ status: 400,
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ },
55
+ }
56
+ );
57
+ }
58
+
59
+ const userResponse = await fetch("https://huggingface.co/api/whoami-v2", {
60
+ headers: {
61
+ Authorization: `Bearer ${response.access_token}`,
62
+ },
63
+ });
64
+
65
+ if (!userResponse.ok) {
66
+ return NextResponse.json(
67
+ { user: null, errCode: userResponse.status },
68
+ { status: userResponse.status }
69
+ );
70
+ }
71
+ const user = await userResponse.json();
72
+
73
+ return NextResponse.json(
74
+ {
75
+ access_token: response.access_token,
76
+ expires_in: response.expires_in,
77
+ user,
78
+ },
79
+ {
80
+ status: 200,
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ },
84
+ }
85
+ );
86
+ }
app/api/me/projects/[namespace]/[repoId]/images/route.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { RepoDesignation, uploadFiles } from "@huggingface/hub";
3
+
4
+ import { isAuthenticated } from "@/lib/auth";
5
+ import Project from "@/models/Project";
6
+ import dbConnect from "@/lib/mongodb";
7
+
8
+ // No longer need the ImageUpload interface since we're handling FormData with File objects
9
+
10
+ export async function POST(
11
+ req: NextRequest,
12
+ { params }: { params: Promise<{ namespace: string; repoId: string }> }
13
+ ) {
14
+ try {
15
+ const user = await isAuthenticated();
16
+
17
+ if (user instanceof NextResponse || !user) {
18
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
19
+ }
20
+
21
+ await dbConnect();
22
+ const param = await params;
23
+ const { namespace, repoId } = param;
24
+
25
+ const project = await Project.findOne({
26
+ user_id: user.id,
27
+ space_id: `${namespace}/${repoId}`,
28
+ }).lean();
29
+
30
+ if (!project) {
31
+ return NextResponse.json(
32
+ {
33
+ ok: false,
34
+ error: "Project not found",
35
+ },
36
+ { status: 404 }
37
+ );
38
+ }
39
+
40
+ // Parse the FormData to get the images
41
+ const formData = await req.formData();
42
+ const imageFiles = formData.getAll("images") as File[];
43
+
44
+ if (!imageFiles || imageFiles.length === 0) {
45
+ return NextResponse.json(
46
+ {
47
+ ok: false,
48
+ error: "At least one image file is required under the 'images' key",
49
+ },
50
+ { status: 400 }
51
+ );
52
+ }
53
+
54
+ const files: File[] = [];
55
+ for (const file of imageFiles) {
56
+ if (!(file instanceof File)) {
57
+ return NextResponse.json(
58
+ {
59
+ ok: false,
60
+ error: "Invalid file format - all items under 'images' key must be files",
61
+ },
62
+ { status: 400 }
63
+ );
64
+ }
65
+
66
+ if (!file.type.startsWith('image/')) {
67
+ return NextResponse.json(
68
+ {
69
+ ok: false,
70
+ error: `File ${file.name} is not an image`,
71
+ },
72
+ { status: 400 }
73
+ );
74
+ }
75
+
76
+ // Create File object with images/ folder prefix
77
+ const fileName = `images/${file.name}`;
78
+ const processedFile = new File([file], fileName, { type: file.type });
79
+ files.push(processedFile);
80
+ }
81
+
82
+ // Upload files to HuggingFace space
83
+ const repo: RepoDesignation = {
84
+ type: "space",
85
+ name: `${namespace}/${repoId}`,
86
+ };
87
+
88
+ await uploadFiles({
89
+ repo,
90
+ files,
91
+ accessToken: user.token as string,
92
+ commitTitle: `Upload ${files.length} image(s)`,
93
+ });
94
+
95
+ return NextResponse.json({
96
+ ok: true,
97
+ message: `Successfully uploaded ${files.length} image(s) to ${namespace}/${repoId}/images/`,
98
+ uploadedFiles: files.map((file) => `https://huggingface.co/spaces/${namespace}/${repoId}/resolve/main/${file.name}`),
99
+ }, { status: 200 });
100
+
101
+ } catch (error) {
102
+ console.error('Error uploading images:', error);
103
+ return NextResponse.json(
104
+ {
105
+ ok: false,
106
+ error: "Failed to upload images",
107
+ },
108
+ { status: 500 }
109
+ );
110
+ }
111
+ }
app/api/me/projects/[namespace]/[repoId]/route.ts ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { RepoDesignation, spaceInfo, uploadFiles, listFiles } from "@huggingface/hub";
3
+
4
+ import { isAuthenticated } from "@/lib/auth";
5
+ import Project from "@/models/Project";
6
+ import dbConnect from "@/lib/mongodb";
7
+ import { Page } from "@/types";
8
+
9
+ export async function GET(
10
+ req: NextRequest,
11
+ { params }: { params: Promise<{ namespace: string; repoId: string }> }
12
+ ) {
13
+ const user = await isAuthenticated();
14
+
15
+ if (user instanceof NextResponse || !user) {
16
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
17
+ }
18
+
19
+ await dbConnect();
20
+ const param = await params;
21
+ const { namespace, repoId } = param;
22
+
23
+ const project = await Project.findOne({
24
+ user_id: user.id,
25
+ space_id: `${namespace}/${repoId}`,
26
+ }).lean();
27
+ if (!project) {
28
+ return NextResponse.json(
29
+ {
30
+ ok: false,
31
+ error: "Project not found",
32
+ },
33
+ { status: 404 }
34
+ );
35
+ }
36
+ try {
37
+ const space = await spaceInfo({
38
+ name: namespace + "/" + repoId,
39
+ accessToken: user.token as string,
40
+ additionalFields: ["author"],
41
+ });
42
+
43
+ if (!space || space.sdk !== "static") {
44
+ return NextResponse.json(
45
+ {
46
+ ok: false,
47
+ error: "Space is not a static space",
48
+ },
49
+ { status: 404 }
50
+ );
51
+ }
52
+ if (space.author !== user.name) {
53
+ return NextResponse.json(
54
+ {
55
+ ok: false,
56
+ error: "Space does not belong to the authenticated user",
57
+ },
58
+ { status: 403 }
59
+ );
60
+ }
61
+
62
+ const repo: RepoDesignation = {
63
+ type: "space",
64
+ name: `${namespace}/${repoId}`,
65
+ };
66
+
67
+ const htmlFiles: Page[] = [];
68
+ const images: string[] = [];
69
+
70
+ const allowedImagesExtensions = ["jpg", "jpeg", "png", "gif", "svg", "webp", "avif", "heic", "heif", "ico", "bmp", "tiff", "tif"];
71
+
72
+ for await (const fileInfo of listFiles({repo, accessToken: user.token as string})) {
73
+ if (fileInfo.path.endsWith(".html")) {
74
+ const res = await fetch(`https://huggingface.co/spaces/${namespace}/${repoId}/raw/main/${fileInfo.path}`);
75
+ if (res.ok) {
76
+ const html = await res.text();
77
+ if (fileInfo.path === "index.html") {
78
+ htmlFiles.unshift({
79
+ path: fileInfo.path,
80
+ html,
81
+ });
82
+ } else {
83
+ htmlFiles.push({
84
+ path: fileInfo.path,
85
+ html,
86
+ });
87
+ }
88
+ }
89
+ }
90
+ if (fileInfo.type === "directory" && fileInfo.path === "images") {
91
+ for await (const imageInfo of listFiles({repo, accessToken: user.token as string, path: fileInfo.path})) {
92
+ if (allowedImagesExtensions.includes(imageInfo.path.split(".").pop() || "")) {
93
+ images.push(`https://huggingface.co/spaces/${namespace}/${repoId}/resolve/main/${imageInfo.path}`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ if (htmlFiles.length === 0) {
100
+ return NextResponse.json(
101
+ {
102
+ ok: false,
103
+ error: "No HTML files found",
104
+ },
105
+ { status: 404 }
106
+ );
107
+ }
108
+
109
+ return NextResponse.json(
110
+ {
111
+ project: {
112
+ ...project,
113
+ pages: htmlFiles,
114
+ images,
115
+ },
116
+ ok: true,
117
+ },
118
+ { status: 200 }
119
+ );
120
+
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ } catch (error: any) {
123
+ if (error.statusCode === 404) {
124
+ await Project.deleteOne({
125
+ user_id: user.id,
126
+ space_id: `${namespace}/${repoId}`,
127
+ });
128
+ return NextResponse.json(
129
+ { error: "Space not found", ok: false },
130
+ { status: 404 }
131
+ );
132
+ }
133
+ return NextResponse.json(
134
+ { error: error.message, ok: false },
135
+ { status: 500 }
136
+ );
137
+ }
138
+ }
139
+
140
+ export async function PUT(
141
+ req: NextRequest,
142
+ { params }: { params: Promise<{ namespace: string; repoId: string }> }
143
+ ) {
144
+ const user = await isAuthenticated();
145
+
146
+ if (user instanceof NextResponse || !user) {
147
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
148
+ }
149
+
150
+ await dbConnect();
151
+ const param = await params;
152
+ const { namespace, repoId } = param;
153
+ const { pages, prompts } = await req.json();
154
+
155
+ const project = await Project.findOne({
156
+ user_id: user.id,
157
+ space_id: `${namespace}/${repoId}`,
158
+ }).lean();
159
+ if (!project) {
160
+ return NextResponse.json(
161
+ {
162
+ ok: false,
163
+ error: "Project not found",
164
+ },
165
+ { status: 404 }
166
+ );
167
+ }
168
+
169
+ const repo: RepoDesignation = {
170
+ type: "space",
171
+ name: `${namespace}/${repoId}`,
172
+ };
173
+
174
+ const files: File[] = [];
175
+ const promptsFile = new File([prompts.join("\n")], "prompts.txt", {
176
+ type: "text/plain",
177
+ });
178
+ files.push(promptsFile);
179
+ pages.forEach((page: Page) => {
180
+ const file = new File([page.html], page.path, { type: "text/html" });
181
+ files.push(file);
182
+ });
183
+ await uploadFiles({
184
+ repo,
185
+ files,
186
+ accessToken: user.token as string,
187
+ commitTitle: `${prompts[prompts.length - 1]} - Follow Up Deployment`,
188
+ });
189
+
190
+ await Project.updateOne(
191
+ { user_id: user.id, space_id: `${namespace}/${repoId}` },
192
+ {
193
+ $set: {
194
+ prompts: [
195
+ ...prompts,
196
+ ],
197
+ },
198
+ }
199
+ );
200
+ return NextResponse.json({ ok: true }, { status: 200 });
201
+ }
202
+
203
+ export async function POST(
204
+ req: NextRequest,
205
+ { params }: { params: Promise<{ namespace: string; repoId: string }> }
206
+ ) {
207
+ const user = await isAuthenticated();
208
+
209
+ if (user instanceof NextResponse || !user) {
210
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
211
+ }
212
+
213
+ await dbConnect();
214
+ const param = await params;
215
+ const { namespace, repoId } = param;
216
+
217
+ const space = await spaceInfo({
218
+ name: namespace + "/" + repoId,
219
+ accessToken: user.token as string,
220
+ additionalFields: ["author"],
221
+ });
222
+
223
+ if (!space || space.sdk !== "static") {
224
+ return NextResponse.json(
225
+ {
226
+ ok: false,
227
+ error: "Space is not a static space",
228
+ },
229
+ { status: 404 }
230
+ );
231
+ }
232
+ if (space.author !== user.name) {
233
+ return NextResponse.json(
234
+ {
235
+ ok: false,
236
+ error: "Space does not belong to the authenticated user",
237
+ },
238
+ { status: 403 }
239
+ );
240
+ }
241
+
242
+ const project = await Project.findOne({
243
+ user_id: user.id,
244
+ space_id: `${namespace}/${repoId}`,
245
+ }).lean();
246
+ if (project) {
247
+ // redirect to the project page if it already exists
248
+ return NextResponse.json(
249
+ {
250
+ ok: false,
251
+ error: "Project already exists",
252
+ redirect: `/projects/${namespace}/${repoId}`,
253
+ },
254
+ { status: 400 }
255
+ );
256
+ }
257
+
258
+ const newProject = new Project({
259
+ user_id: user.id,
260
+ space_id: `${namespace}/${repoId}`,
261
+ prompts: [],
262
+ });
263
+
264
+ await newProject.save();
265
+ return NextResponse.json(
266
+ {
267
+ ok: true,
268
+ project: {
269
+ id: newProject._id,
270
+ space_id: newProject.space_id,
271
+ prompts: newProject.prompts,
272
+ },
273
+ },
274
+ { status: 201 }
275
+ );
276
+ }
app/api/me/projects/route.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createRepo, RepoDesignation, uploadFiles } from "@huggingface/hub";
3
+
4
+ import { isAuthenticated } from "@/lib/auth";
5
+ import Project from "@/models/Project";
6
+ import dbConnect from "@/lib/mongodb";
7
+ import { COLORS } from "@/lib/utils";
8
+ import { Page } from "@/types";
9
+
10
+ export async function GET() {
11
+ const user = await isAuthenticated();
12
+
13
+ if (user instanceof NextResponse || !user) {
14
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
15
+ }
16
+
17
+ await dbConnect();
18
+
19
+ const projects = await Project.find({
20
+ user_id: user?.id,
21
+ })
22
+ .sort({ _createdAt: -1 })
23
+ .limit(100)
24
+ .lean();
25
+ if (!projects) {
26
+ return NextResponse.json(
27
+ {
28
+ ok: false,
29
+ projects: [],
30
+ },
31
+ { status: 404 }
32
+ );
33
+ }
34
+ return NextResponse.json(
35
+ {
36
+ ok: true,
37
+ projects,
38
+ },
39
+ { status: 200 }
40
+ );
41
+ }
42
+
43
+ export async function POST(request: NextRequest) {
44
+ const user = await isAuthenticated();
45
+
46
+ if (user instanceof NextResponse || !user) {
47
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
48
+ }
49
+
50
+ const { title, pages, prompts } = await request.json();
51
+
52
+ if (!title || !pages || pages.length === 0) {
53
+ return NextResponse.json(
54
+ { message: "Title and HTML content are required.", ok: false },
55
+ { status: 400 }
56
+ );
57
+ }
58
+
59
+ await dbConnect();
60
+
61
+ try {
62
+ let readme = "";
63
+
64
+ const newTitle = title
65
+ .toLowerCase()
66
+ .replace(/[^a-z0-9]+/g, "-")
67
+ .split("-")
68
+ .filter(Boolean)
69
+ .join("-")
70
+ .slice(0, 96);
71
+
72
+ const repo: RepoDesignation = {
73
+ type: "space",
74
+ name: `${user.name}/${newTitle}`,
75
+ };
76
+
77
+ const { repoUrl } = await createRepo({
78
+ repo,
79
+ accessToken: user.token as string,
80
+ });
81
+ const colorFrom = COLORS[Math.floor(Math.random() * COLORS.length)];
82
+ const colorTo = COLORS[Math.floor(Math.random() * COLORS.length)];
83
+ readme = `---
84
+ title: ${newTitle}
85
+ emoji: 🐳
86
+ colorFrom: ${colorFrom}
87
+ colorTo: ${colorTo}
88
+ sdk: static
89
+ pinned: false
90
+ tags:
91
+ - deepsite
92
+ ---
93
+
94
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference`;
95
+
96
+ const readmeFile = new File([readme], "README.md", {
97
+ type: "text/markdown",
98
+ });
99
+ const promptsFile = new File([prompts.join("\n")], "prompts.txt", {
100
+ type: "text/plain",
101
+ });
102
+ const files = [readmeFile, promptsFile];
103
+ pages.forEach((page: Page) => {
104
+ const file = new File([page.html], page.path, { type: "text/html" });
105
+ files.push(file);
106
+ });
107
+ await uploadFiles({
108
+ repo,
109
+ files,
110
+ accessToken: user.token as string,
111
+ commitTitle: `${prompts[prompts.length - 1]} - Initial Deployment`,
112
+ });
113
+ const path = repoUrl.split("/").slice(-2).join("/");
114
+ const project = await Project.create({
115
+ user_id: user.id,
116
+ space_id: path,
117
+ prompts,
118
+ });
119
+ return NextResponse.json({ project, path, ok: true }, { status: 201 });
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ } catch (err: any) {
122
+ return NextResponse.json(
123
+ { error: err.message, ok: false },
124
+ { status: 500 }
125
+ );
126
+ }
127
+ }
app/api/me/route.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { headers } from "next/headers";
2
+ import { NextResponse } from "next/server";
3
+
4
+ export async function GET() {
5
+ const authHeaders = await headers();
6
+ const token = authHeaders.get("Authorization");
7
+ if (!token) {
8
+ return NextResponse.json({ user: null, errCode: 401 }, { status: 401 });
9
+ }
10
+
11
+ const userResponse = await fetch("https://huggingface.co/api/whoami-v2", {
12
+ headers: {
13
+ Authorization: `${token}`,
14
+ },
15
+ });
16
+
17
+ if (!userResponse.ok) {
18
+ return NextResponse.json(
19
+ { user: null, errCode: userResponse.status },
20
+ { status: userResponse.status }
21
+ );
22
+ }
23
+ const user = await userResponse.json();
24
+ return NextResponse.json({ user, errCode: null }, { status: 200 });
25
+ }
app/api/re-design/route.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ export async function PUT(request: NextRequest) {
4
+ const body = await request.json();
5
+ const { url } = body;
6
+
7
+ if (!url) {
8
+ return NextResponse.json({ error: "URL is required" }, { status: 400 });
9
+ }
10
+
11
+ try {
12
+ const response = await fetch(
13
+ `https://r.jina.ai/${encodeURIComponent(url)}`,
14
+ {
15
+ method: "POST",
16
+ }
17
+ );
18
+ if (!response.ok) {
19
+ return NextResponse.json(
20
+ { error: "Failed to fetch redesign" },
21
+ { status: 500 }
22
+ );
23
+ }
24
+ const markdown = await response.text();
25
+ return NextResponse.json(
26
+ {
27
+ ok: true,
28
+ markdown,
29
+ },
30
+ { status: 200 }
31
+ );
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ } catch (error: any) {
34
+ return NextResponse.json(
35
+ { error: error.message || "An error occurred" },
36
+ { status: 500 }
37
+ );
38
+ }
39
+ }
app/auth/callback/page.tsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { useUser } from "@/hooks/useUser";
4
+ import { use, useState } from "react";
5
+ import { useMount, useTimeoutFn } from "react-use";
6
+
7
+ import { Button } from "@/components/ui/button";
8
+ export default function AuthCallback({
9
+ searchParams,
10
+ }: {
11
+ searchParams: Promise<{ code: string }>;
12
+ }) {
13
+ const [showButton, setShowButton] = useState(false);
14
+ const { code } = use(searchParams);
15
+ const { loginFromCode } = useUser();
16
+
17
+ useMount(async () => {
18
+ if (code) {
19
+ await loginFromCode(code);
20
+ }
21
+ });
22
+
23
+ useTimeoutFn(
24
+ () => setShowButton(true),
25
+ 7000 // Show button after 5 seconds
26
+ );
27
+
28
+ return (
29
+ <div className="h-screen flex flex-col justify-center items-center">
30
+ <div className="!rounded-2xl !p-0 !bg-white !border-neutral-100 min-w-xs text-center overflow-hidden ring-[8px] ring-white/20">
31
+ <header className="bg-neutral-50 p-6 border-b border-neutral-200/60">
32
+ <div className="flex items-center justify-center -space-x-4 mb-3">
33
+ <div className="size-9 rounded-full bg-pink-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
34
+ 🚀
35
+ </div>
36
+ <div className="size-11 rounded-full bg-amber-200 shadow-2xl flex items-center justify-center text-2xl z-2">
37
+ 👋
38
+ </div>
39
+ <div className="size-9 rounded-full bg-sky-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
40
+ 🙌
41
+ </div>
42
+ </div>
43
+ <p className="text-xl font-semibold text-neutral-950">
44
+ Login In Progress...
45
+ </p>
46
+ <p className="text-sm text-neutral-500 mt-1.5">
47
+ Wait a moment while we log you in with your code.
48
+ </p>
49
+ </header>
50
+ <main className="space-y-4 p-6">
51
+ <div>
52
+ <p className="text-sm text-neutral-700 mb-4 max-w-xs">
53
+ If you are not redirected automatically in the next 5 seconds,
54
+ please click the button below
55
+ </p>
56
+ {showButton ? (
57
+ <Link href="/">
58
+ <Button variant="black" className="relative">
59
+ Go to Home
60
+ </Button>
61
+ </Link>
62
+ ) : (
63
+ <p className="text-xs text-neutral-500">
64
+ Please wait, we are logging you in...
65
+ </p>
66
+ )}
67
+ </div>
68
+ </main>
69
+ </div>
70
+ </div>
71
+ );
72
+ }
app/auth/page.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "next/navigation";
2
+ import { Metadata } from "next";
3
+
4
+ import { getAuth } from "@/app/actions/auth";
5
+
6
+ export const revalidate = 1;
7
+
8
+ export const metadata: Metadata = {
9
+ robots: "noindex, nofollow",
10
+ };
11
+
12
+ export default async function Auth() {
13
+ const loginRedirectUrl = await getAuth();
14
+ if (loginRedirectUrl) {
15
+ redirect(loginRedirectUrl);
16
+ }
17
+
18
+ return (
19
+ <div className="p-4">
20
+ <div className="border bg-red-500/10 border-red-500/20 text-red-500 px-5 py-3 rounded-lg">
21
+ <h1 className="text-xl font-bold">Error</h1>
22
+ <p className="text-sm">
23
+ An error occurred while trying to log in. Please try again later.
24
+ </p>
25
+ </div>
26
+ </div>
27
+ );
28
+ }
app/favicon.ico ADDED
app/layout.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import type { Metadata, Viewport } from "next";
3
+ import { Inter, PT_Sans } from "next/font/google";
4
+ import { cookies } from "next/headers";
5
+
6
+ import TanstackProvider from "@/components/providers/tanstack-query-provider";
7
+ import "@/assets/globals.css";
8
+ import { Toaster } from "@/components/ui/sonner";
9
+ import MY_TOKEN_KEY from "@/lib/get-cookie-name";
10
+ import { apiServer } from "@/lib/api";
11
+ import AppContext from "@/components/contexts/app-context";
12
+ import Script from "next/script";
13
+ import IframeDetector from "@/components/iframe-detector";
14
+
15
+ const inter = Inter({
16
+ variable: "--font-inter-sans",
17
+ subsets: ["latin"],
18
+ });
19
+
20
+ const ptSans = PT_Sans({
21
+ variable: "--font-ptSans-mono",
22
+ subsets: ["latin"],
23
+ weight: ["400", "700"],
24
+ });
25
+
26
+ export const metadata: Metadata = {
27
+ title: "DeepSite | Build with AI ✨",
28
+ description:
29
+ "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.",
30
+ openGraph: {
31
+ title: "DeepSite | Build with AI ✨",
32
+ description:
33
+ "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.",
34
+ url: "https://deepsite.hf.co",
35
+ siteName: "DeepSite",
36
+ images: [
37
+ {
38
+ url: "https://deepsite.hf.co/banner.png",
39
+ width: 1200,
40
+ height: 630,
41
+ alt: "DeepSite Open Graph Image",
42
+ },
43
+ ],
44
+ },
45
+ twitter: {
46
+ card: "summary_large_image",
47
+ title: "DeepSite | Build with AI ✨",
48
+ description:
49
+ "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.",
50
+ images: ["https://deepsite.hf.co/banner.png"],
51
+ },
52
+ appleWebApp: {
53
+ capable: true,
54
+ title: "DeepSite",
55
+ statusBarStyle: "black-translucent",
56
+ },
57
+ icons: {
58
+ icon: "/logo.svg",
59
+ shortcut: "/logo.svg",
60
+ apple: "/logo.svg",
61
+ },
62
+ };
63
+
64
+ export const viewport: Viewport = {
65
+ initialScale: 1,
66
+ maximumScale: 1,
67
+ themeColor: "#000000",
68
+ };
69
+
70
+ async function getMe() {
71
+ const cookieStore = await cookies();
72
+ const token = cookieStore.get(MY_TOKEN_KEY())?.value;
73
+ if (!token) return { user: null, errCode: null };
74
+ try {
75
+ const res = await apiServer.get("/me", {
76
+ headers: {
77
+ Authorization: `Bearer ${token}`,
78
+ },
79
+ });
80
+ return { user: res.data.user, errCode: null };
81
+ } catch (err: any) {
82
+ return { user: null, errCode: err.status };
83
+ }
84
+ }
85
+
86
+ // if domain isn't deepsite.hf.co or enzostvs-deepsite.hf.space redirect to deepsite.hf.co
87
+
88
+ export default async function RootLayout({
89
+ children,
90
+ }: Readonly<{
91
+ children: React.ReactNode;
92
+ }>) {
93
+ const data = await getMe();
94
+ return (
95
+ <html lang="en">
96
+ <Script
97
+ defer
98
+ data-domain="deepsite.hf.co"
99
+ src="https://plausible.io/js/script.js"
100
+ ></Script>
101
+ <body
102
+ className={`${inter.variable} ${ptSans.variable} antialiased bg-black dark h-[100dvh] overflow-hidden`}
103
+ >
104
+ <IframeDetector />
105
+ <Toaster richColors position="bottom-center" />
106
+ <TanstackProvider>
107
+ <AppContext me={data}>{children}</AppContext>
108
+ </TanstackProvider>
109
+ </body>
110
+ </html>
111
+ );
112
+ }
app/projects/[namespace]/[repoId]/page.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cookies } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+
4
+ import { apiServer } from "@/lib/api";
5
+ import MY_TOKEN_KEY from "@/lib/get-cookie-name";
6
+ import { AppEditor } from "@/components/editor";
7
+
8
+ async function getProject(namespace: string, repoId: string) {
9
+ // TODO replace with a server action
10
+ const cookieStore = await cookies();
11
+ const token = cookieStore.get(MY_TOKEN_KEY())?.value;
12
+ if (!token) return {};
13
+ try {
14
+ const { data } = await apiServer.get(
15
+ `/me/projects/${namespace}/${repoId}`,
16
+ {
17
+ headers: {
18
+ Authorization: `Bearer ${token}`,
19
+ },
20
+ }
21
+ );
22
+
23
+ return data.project;
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ export default async function ProjectNamespacePage({
30
+ params,
31
+ }: {
32
+ params: Promise<{ namespace: string; repoId: string }>;
33
+ }) {
34
+ const { namespace, repoId } = await params;
35
+ const data = await getProject(namespace, repoId);
36
+ if (!data?.pages) {
37
+ redirect("/projects");
38
+ }
39
+ return (
40
+ <AppEditor project={data} pages={data.pages} images={data.images ?? []} />
41
+ );
42
+ }
app/projects/new/page.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { AppEditor } from "@/components/editor";
2
+
3
+ export default function ProjectsNewPage() {
4
+ return <AppEditor isNew />;
5
+ }
assets/globals.css ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ @custom-variant dark (&:is(.dark *));
5
+
6
+ @theme inline {
7
+ --color-background: var(--background);
8
+ --color-foreground: var(--foreground);
9
+ --font-sans: var(--font-inter-sans);
10
+ --font-mono: var(--font-ptSans-mono);
11
+ --color-sidebar-ring: var(--sidebar-ring);
12
+ --color-sidebar-border: var(--sidebar-border);
13
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14
+ --color-sidebar-accent: var(--sidebar-accent);
15
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16
+ --color-sidebar-primary: var(--sidebar-primary);
17
+ --color-sidebar-foreground: var(--sidebar-foreground);
18
+ --color-sidebar: var(--sidebar);
19
+ --color-chart-5: var(--chart-5);
20
+ --color-chart-4: var(--chart-4);
21
+ --color-chart-3: var(--chart-3);
22
+ --color-chart-2: var(--chart-2);
23
+ --color-chart-1: var(--chart-1);
24
+ --color-ring: var(--ring);
25
+ --color-input: var(--input);
26
+ --color-border: var(--border);
27
+ --color-destructive: var(--destructive);
28
+ --color-accent-foreground: var(--accent-foreground);
29
+ --color-accent: var(--accent);
30
+ --color-muted-foreground: var(--muted-foreground);
31
+ --color-muted: var(--muted);
32
+ --color-secondary-foreground: var(--secondary-foreground);
33
+ --color-secondary: var(--secondary);
34
+ --color-primary-foreground: var(--primary-foreground);
35
+ --color-primary: var(--primary);
36
+ --color-popover-foreground: var(--popover-foreground);
37
+ --color-popover: var(--popover);
38
+ --color-card-foreground: var(--card-foreground);
39
+ --color-card: var(--card);
40
+ --radius-sm: calc(var(--radius) - 4px);
41
+ --radius-md: calc(var(--radius) - 2px);
42
+ --radius-lg: var(--radius);
43
+ --radius-xl: calc(var(--radius) + 4px);
44
+ }
45
+
46
+ :root {
47
+ --radius: 0.625rem;
48
+ --background: oklch(1 0 0);
49
+ --foreground: oklch(0.145 0 0);
50
+ --card: oklch(1 0 0);
51
+ --card-foreground: oklch(0.145 0 0);
52
+ --popover: oklch(1 0 0);
53
+ --popover-foreground: oklch(0.145 0 0);
54
+ --primary: oklch(0.205 0 0);
55
+ --primary-foreground: oklch(0.985 0 0);
56
+ --secondary: oklch(0.97 0 0);
57
+ --secondary-foreground: oklch(0.205 0 0);
58
+ --muted: oklch(0.97 0 0);
59
+ --muted-foreground: oklch(0.556 0 0);
60
+ --accent: oklch(0.97 0 0);
61
+ --accent-foreground: oklch(0.205 0 0);
62
+ --destructive: oklch(0.577 0.245 27.325);
63
+ --border: oklch(0.922 0 0);
64
+ --input: oklch(0.922 0 0);
65
+ --ring: oklch(0.708 0 0);
66
+ --chart-1: oklch(0.646 0.222 41.116);
67
+ --chart-2: oklch(0.6 0.118 184.704);
68
+ --chart-3: oklch(0.398 0.07 227.392);
69
+ --chart-4: oklch(0.828 0.189 84.429);
70
+ --chart-5: oklch(0.769 0.188 70.08);
71
+ --sidebar: oklch(0.985 0 0);
72
+ --sidebar-foreground: oklch(0.145 0 0);
73
+ --sidebar-primary: oklch(0.205 0 0);
74
+ --sidebar-primary-foreground: oklch(0.985 0 0);
75
+ --sidebar-accent: oklch(0.97 0 0);
76
+ --sidebar-accent-foreground: oklch(0.205 0 0);
77
+ --sidebar-border: oklch(0.922 0 0);
78
+ --sidebar-ring: oklch(0.708 0 0);
79
+ }
80
+
81
+ .dark {
82
+ --background: oklch(0.145 0 0);
83
+ --foreground: oklch(0.985 0 0);
84
+ --card: oklch(0.205 0 0);
85
+ --card-foreground: oklch(0.985 0 0);
86
+ --popover: oklch(0.205 0 0);
87
+ --popover-foreground: oklch(0.985 0 0);
88
+ --primary: oklch(0.922 0 0);
89
+ --primary-foreground: oklch(0.205 0 0);
90
+ --secondary: oklch(0.269 0 0);
91
+ --secondary-foreground: oklch(0.985 0 0);
92
+ --muted: oklch(0.269 0 0);
93
+ --muted-foreground: oklch(0.708 0 0);
94
+ --accent: oklch(0.269 0 0);
95
+ --accent-foreground: oklch(0.985 0 0);
96
+ --destructive: oklch(0.704 0.191 22.216);
97
+ --border: oklch(1 0 0 / 10%);
98
+ --input: oklch(1 0 0 / 15%);
99
+ --ring: oklch(0.556 0 0);
100
+ --chart-1: oklch(0.488 0.243 264.376);
101
+ --chart-2: oklch(0.696 0.17 162.48);
102
+ --chart-3: oklch(0.769 0.188 70.08);
103
+ --chart-4: oklch(0.627 0.265 303.9);
104
+ --chart-5: oklch(0.645 0.246 16.439);
105
+ --sidebar: oklch(0.205 0 0);
106
+ --sidebar-foreground: oklch(0.985 0 0);
107
+ --sidebar-primary: oklch(0.488 0.243 264.376);
108
+ --sidebar-primary-foreground: oklch(0.985 0 0);
109
+ --sidebar-accent: oklch(0.269 0 0);
110
+ --sidebar-accent-foreground: oklch(0.985 0 0);
111
+ --sidebar-border: oklch(1 0 0 / 10%);
112
+ --sidebar-ring: oklch(0.556 0 0);
113
+ }
114
+
115
+ @layer base {
116
+ * {
117
+ @apply border-border outline-ring/50;
118
+ }
119
+ body {
120
+ @apply bg-background text-foreground;
121
+ }
122
+ html {
123
+ @apply scroll-smooth;
124
+ }
125
+ }
126
+
127
+ .background__noisy {
128
+ @apply bg-blend-normal pointer-events-none opacity-90;
129
+ background-size: 25ww auto;
130
+ background-image: url("/background_noisy.webp");
131
+ @apply fixed w-screen h-screen -z-1 top-0 left-0;
132
+ }
133
+
134
+ .monaco-editor .margin {
135
+ @apply !bg-neutral-900;
136
+ }
137
+ .monaco-editor .monaco-editor-background {
138
+ @apply !bg-neutral-900;
139
+ }
140
+ .monaco-editor .line-numbers {
141
+ @apply !text-neutral-500;
142
+ }
143
+
144
+ .matched-line {
145
+ @apply bg-sky-500/30;
146
+ }
assets/logo.svg ADDED
assets/space.svg ADDED
components.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
components/contexts/app-context.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ "use client";
3
+
4
+ import { useUser } from "@/hooks/useUser";
5
+ import { usePathname, useRouter } from "next/navigation";
6
+ import { useMount } from "react-use";
7
+ import { UserContext } from "@/components/contexts/user-context";
8
+ import { User } from "@/types";
9
+ import { toast } from "sonner";
10
+ import { useBroadcastChannel } from "@/lib/useBroadcastChannel";
11
+
12
+ export default function AppContext({
13
+ children,
14
+ me: initialData,
15
+ }: {
16
+ children: React.ReactNode;
17
+ me?: {
18
+ user: User | null;
19
+ errCode: number | null;
20
+ };
21
+ }) {
22
+ const { loginFromCode, user, logout, loading, errCode } =
23
+ useUser(initialData);
24
+ const pathname = usePathname();
25
+ const router = useRouter();
26
+
27
+ useMount(() => {
28
+ if (!initialData?.user && !user) {
29
+ if ([401, 403].includes(errCode as number)) {
30
+ logout();
31
+ } else if (pathname.includes("/spaces")) {
32
+ if (errCode) {
33
+ toast.error("An error occured while trying to log in");
34
+ }
35
+ // If we did not manage to log in (probs because api is down), we simply redirect to the home page
36
+ router.push("/");
37
+ }
38
+ }
39
+ });
40
+
41
+ const events: any = {};
42
+
43
+ useBroadcastChannel("auth", (message) => {
44
+ if (pathname.includes("/auth/callback")) return;
45
+
46
+ if (!message.code) return;
47
+ if (message.type === "user-oauth" && message?.code && !events.code) {
48
+ loginFromCode(message.code);
49
+ }
50
+ });
51
+
52
+ return (
53
+ <UserContext value={{ user, loading, logout } as any}>
54
+ {children}
55
+ </UserContext>
56
+ );
57
+ }
components/contexts/user-context.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { createContext } from "react";
4
+ import { User } from "@/types";
5
+
6
+ export const UserContext = createContext({
7
+ user: undefined as User | undefined,
8
+ });
components/editor/ask-ai/follow-up-tooltip.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Popover,
3
+ PopoverContent,
4
+ PopoverTrigger,
5
+ } from "@/components/ui/popover";
6
+ import { Info } from "lucide-react";
7
+
8
+ export const FollowUpTooltip = () => {
9
+ return (
10
+ <Popover>
11
+ <PopoverTrigger asChild>
12
+ <Info className="size-3 text-neutral-300 cursor-pointer" />
13
+ </PopoverTrigger>
14
+ <PopoverContent
15
+ align="start"
16
+ className="!rounded-2xl !p-0 min-w-xs text-center overflow-hidden"
17
+ >
18
+ <header className="bg-neutral-950 px-4 py-3 border-b border-neutral-700/70">
19
+ <p className="text-base text-neutral-200 font-semibold">
20
+ ⚡ Faster, Smarter Updates
21
+ </p>
22
+ </header>
23
+ <main className="p-4">
24
+ <p className="text-neutral-300 text-sm">
25
+ Using the Diff-Patch system, allow DeepSite to intelligently update
26
+ your project without rewritting the entire codebase.
27
+ </p>
28
+ <p className="text-neutral-500 text-sm mt-2">
29
+ This means faster updates, less data usage, and a more efficient
30
+ development process.
31
+ </p>
32
+ </main>
33
+ </PopoverContent>
34
+ </Popover>
35
+ );
36
+ };
components/editor/ask-ai/index.tsx ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ /* eslint-disable @typescript-eslint/no-explicit-any */
3
+ import { useState, useMemo, useRef } from "react";
4
+ import classNames from "classnames";
5
+ import { toast } from "sonner";
6
+ import { useLocalStorage, useUpdateEffect } from "react-use";
7
+ import { ArrowUp, ChevronDown, Crosshair } from "lucide-react";
8
+ import { FaStopCircle } from "react-icons/fa";
9
+
10
+ import ProModal from "@/components/pro-modal";
11
+ import { Button } from "@/components/ui/button";
12
+ import { MODELS } from "@/lib/providers";
13
+ import { HtmlHistory, Page, Project } from "@/types";
14
+ // import { InviteFriends } from "@/components/invite-friends";
15
+ import { Settings } from "@/components/editor/ask-ai/settings";
16
+ import { LoginModal } from "@/components/login-modal";
17
+ import { ReImagine } from "@/components/editor/ask-ai/re-imagine";
18
+ import Loading from "@/components/loading";
19
+ import { Checkbox } from "@/components/ui/checkbox";
20
+ import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
21
+ import { TooltipContent } from "@radix-ui/react-tooltip";
22
+ import { SelectedHtmlElement } from "./selected-html-element";
23
+ import { FollowUpTooltip } from "./follow-up-tooltip";
24
+ import { isTheSameHtml } from "@/lib/compare-html-diff";
25
+ import { useCallAi } from "@/hooks/useCallAi";
26
+ import { SelectedFiles } from "./selected-files";
27
+ import { Uploader } from "./uploader";
28
+
29
+ export function AskAI({
30
+ isNew,
31
+ project,
32
+ images,
33
+ currentPage,
34
+ previousPrompts,
35
+ onScrollToBottom,
36
+ isAiWorking,
37
+ setisAiWorking,
38
+ isEditableModeEnabled = false,
39
+ pages,
40
+ htmlHistory,
41
+ selectedElement,
42
+ setSelectedElement,
43
+ selectedFiles,
44
+ setSelectedFiles,
45
+ setIsEditableModeEnabled,
46
+ onNewPrompt,
47
+ onSuccess,
48
+ setPages,
49
+ setCurrentPage,
50
+ }: {
51
+ project?: Project | null;
52
+ currentPage: Page;
53
+ images?: string[];
54
+ pages: Page[];
55
+ onScrollToBottom: () => void;
56
+ previousPrompts: string[];
57
+ isAiWorking: boolean;
58
+ onNewPrompt: (prompt: string) => void;
59
+ htmlHistory?: HtmlHistory[];
60
+ setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
61
+ isNew?: boolean;
62
+ onSuccess: (page: Page[], p: string, n?: number[][]) => void;
63
+ isEditableModeEnabled: boolean;
64
+ setIsEditableModeEnabled: React.Dispatch<React.SetStateAction<boolean>>;
65
+ selectedElement?: HTMLElement | null;
66
+ setSelectedElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
67
+ selectedFiles: string[];
68
+ setSelectedFiles: React.Dispatch<React.SetStateAction<string[]>>;
69
+ setPages: React.Dispatch<React.SetStateAction<Page[]>>;
70
+ setCurrentPage: React.Dispatch<React.SetStateAction<string>>;
71
+ }) {
72
+ const refThink = useRef<HTMLDivElement | null>(null);
73
+
74
+ const [open, setOpen] = useState(false);
75
+ const [prompt, setPrompt] = useState("");
76
+ const [provider, setProvider] = useLocalStorage("provider", "auto");
77
+ const [model, setModel] = useLocalStorage("model", MODELS[0].value);
78
+ const [openProvider, setOpenProvider] = useState(false);
79
+ const [providerError, setProviderError] = useState("");
80
+ const [openProModal, setOpenProModal] = useState(false);
81
+ const [openThink, setOpenThink] = useState(false);
82
+ const [isThinking, setIsThinking] = useState(true);
83
+ const [think, setThink] = useState("");
84
+ const [isFollowUp, setIsFollowUp] = useState(true);
85
+ const [isUploading, setIsUploading] = useState(false);
86
+ const [files, setFiles] = useState<string[]>(images ?? []);
87
+
88
+ const {
89
+ callAiNewProject,
90
+ callAiFollowUp,
91
+ callAiNewPage,
92
+ stopController,
93
+ audio: hookAudio,
94
+ } = useCallAi({
95
+ onNewPrompt,
96
+ onSuccess,
97
+ onScrollToBottom,
98
+ setPages,
99
+ setCurrentPage,
100
+ currentPage,
101
+ pages,
102
+ isAiWorking,
103
+ setisAiWorking,
104
+ });
105
+
106
+ const selectedModel = useMemo(() => {
107
+ return MODELS.find((m: { value: string }) => m.value === model);
108
+ }, [model]);
109
+
110
+ const callAi = async (redesignMarkdown?: string) => {
111
+ if (isAiWorking) return;
112
+ if (!redesignMarkdown && !prompt.trim()) return;
113
+
114
+ if (isFollowUp && !redesignMarkdown && !isSameHtml) {
115
+ // Use follow-up function for existing projects
116
+ const selectedElementHtml = selectedElement
117
+ ? selectedElement.outerHTML
118
+ : "";
119
+
120
+ const result = await callAiFollowUp(
121
+ prompt,
122
+ model,
123
+ provider,
124
+ previousPrompts,
125
+ selectedElementHtml,
126
+ selectedFiles
127
+ );
128
+
129
+ if (result?.error) {
130
+ handleError(result.error, result.message);
131
+ return;
132
+ }
133
+
134
+ if (result?.success) {
135
+ setPrompt("");
136
+ }
137
+ } else if (isFollowUp && pages.length > 1 && isSameHtml) {
138
+ const result = await callAiNewPage(
139
+ prompt,
140
+ model,
141
+ provider,
142
+ currentPage.path,
143
+ [
144
+ ...(previousPrompts ?? []),
145
+ ...(htmlHistory?.map((h) => h.prompt) ?? []),
146
+ ]
147
+ );
148
+ if (result?.error) {
149
+ handleError(result.error, result.message);
150
+ return;
151
+ }
152
+
153
+ if (result?.success) {
154
+ setPrompt("");
155
+ }
156
+ } else {
157
+ const result = await callAiNewProject(
158
+ prompt,
159
+ model,
160
+ provider,
161
+ redesignMarkdown,
162
+ handleThink,
163
+ () => {
164
+ setIsThinking(false);
165
+ }
166
+ );
167
+
168
+ if (result?.error) {
169
+ handleError(result.error, result.message);
170
+ return;
171
+ }
172
+
173
+ if (result?.success) {
174
+ setPrompt("");
175
+ if (selectedModel?.isThinker) {
176
+ setModel(MODELS[0].value);
177
+ }
178
+ }
179
+ }
180
+ };
181
+
182
+ const handleThink = (think: string) => {
183
+ setThink(think);
184
+ setIsThinking(true);
185
+ setOpenThink(true);
186
+ };
187
+
188
+ const handleError = (error: string, message?: string) => {
189
+ switch (error) {
190
+ case "login_required":
191
+ setOpen(true);
192
+ break;
193
+ case "provider_required":
194
+ setOpenProvider(true);
195
+ setProviderError(message || "");
196
+ break;
197
+ case "pro_required":
198
+ setOpenProModal(true);
199
+ break;
200
+ case "api_error":
201
+ toast.error(message || "An error occurred");
202
+ break;
203
+ case "network_error":
204
+ toast.error(message || "Network error occurred");
205
+ break;
206
+ default:
207
+ toast.error("An unexpected error occurred");
208
+ }
209
+ };
210
+
211
+ useUpdateEffect(() => {
212
+ if (refThink.current) {
213
+ refThink.current.scrollTop = refThink.current.scrollHeight;
214
+ }
215
+ }, [think]);
216
+
217
+ useUpdateEffect(() => {
218
+ if (!isThinking) {
219
+ setOpenThink(false);
220
+ }
221
+ }, [isThinking]);
222
+
223
+ const isSameHtml = useMemo(() => {
224
+ return isTheSameHtml(currentPage.html);
225
+ }, [currentPage.html]);
226
+
227
+ return (
228
+ <div className="px-3">
229
+ <div className="relative bg-neutral-800 border border-neutral-700 rounded-2xl ring-[4px] focus-within:ring-neutral-500/30 focus-within:border-neutral-600 ring-transparent z-10 w-full group">
230
+ {think && (
231
+ <div className="w-full border-b border-neutral-700 relative overflow-hidden">
232
+ <header
233
+ className="flex items-center justify-between px-5 py-2.5 group hover:bg-neutral-600/20 transition-colors duration-200 cursor-pointer"
234
+ onClick={() => {
235
+ setOpenThink(!openThink);
236
+ }}
237
+ >
238
+ <p className="text-sm font-medium text-neutral-300 group-hover:text-neutral-200 transition-colors duration-200">
239
+ {isThinking ? "DeepSite is thinking..." : "DeepSite's plan"}
240
+ </p>
241
+ <ChevronDown
242
+ className={classNames(
243
+ "size-4 text-neutral-400 group-hover:text-neutral-300 transition-all duration-200",
244
+ {
245
+ "rotate-180": openThink,
246
+ }
247
+ )}
248
+ />
249
+ </header>
250
+ <main
251
+ ref={refThink}
252
+ className={classNames(
253
+ "overflow-y-auto transition-all duration-200 ease-in-out",
254
+ {
255
+ "max-h-[0px]": !openThink,
256
+ "min-h-[250px] max-h-[250px] border-t border-neutral-700":
257
+ openThink,
258
+ }
259
+ )}
260
+ >
261
+ <p className="text-[13px] text-neutral-400 whitespace-pre-line px-5 pb-4 pt-3">
262
+ {think}
263
+ </p>
264
+ </main>
265
+ </div>
266
+ )}
267
+ <SelectedFiles
268
+ files={selectedFiles}
269
+ isAiWorking={isAiWorking}
270
+ onDelete={(file) =>
271
+ setSelectedFiles((prev) => prev.filter((f) => f !== file))
272
+ }
273
+ />
274
+ {selectedElement && (
275
+ <div className="px-4 pt-3">
276
+ <SelectedHtmlElement
277
+ element={selectedElement}
278
+ isAiWorking={isAiWorking}
279
+ onDelete={() => setSelectedElement(null)}
280
+ />
281
+ </div>
282
+ )}
283
+ <div className="w-full relative flex items-center justify-between">
284
+ {(isAiWorking || isUploading) && (
285
+ <div className="absolute bg-neutral-800 top-0 left-4 w-[calc(100%-30px)] h-full z-1 flex items-start pt-3.5 justify-between max-lg:text-sm">
286
+ <div className="flex items-center justify-start gap-2">
287
+ <Loading overlay={false} className="!size-4 opacity-50" />
288
+ <p className="text-neutral-400 text-sm">
289
+ {isUploading ? (
290
+ "Uploading images..."
291
+ ) : isAiWorking && !isSameHtml ? (
292
+ "AI is working..."
293
+ ) : (
294
+ <span className="inline-flex">
295
+ {[
296
+ "D",
297
+ "e",
298
+ "e",
299
+ "p",
300
+ "S",
301
+ "i",
302
+ "t",
303
+ "e",
304
+ " ",
305
+ "i",
306
+ "s",
307
+ " ",
308
+ "T",
309
+ "h",
310
+ "i",
311
+ "n",
312
+ "k",
313
+ "i",
314
+ "n",
315
+ "g",
316
+ ".",
317
+ ".",
318
+ ".",
319
+ " ",
320
+ "W",
321
+ "a",
322
+ "i",
323
+ "t",
324
+ " ",
325
+ "a",
326
+ " ",
327
+ "m",
328
+ "o",
329
+ "m",
330
+ "e",
331
+ "n",
332
+ "t",
333
+ ".",
334
+ ".",
335
+ ".",
336
+ ].map((char, index) => (
337
+ <span
338
+ key={index}
339
+ className="bg-gradient-to-r from-neutral-100 to-neutral-300 bg-clip-text text-transparent animate-pulse"
340
+ style={{
341
+ animationDelay: `${index * 0.1}s`,
342
+ animationDuration: "1.3s",
343
+ animationIterationCount: "infinite",
344
+ }}
345
+ >
346
+ {char === " " ? "\u00A0" : char}
347
+ </span>
348
+ ))}
349
+ </span>
350
+ )}
351
+ </p>
352
+ </div>
353
+ {isAiWorking && (
354
+ <div
355
+ className="text-xs text-neutral-400 px-1 py-0.5 rounded-md border border-neutral-600 flex items-center justify-center gap-1.5 bg-neutral-800 hover:brightness-110 transition-all duration-200 cursor-pointer"
356
+ onClick={stopController}
357
+ >
358
+ <FaStopCircle />
359
+ Stop generation
360
+ </div>
361
+ )}
362
+ </div>
363
+ )}
364
+ <textarea
365
+ disabled={isAiWorking}
366
+ className={classNames(
367
+ "w-full bg-transparent text-sm outline-none text-white placeholder:text-neutral-400 p-4 resize-none",
368
+ {
369
+ "!pt-2.5": selectedElement && !isAiWorking,
370
+ }
371
+ )}
372
+ placeholder={
373
+ selectedElement
374
+ ? `Ask DeepSite about ${selectedElement.tagName.toLowerCase()}...`
375
+ : isFollowUp && (!isSameHtml || pages?.length > 1)
376
+ ? "Ask DeepSite for edits"
377
+ : "Ask DeepSite anything..."
378
+ }
379
+ value={prompt}
380
+ onChange={(e) => setPrompt(e.target.value)}
381
+ onKeyDown={(e) => {
382
+ if (e.key === "Enter" && !e.shiftKey) {
383
+ callAi();
384
+ }
385
+ }}
386
+ />
387
+ </div>
388
+ <div className="flex items-center justify-between gap-2 px-4 pb-3 mt-2">
389
+ <div className="flex-1 flex items-center justify-start gap-1.5">
390
+ <Uploader
391
+ pages={pages}
392
+ onLoading={setIsUploading}
393
+ isLoading={isUploading}
394
+ onFiles={setFiles}
395
+ onSelectFile={(file) => {
396
+ if (selectedFiles.includes(file)) {
397
+ setSelectedFiles((prev) => prev.filter((f) => f !== file));
398
+ } else {
399
+ setSelectedFiles((prev) => [...prev, file]);
400
+ }
401
+ }}
402
+ files={files}
403
+ selectedFiles={selectedFiles}
404
+ project={project}
405
+ />
406
+ {isNew && <ReImagine onRedesign={(md) => callAi(md)} />}
407
+ {!isSameHtml && (
408
+ <Tooltip>
409
+ <TooltipTrigger asChild>
410
+ <Button
411
+ size="xs"
412
+ variant={isEditableModeEnabled ? "default" : "outline"}
413
+ onClick={() => {
414
+ setIsEditableModeEnabled?.(!isEditableModeEnabled);
415
+ }}
416
+ className={classNames("h-[28px]", {
417
+ "!text-neutral-400 hover:!text-neutral-200 !border-neutral-600 !hover:!border-neutral-500":
418
+ !isEditableModeEnabled,
419
+ })}
420
+ >
421
+ <Crosshair className="size-4" />
422
+ Edit
423
+ </Button>
424
+ </TooltipTrigger>
425
+ <TooltipContent
426
+ align="start"
427
+ className="bg-neutral-950 text-xs text-neutral-200 py-1 px-2 rounded-md -translate-y-0.5"
428
+ >
429
+ Select an element on the page to ask DeepSite edit it
430
+ directly.
431
+ </TooltipContent>
432
+ </Tooltip>
433
+ )}
434
+ {/* <InviteFriends /> */}
435
+ </div>
436
+ <div className="flex items-center justify-end gap-2">
437
+ <Settings
438
+ provider={provider as string}
439
+ model={model as string}
440
+ onChange={setProvider}
441
+ onModelChange={setModel}
442
+ open={openProvider}
443
+ error={providerError}
444
+ isFollowUp={!isSameHtml && isFollowUp}
445
+ onClose={setOpenProvider}
446
+ />
447
+ <Button
448
+ size="iconXs"
449
+ disabled={isAiWorking || !prompt.trim()}
450
+ onClick={() => callAi()}
451
+ >
452
+ <ArrowUp className="size-4" />
453
+ </Button>
454
+ </div>
455
+ </div>
456
+ <LoginModal open={open} onClose={() => setOpen(false)} pages={pages} />
457
+ <ProModal
458
+ pages={pages}
459
+ open={openProModal}
460
+ onClose={() => setOpenProModal(false)}
461
+ />
462
+ {pages.length === 1 && (
463
+ <div className="border border-sky-500/20 bg-sky-500/40 hover:bg-sky-600 transition-all duration-200 text-sky-500 pl-2 pr-4 py-1.5 text-xs rounded-full absolute top-0 -translate-y-[calc(100%+8px)] left-0 max-w-max flex items-center justify-start gap-2">
464
+ <span className="rounded-full text-[10px] font-semibold bg-white text-neutral-900 px-1.5 py-0.5">
465
+ NEW
466
+ </span>
467
+ <p className="text-sm text-neutral-100">
468
+ DeepSite can now create multiple pages at once. Try it!
469
+ </p>
470
+ </div>
471
+ )}
472
+ {!isSameHtml && (
473
+ <div className="absolute top-0 right-0 -translate-y-[calc(100%+8px)] select-none text-xs text-neutral-400 flex items-center justify-center gap-2 bg-neutral-800 border border-neutral-700 rounded-md p-1 pr-2.5">
474
+ <label
475
+ htmlFor="diff-patch-checkbox"
476
+ className="flex items-center gap-1.5 cursor-pointer"
477
+ >
478
+ <Checkbox
479
+ id="diff-patch-checkbox"
480
+ checked={isFollowUp}
481
+ onCheckedChange={(e) => {
482
+ if (e === true && !isSameHtml && selectedModel?.isThinker) {
483
+ setModel(MODELS[0].value);
484
+ }
485
+ setIsFollowUp(e === true);
486
+ }}
487
+ />
488
+ Diff-Patch Update
489
+ </label>
490
+ <FollowUpTooltip />
491
+ </div>
492
+ )}
493
+ </div>
494
+ <audio ref={hookAudio} id="audio" className="hidden">
495
+ <source src="/success.mp3" type="audio/mpeg" />
496
+ Your browser does not support the audio element.
497
+ </audio>
498
+ </div>
499
+ );
500
+ }
components/editor/ask-ai/re-imagine.tsx ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Paintbrush } from "lucide-react";
3
+ import { toast } from "sonner";
4
+
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Popover,
8
+ PopoverContent,
9
+ PopoverTrigger,
10
+ } from "@/components/ui/popover";
11
+ import { Input } from "@/components/ui/input";
12
+ import Loading from "@/components/loading";
13
+ import { api } from "@/lib/api";
14
+
15
+ export function ReImagine({
16
+ onRedesign,
17
+ }: {
18
+ onRedesign: (md: string) => void;
19
+ }) {
20
+ const [url, setUrl] = useState<string>("");
21
+ const [open, setOpen] = useState(false);
22
+ const [isLoading, setIsLoading] = useState(false);
23
+
24
+ const checkIfUrlIsValid = (url: string) => {
25
+ const urlPattern = new RegExp(
26
+ /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/,
27
+ "i"
28
+ );
29
+ return urlPattern.test(url);
30
+ };
31
+
32
+ const handleClick = async () => {
33
+ if (isLoading) return; // Prevent multiple clicks while loading
34
+ if (!url) {
35
+ toast.error("Please enter a URL.");
36
+ return;
37
+ }
38
+ if (!checkIfUrlIsValid(url)) {
39
+ toast.error("Please enter a valid URL.");
40
+ return;
41
+ }
42
+ setIsLoading(true);
43
+ const response = await api.put("/re-design", {
44
+ url: url.trim(),
45
+ });
46
+ if (response?.data?.ok) {
47
+ setOpen(false);
48
+ setUrl("");
49
+ onRedesign(response.data.markdown);
50
+ toast.success("DeepSite is redesigning your site! Let him cook... 🔥");
51
+ } else {
52
+ toast.error(response?.data?.error || "Failed to redesign the site.");
53
+ }
54
+ setIsLoading(false);
55
+ };
56
+
57
+ return (
58
+ <Popover open={open} onOpenChange={setOpen}>
59
+ <form>
60
+ <PopoverTrigger asChild>
61
+ <Button
62
+ size="iconXs"
63
+ variant="outline"
64
+ className="!border-neutral-600 !text-neutral-400 !hover:!border-neutral-500 hover:!text-neutral-300"
65
+ >
66
+ <Paintbrush className="size-4" />
67
+ </Button>
68
+ </PopoverTrigger>
69
+ <PopoverContent
70
+ align="start"
71
+ className="!rounded-2xl !p-0 !bg-white !border-neutral-100 min-w-xs text-center overflow-hidden"
72
+ >
73
+ <header className="bg-neutral-50 p-6 border-b border-neutral-200/60">
74
+ <div className="flex items-center justify-center -space-x-4 mb-3">
75
+ <div className="size-9 rounded-full bg-pink-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
76
+ 🎨
77
+ </div>
78
+ <div className="size-11 rounded-full bg-amber-200 shadow-2xl flex items-center justify-center text-2xl z-2">
79
+ 🥳
80
+ </div>
81
+ <div className="size-9 rounded-full bg-sky-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
82
+ 💎
83
+ </div>
84
+ </div>
85
+ <p className="text-xl font-semibold text-neutral-950">
86
+ Redesign your Site!
87
+ </p>
88
+ <p className="text-sm text-neutral-500 mt-1.5">
89
+ Try our new Redesign feature to give your site a fresh look.
90
+ </p>
91
+ </header>
92
+ <main className="space-y-4 p-6">
93
+ <div>
94
+ <p className="text-sm text-neutral-700 mb-2">
95
+ Enter your website URL to get started:
96
+ </p>
97
+ <Input
98
+ type="text"
99
+ placeholder="https://example.com"
100
+ value={url}
101
+ onChange={(e) => setUrl(e.target.value)}
102
+ onBlur={(e) => {
103
+ const inputUrl = e.target.value.trim();
104
+ if (!inputUrl) {
105
+ setUrl("");
106
+ return;
107
+ }
108
+ if (!checkIfUrlIsValid(inputUrl)) {
109
+ toast.error("Please enter a valid URL.");
110
+ return;
111
+ }
112
+ setUrl(inputUrl);
113
+ }}
114
+ className="!bg-white !border-neutral-300 !text-neutral-800 !placeholder:text-neutral-400 selection:!bg-blue-100"
115
+ />
116
+ </div>
117
+ <div>
118
+ <p className="text-sm text-neutral-700 mb-2">
119
+ Then, let&apos;s redesign it!
120
+ </p>
121
+ <Button
122
+ variant="black"
123
+ onClick={handleClick}
124
+ className="relative w-full"
125
+ >
126
+ {isLoading ? (
127
+ <>
128
+ <Loading
129
+ overlay={false}
130
+ className="ml-2 size-4 animate-spin"
131
+ />
132
+ Fetching your site...
133
+ </>
134
+ ) : (
135
+ <>
136
+ Redesign <Paintbrush className="size-4" />
137
+ </>
138
+ )}
139
+ </Button>
140
+ </div>
141
+ </main>
142
+ </PopoverContent>
143
+ </form>
144
+ </Popover>
145
+ );
146
+ }
components/editor/ask-ai/selected-files.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Image from "next/image";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { Minus } from "lucide-react";
5
+
6
+ export const SelectedFiles = ({
7
+ files,
8
+ isAiWorking,
9
+ onDelete,
10
+ }: {
11
+ files: string[];
12
+ isAiWorking: boolean;
13
+ onDelete: (file: string) => void;
14
+ }) => {
15
+ if (files.length === 0) return null;
16
+ return (
17
+ <div className="px-4 pt-3">
18
+ <div className="flex items-center justify-start gap-2">
19
+ {files.map((file) => (
20
+ <div
21
+ key={file}
22
+ className="flex items-center relative justify-start gap-2 p-1 bg-neutral-700 rounded-md"
23
+ >
24
+ <Image
25
+ src={file}
26
+ alt="uploaded image"
27
+ className="size-12 rounded-md object-cover"
28
+ width={40}
29
+ height={40}
30
+ />
31
+ <Button
32
+ size="iconXsss"
33
+ variant="secondary"
34
+ className={`absolute top-0.5 right-0.5 ${
35
+ isAiWorking ? "opacity-50 !cursor-not-allowed" : ""
36
+ }`}
37
+ disabled={isAiWorking}
38
+ onClick={() => onDelete(file)}
39
+ >
40
+ <Minus className="size-4" />
41
+ </Button>
42
+ </div>
43
+ ))}
44
+ </div>
45
+ </div>
46
+ );
47
+ };
components/editor/ask-ai/selected-html-element.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import classNames from "classnames";
2
+ import { Code, XCircle } from "lucide-react";
3
+
4
+ import { Collapsible, CollapsibleTrigger } from "@/components/ui/collapsible";
5
+ import { htmlTagToText } from "@/lib/html-tag-to-text";
6
+
7
+ export const SelectedHtmlElement = ({
8
+ element,
9
+ isAiWorking = false,
10
+ onDelete,
11
+ }: {
12
+ element: HTMLElement | null;
13
+ isAiWorking: boolean;
14
+ onDelete?: () => void;
15
+ }) => {
16
+ if (!element) return null;
17
+
18
+ const tagName = element.tagName.toLowerCase();
19
+ return (
20
+ <Collapsible
21
+ className={classNames(
22
+ "border border-neutral-700 rounded-xl p-1.5 pr-3 max-w-max hover:brightness-110 transition-all duration-200 ease-in-out !cursor-pointer",
23
+ {
24
+ "!cursor-pointer": !isAiWorking,
25
+ "opacity-50 !cursor-not-allowed": isAiWorking,
26
+ }
27
+ )}
28
+ disabled={isAiWorking}
29
+ onClick={() => {
30
+ if (!isAiWorking && onDelete) {
31
+ onDelete();
32
+ }
33
+ }}
34
+ >
35
+ <CollapsibleTrigger className="flex items-center justify-start gap-2 cursor-pointer">
36
+ <div className="rounded-lg bg-neutral-700 size-6 flex items-center justify-center">
37
+ <Code className="text-neutral-300 size-3.5" />
38
+ </div>
39
+ <p className="text-sm font-semibold text-neutral-300">
40
+ {element.textContent?.trim().split(/\s+/)[0]} {htmlTagToText(tagName)}
41
+ </p>
42
+ <XCircle className="text-neutral-300 size-4" />
43
+ </CollapsibleTrigger>
44
+ {/* <CollapsibleContent className="border-t border-neutral-700 pt-2 mt-2">
45
+ <div className="text-xs text-neutral-400">
46
+ <p>
47
+ <span className="font-semibold">ID:</span> {element.id || "No ID"}
48
+ </p>
49
+ <p>
50
+ <span className="font-semibold">Classes:</span>{" "}
51
+ {element.className || "No classes"}
52
+ </p>
53
+ </div>
54
+ </CollapsibleContent> */}
55
+ </Collapsible>
56
+ );
57
+ };
components/editor/ask-ai/settings.tsx ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import classNames from "classnames";
2
+ import { PiGearSixFill } from "react-icons/pi";
3
+ import { RiCheckboxCircleFill } from "react-icons/ri";
4
+
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from "@/components/ui/popover";
10
+ import { PROVIDERS, MODELS } from "@/lib/providers";
11
+ import { Button } from "@/components/ui/button";
12
+ import {
13
+ Select,
14
+ SelectContent,
15
+ SelectGroup,
16
+ SelectItem,
17
+ SelectLabel,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "@/components/ui/select";
21
+ import { useMemo } from "react";
22
+ import { useUpdateEffect } from "react-use";
23
+ import Image from "next/image";
24
+
25
+ export function Settings({
26
+ open,
27
+ onClose,
28
+ provider,
29
+ model,
30
+ error,
31
+ isFollowUp = false,
32
+ onChange,
33
+ onModelChange,
34
+ }: {
35
+ open: boolean;
36
+ provider: string;
37
+ model: string;
38
+ error?: string;
39
+ isFollowUp?: boolean;
40
+ onClose: React.Dispatch<React.SetStateAction<boolean>>;
41
+ onChange: (provider: string) => void;
42
+ onModelChange: (model: string) => void;
43
+ }) {
44
+ const modelAvailableProviders = useMemo(() => {
45
+ const availableProviders = MODELS.find(
46
+ (m: { value: string }) => m.value === model
47
+ )?.providers;
48
+ if (!availableProviders) return Object.keys(PROVIDERS);
49
+ return Object.keys(PROVIDERS).filter((id) =>
50
+ availableProviders.includes(id)
51
+ );
52
+ }, [model]);
53
+
54
+ useUpdateEffect(() => {
55
+ if (provider !== "auto" && !modelAvailableProviders.includes(provider)) {
56
+ onChange("auto");
57
+ }
58
+ }, [model, provider]);
59
+
60
+ return (
61
+ <div className="">
62
+ <Popover open={open} onOpenChange={onClose}>
63
+ <PopoverTrigger asChild>
64
+ <Button variant="black" size="sm">
65
+ <PiGearSixFill className="size-4" />
66
+ Settings
67
+ </Button>
68
+ </PopoverTrigger>
69
+ <PopoverContent
70
+ className="!rounded-2xl p-0 !w-96 overflow-hidden !bg-neutral-900"
71
+ align="center"
72
+ >
73
+ <header className="flex items-center justify-center text-sm px-4 py-3 border-b gap-2 bg-neutral-950 border-neutral-800 font-semibold text-neutral-200">
74
+ Customize Settings
75
+ </header>
76
+ <main className="px-4 pt-5 pb-6 space-y-5">
77
+ {error !== "" && (
78
+ <p className="text-red-500 text-sm font-medium mb-2 flex items-center justify-between bg-red-500/10 p-2 rounded-md">
79
+ {error}
80
+ </p>
81
+ )}
82
+ <label className="block">
83
+ <p className="text-neutral-300 text-sm mb-2.5">Choose a model</p>
84
+ <Select defaultValue={model} onValueChange={onModelChange}>
85
+ <SelectTrigger className="w-full">
86
+ <SelectValue placeholder="Select a model" />
87
+ </SelectTrigger>
88
+ <SelectContent>
89
+ <SelectGroup>
90
+ <SelectLabel>Models</SelectLabel>
91
+ {MODELS.map(
92
+ ({
93
+ value,
94
+ label,
95
+ isNew = false,
96
+ isThinker = false,
97
+ }: {
98
+ value: string;
99
+ label: string;
100
+ isNew?: boolean;
101
+ isThinker?: boolean;
102
+ }) => (
103
+ <SelectItem
104
+ key={value}
105
+ value={value}
106
+ className=""
107
+ disabled={isThinker && isFollowUp}
108
+ >
109
+ {label}
110
+ {isNew && (
111
+ <span className="text-xs bg-gradient-to-br from-sky-400 to-sky-600 text-white rounded-full px-1.5 py-0.5">
112
+ New
113
+ </span>
114
+ )}
115
+ </SelectItem>
116
+ )
117
+ )}
118
+ </SelectGroup>
119
+ </SelectContent>
120
+ </Select>
121
+ </label>
122
+ {isFollowUp && (
123
+ <div className="bg-amber-500/10 border-amber-500/10 p-3 text-xs text-amber-500 border rounded-lg">
124
+ Note: You can&apos;t use a Thinker model for follow-up requests.
125
+ We automatically switch to the default model for you.
126
+ </div>
127
+ )}
128
+ <div className="flex flex-col gap-3">
129
+ <div className="flex items-center justify-between">
130
+ <div>
131
+ <p className="text-neutral-300 text-sm mb-1.5">
132
+ Use auto-provider
133
+ </p>
134
+ <p className="text-xs text-neutral-400/70">
135
+ We&apos;ll automatically select the best provider for you
136
+ based on your prompt.
137
+ </p>
138
+ </div>
139
+ <div
140
+ className={classNames(
141
+ "bg-neutral-700 rounded-full min-w-10 w-10 h-6 flex items-center justify-between p-1 cursor-pointer transition-all duration-200",
142
+ {
143
+ "!bg-sky-500": provider === "auto",
144
+ }
145
+ )}
146
+ onClick={() => {
147
+ const foundModel = MODELS.find(
148
+ (m: { value: string }) => m.value === model
149
+ );
150
+ if (provider === "auto" && foundModel?.autoProvider) {
151
+ onChange(foundModel.autoProvider);
152
+ } else {
153
+ onChange("auto");
154
+ }
155
+ }}
156
+ >
157
+ <div
158
+ className={classNames(
159
+ "w-4 h-4 rounded-full shadow-md transition-all duration-200 bg-neutral-200",
160
+ {
161
+ "translate-x-4": provider === "auto",
162
+ }
163
+ )}
164
+ />
165
+ </div>
166
+ </div>
167
+ <label className="block">
168
+ <p className="text-neutral-300 text-sm mb-2">
169
+ Inference Provider
170
+ </p>
171
+ <div className="grid grid-cols-2 gap-1.5">
172
+ {modelAvailableProviders.map((id: string) => (
173
+ <Button
174
+ key={id}
175
+ variant={id === provider ? "default" : "secondary"}
176
+ size="sm"
177
+ onClick={() => {
178
+ onChange(id);
179
+ }}
180
+ >
181
+ <Image
182
+ src={`/providers/${id}.svg`}
183
+ alt={PROVIDERS[id as keyof typeof PROVIDERS].name}
184
+ className="size-5 mr-2"
185
+ width={20}
186
+ height={20}
187
+ />
188
+ {PROVIDERS[id as keyof typeof PROVIDERS].name}
189
+ {id === provider && (
190
+ <RiCheckboxCircleFill className="ml-2 size-4 text-blue-500" />
191
+ )}
192
+ </Button>
193
+ ))}
194
+ </div>
195
+ </label>
196
+ </div>
197
+ </main>
198
+ </PopoverContent>
199
+ </Popover>
200
+ </div>
201
+ );
202
+ }
components/editor/ask-ai/uploader.tsx ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useState } from "react";
2
+ import { Images, Upload } from "lucide-react";
3
+ import Image from "next/image";
4
+
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from "@/components/ui/popover";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Page, Project } from "@/types";
12
+ import Loading from "@/components/loading";
13
+ import { RiCheckboxCircleFill } from "react-icons/ri";
14
+ import { useUser } from "@/hooks/useUser";
15
+ import { LoginModal } from "@/components/login-modal";
16
+ import { DeployButtonContent } from "../deploy-button/content";
17
+
18
+ export const Uploader = ({
19
+ pages,
20
+ onLoading,
21
+ isLoading,
22
+ onFiles,
23
+ onSelectFile,
24
+ selectedFiles,
25
+ files,
26
+ project,
27
+ }: {
28
+ pages: Page[];
29
+ onLoading: (isLoading: boolean) => void;
30
+ isLoading: boolean;
31
+ files: string[];
32
+ onFiles: React.Dispatch<React.SetStateAction<string[]>>;
33
+ onSelectFile: (file: string) => void;
34
+ selectedFiles: string[];
35
+ project?: Project | null;
36
+ }) => {
37
+ const { user } = useUser();
38
+
39
+ const [open, setOpen] = useState(false);
40
+ const fileInputRef = useRef<HTMLInputElement>(null);
41
+
42
+ const uploadFiles = async (files: FileList | null) => {
43
+ if (!files) return;
44
+ if (!project) return;
45
+
46
+ onLoading(true);
47
+
48
+ const images = Array.from(files).filter((file) => {
49
+ return file.type.startsWith("image/");
50
+ });
51
+
52
+ const data = new FormData();
53
+ images.forEach((image) => {
54
+ data.append("images", image);
55
+ });
56
+
57
+ const response = await fetch(
58
+ `/api/me/projects/${project.space_id}/images`,
59
+ {
60
+ method: "POST",
61
+ body: data,
62
+ }
63
+ );
64
+ if (response.ok) {
65
+ const data = await response.json();
66
+ onFiles((prev) => [...prev, ...data.uploadedFiles]);
67
+ }
68
+ onLoading(false);
69
+ };
70
+
71
+ // TODO FIRST PUBLISH YOUR PROJECT TO UPLOAD IMAGES.
72
+ return user?.id ? (
73
+ <Popover open={open} onOpenChange={setOpen}>
74
+ <form>
75
+ <PopoverTrigger asChild>
76
+ <Button
77
+ size="iconXs"
78
+ variant="outline"
79
+ className="!border-neutral-600 !text-neutral-400 !hover:!border-neutral-500 hover:!text-neutral-300"
80
+ >
81
+ <Images className="size-4" />
82
+ </Button>
83
+ </PopoverTrigger>
84
+ <PopoverContent
85
+ align="start"
86
+ className="!rounded-2xl !p-0 !bg-white !border-neutral-100 min-w-xs text-center overflow-hidden"
87
+ >
88
+ {project?.space_id ? (
89
+ <>
90
+ <header className="bg-neutral-50 p-6 border-b border-neutral-200/60">
91
+ <div className="flex items-center justify-center -space-x-4 mb-3">
92
+ <div className="size-9 rounded-full bg-pink-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
93
+ 🎨
94
+ </div>
95
+ <div className="size-11 rounded-full bg-amber-200 shadow-2xl flex items-center justify-center text-2xl z-2">
96
+ 🖼️
97
+ </div>
98
+ <div className="size-9 rounded-full bg-sky-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
99
+ 💻
100
+ </div>
101
+ </div>
102
+ <p className="text-xl font-semibold text-neutral-950">
103
+ Add Custom Images
104
+ </p>
105
+ <p className="text-sm text-neutral-500 mt-1.5">
106
+ Upload images to your project and use them with DeepSite!
107
+ </p>
108
+ </header>
109
+ <main className="space-y-4 p-5">
110
+ <div>
111
+ <p className="text-xs text-left text-neutral-700 mb-2">
112
+ Uploaded Images
113
+ </p>
114
+ <div className="grid grid-cols-4 gap-1 flex-wrap max-h-40 overflow-y-auto">
115
+ {files.map((file) => (
116
+ <div
117
+ key={file}
118
+ className="select-none relative cursor-pointer bg-white rounded-md border-[2px] border-white hover:shadow-2xl transition-all duration-300"
119
+ onClick={() => onSelectFile(file)}
120
+ >
121
+ <Image
122
+ src={file}
123
+ alt="uploaded image"
124
+ width={56}
125
+ height={56}
126
+ className="object-cover w-full rounded-sm aspect-square"
127
+ />
128
+ {selectedFiles.includes(file) && (
129
+ <div className="absolute top-0 right-0 h-full w-full flex items-center justify-center bg-black/50 rounded-md">
130
+ <RiCheckboxCircleFill className="size-6 text-neutral-100" />
131
+ </div>
132
+ )}
133
+ </div>
134
+ ))}
135
+ </div>
136
+ </div>
137
+ <div>
138
+ <p className="text-xs text-left text-neutral-700 mb-2">
139
+ Or import images from your computer
140
+ </p>
141
+ <Button
142
+ variant="black"
143
+ onClick={() => fileInputRef.current?.click()}
144
+ className="relative w-full"
145
+ >
146
+ {isLoading ? (
147
+ <>
148
+ <Loading
149
+ overlay={false}
150
+ className="ml-2 size-4 animate-spin"
151
+ />
152
+ Uploading image(s)...
153
+ </>
154
+ ) : (
155
+ <>
156
+ <Upload className="size-4" />
157
+ Upload Images
158
+ </>
159
+ )}
160
+ </Button>
161
+ <input
162
+ ref={fileInputRef}
163
+ type="file"
164
+ className="hidden"
165
+ multiple
166
+ accept="image/*"
167
+ onChange={(e) => uploadFiles(e.target.files)}
168
+ />
169
+ </div>
170
+ </main>
171
+ </>
172
+ ) : (
173
+ <DeployButtonContent
174
+ pages={pages}
175
+ prompts={[]}
176
+ options={{
177
+ description: "Publish your project first to add custom images.",
178
+ }}
179
+ />
180
+ )}
181
+ </PopoverContent>
182
+ </form>
183
+ </Popover>
184
+ ) : (
185
+ <>
186
+ <Button
187
+ size="iconXs"
188
+ variant="outline"
189
+ className="!border-neutral-600 !text-neutral-400 !hover:!border-neutral-500 hover:!text-neutral-300"
190
+ onClick={() => setOpen(true)}
191
+ >
192
+ <Images className="size-4" />
193
+ </Button>
194
+ <LoginModal
195
+ open={open}
196
+ onClose={() => setOpen(false)}
197
+ pages={pages}
198
+ title="Log In to add Custom Images"
199
+ description="Log In through your Hugging Face account to publish your project and increase your monthly free limit."
200
+ />
201
+ </>
202
+ );
203
+ };
components/editor/deploy-button/content.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Rocket } from "lucide-react";
2
+ import Image from "next/image";
3
+
4
+ import Loading from "@/components/loading";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import SpaceIcon from "@/assets/space.svg";
8
+ import { Page } from "@/types";
9
+ import { api } from "@/lib/api";
10
+ import { toast } from "sonner";
11
+ import { useState } from "react";
12
+ import { useRouter } from "next/navigation";
13
+
14
+ export const DeployButtonContent = ({
15
+ pages,
16
+ options,
17
+ prompts,
18
+ }: {
19
+ pages: Page[];
20
+ options?: {
21
+ title?: string;
22
+ description?: string;
23
+ };
24
+ prompts: string[];
25
+ }) => {
26
+ const router = useRouter();
27
+ const [loading, setLoading] = useState(false);
28
+
29
+ const [config, setConfig] = useState({
30
+ title: "",
31
+ });
32
+
33
+ const createSpace = async () => {
34
+ if (!config.title) {
35
+ toast.error("Please enter a title for your space.");
36
+ return;
37
+ }
38
+ setLoading(true);
39
+
40
+ try {
41
+ const res = await api.post("/me/projects", {
42
+ title: config.title,
43
+ pages,
44
+ prompts,
45
+ });
46
+ if (res.data.ok) {
47
+ router.push(`/projects/${res.data.path}?deploy=true`);
48
+ } else {
49
+ toast.error(res?.data?.error || "Failed to create space");
50
+ }
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ } catch (err: any) {
53
+ toast.error(err.response?.data?.error || err.message);
54
+ } finally {
55
+ setLoading(false);
56
+ }
57
+ };
58
+
59
+ return (
60
+ <>
61
+ <header className="bg-neutral-50 p-6 border-b border-neutral-200/60">
62
+ <div className="flex items-center justify-center -space-x-4 mb-3">
63
+ <div className="size-9 rounded-full bg-amber-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
64
+ 🚀
65
+ </div>
66
+ <div className="size-11 rounded-full bg-red-200 shadow-2xl flex items-center justify-center z-2">
67
+ <Image src={SpaceIcon} alt="Space Icon" className="size-7" />
68
+ </div>
69
+ <div className="size-9 rounded-full bg-sky-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
70
+ 👻
71
+ </div>
72
+ </div>
73
+ <p className="text-xl font-semibold text-neutral-950">
74
+ Publish as Space!
75
+ </p>
76
+ <p className="text-sm text-neutral-500 mt-1.5">
77
+ {options?.description ??
78
+ "Save and Publish your project to a Space on the Hub. Spaces are a way to share your project with the world."}
79
+ </p>
80
+ </header>
81
+ <main className="space-y-4 p-6">
82
+ <div>
83
+ <p className="text-sm text-neutral-700 mb-2">
84
+ Choose a title for your space:
85
+ </p>
86
+ <Input
87
+ type="text"
88
+ placeholder="My Awesome Website"
89
+ value={config.title}
90
+ onChange={(e) => setConfig({ ...config, title: e.target.value })}
91
+ className="!bg-white !border-neutral-300 !text-neutral-800 !placeholder:text-neutral-400 selection:!bg-blue-100"
92
+ />
93
+ </div>
94
+ <div>
95
+ <p className="text-sm text-neutral-700 mb-2">
96
+ Then, let&apos;s publish it!
97
+ </p>
98
+ <Button
99
+ variant="black"
100
+ onClick={createSpace}
101
+ className="relative w-full"
102
+ disabled={loading}
103
+ >
104
+ Publish Space <Rocket className="size-4" />
105
+ {loading && <Loading className="ml-2 size-4 animate-spin" />}
106
+ </Button>
107
+ </div>
108
+ </main>
109
+ </>
110
+ );
111
+ };
components/editor/deploy-button/index.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useState } from "react";
3
+ import { MdSave } from "react-icons/md";
4
+
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Popover,
8
+ PopoverContent,
9
+ PopoverTrigger,
10
+ } from "@/components/ui/popover";
11
+ import { LoginModal } from "@/components/login-modal";
12
+ import { useUser } from "@/hooks/useUser";
13
+ import { Page } from "@/types";
14
+ import { DeployButtonContent } from "./content";
15
+
16
+ export function DeployButton({
17
+ pages,
18
+ prompts,
19
+ }: {
20
+ pages: Page[];
21
+ prompts: string[];
22
+ }) {
23
+ const { user } = useUser();
24
+ const [open, setOpen] = useState(false);
25
+
26
+ return (
27
+ <div className="flex items-center justify-end gap-5">
28
+ <div className="relative flex items-center justify-end">
29
+ {user?.id ? (
30
+ <Popover>
31
+ <PopoverTrigger asChild>
32
+ <div>
33
+ <Button variant="default" className="max-lg:hidden !px-4">
34
+ <MdSave className="size-4" />
35
+ Publish your Project
36
+ </Button>
37
+ <Button variant="default" size="sm" className="lg:hidden">
38
+ Publish
39
+ </Button>
40
+ </div>
41
+ </PopoverTrigger>
42
+ <PopoverContent
43
+ className="!rounded-2xl !p-0 !bg-white !border-neutral-200 min-w-xs text-center overflow-hidden"
44
+ align="end"
45
+ >
46
+ <DeployButtonContent pages={pages} prompts={prompts} />
47
+ </PopoverContent>
48
+ </Popover>
49
+ ) : (
50
+ <>
51
+ <Button
52
+ variant="default"
53
+ className="max-lg:hidden !px-4"
54
+ onClick={() => setOpen(true)}
55
+ >
56
+ <MdSave className="size-4" />
57
+ Publish your Project
58
+ </Button>
59
+ <Button
60
+ variant="default"
61
+ size="sm"
62
+ className="lg:hidden"
63
+ onClick={() => setOpen(true)}
64
+ >
65
+ Publish
66
+ </Button>
67
+ </>
68
+ )}
69
+ <LoginModal
70
+ open={open}
71
+ onClose={() => setOpen(false)}
72
+ pages={pages}
73
+ title="Log In to publish your Project"
74
+ description="Log In through your Hugging Face account to publish your project and increase your monthly free limit."
75
+ />
76
+ </div>
77
+ </div>
78
+ );
79
+ }
components/editor/footer/index.tsx ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import classNames from "classnames";
2
+ import { FaMobileAlt } from "react-icons/fa";
3
+ import { HelpCircle, LogIn, RefreshCcw, SparkleIcon } from "lucide-react";
4
+ import { FaLaptopCode } from "react-icons/fa6";
5
+ import { HtmlHistory, Page } from "@/types";
6
+ import { Button } from "@/components/ui/button";
7
+ import { MdAdd } from "react-icons/md";
8
+ import { History } from "@/components/editor/history";
9
+ import { UserMenu } from "@/components/user-menu";
10
+ import { useUser } from "@/hooks/useUser";
11
+ import Link from "next/link";
12
+ import { useLocalStorage } from "react-use";
13
+ import { isTheSameHtml } from "@/lib/compare-html-diff";
14
+
15
+ const DEVICES = [
16
+ {
17
+ name: "desktop",
18
+ icon: FaLaptopCode,
19
+ },
20
+ {
21
+ name: "mobile",
22
+ icon: FaMobileAlt,
23
+ },
24
+ ];
25
+
26
+ export function Footer({
27
+ pages,
28
+ isNew = false,
29
+ htmlHistory,
30
+ setPages,
31
+ device,
32
+ setDevice,
33
+ iframeRef,
34
+ }: {
35
+ pages: Page[];
36
+ isNew?: boolean;
37
+ htmlHistory?: HtmlHistory[];
38
+ device: "desktop" | "mobile";
39
+ setPages: (pages: Page[]) => void;
40
+ iframeRef?: React.RefObject<HTMLIFrameElement | null>;
41
+ setDevice: React.Dispatch<React.SetStateAction<"desktop" | "mobile">>;
42
+ }) {
43
+ const { user, openLoginWindow } = useUser();
44
+
45
+ const handleRefreshIframe = () => {
46
+ if (iframeRef?.current) {
47
+ const iframe = iframeRef.current;
48
+ const content = iframe.srcdoc;
49
+ iframe.srcdoc = "";
50
+ setTimeout(() => {
51
+ iframe.srcdoc = content;
52
+ }, 10);
53
+ }
54
+ };
55
+
56
+ const [, setStorage] = useLocalStorage("pages");
57
+ const handleClick = async () => {
58
+ if (pages && !isTheSameHtml(pages[0].html)) {
59
+ setStorage(pages);
60
+ }
61
+ openLoginWindow();
62
+ };
63
+
64
+ return (
65
+ <footer className="border-t bg-slate-200 border-slate-300 dark:bg-neutral-950 dark:border-neutral-800 px-3 py-2 flex items-center justify-between sticky bottom-0 z-20">
66
+ <div className="flex items-center gap-2">
67
+ {user ? (
68
+ user?.isLocalUse ? (
69
+ <>
70
+ <div className="max-w-max bg-amber-500/10 rounded-full px-3 py-1 text-amber-500 border border-amber-500/20 text-sm font-semibold">
71
+ Local Usage
72
+ </div>
73
+ </>
74
+ ) : (
75
+ <UserMenu className="!p-1 !pr-3 !h-auto" />
76
+ )
77
+ ) : (
78
+ <Button size="sm" variant="default" onClick={handleClick}>
79
+ <LogIn className="text-sm" />
80
+ Log In
81
+ </Button>
82
+ )}
83
+ {user && !isNew && <p className="text-neutral-700">|</p>}
84
+ {!isNew && (
85
+ <Link href="/projects/new">
86
+ <Button size="sm" variant="secondary">
87
+ <MdAdd className="text-sm" />
88
+ New <span className="max-lg:hidden">Project</span>
89
+ </Button>
90
+ </Link>
91
+ )}
92
+ {htmlHistory && htmlHistory.length > 0 && (
93
+ <>
94
+ <p className="text-neutral-700">|</p>
95
+ <History history={htmlHistory} setPages={setPages} />
96
+ </>
97
+ )}
98
+ </div>
99
+ <div className="flex justify-end items-center gap-2.5">
100
+ <a
101
+ href="https://huggingface.co/spaces/victor/deepsite-gallery"
102
+ target="_blank"
103
+ >
104
+ <Button size="sm" variant="ghost">
105
+ <SparkleIcon className="size-3.5" />
106
+ <span className="max-lg:hidden">DeepSite Gallery</span>
107
+ </Button>
108
+ </a>
109
+ <a
110
+ target="_blank"
111
+ href="https://huggingface.co/spaces/enzostvs/deepsite/discussions/157"
112
+ >
113
+ <Button size="sm" variant="outline">
114
+ <HelpCircle className="size-3.5" />
115
+ <span className="max-lg:hidden">Help</span>
116
+ </Button>
117
+ </a>
118
+ <Button size="sm" variant="outline" onClick={handleRefreshIframe}>
119
+ <RefreshCcw className="size-3.5" />
120
+ <span className="max-lg:hidden">Refresh Preview</span>
121
+ </Button>
122
+ <div className="flex items-center rounded-full p-0.5 bg-neutral-700/70 relative overflow-hidden z-0 max-lg:hidden gap-0.5">
123
+ <div
124
+ className={classNames(
125
+ "absolute left-0.5 top-0.5 rounded-full bg-white size-7 -z-[1] transition-all duration-200",
126
+ {
127
+ "translate-x-[calc(100%+2px)]": device === "mobile",
128
+ }
129
+ )}
130
+ />
131
+ {DEVICES.map((deviceItem) => (
132
+ <button
133
+ key={deviceItem.name}
134
+ className={classNames(
135
+ "rounded-full text-neutral-300 size-7 flex items-center justify-center cursor-pointer",
136
+ {
137
+ "!text-black": device === deviceItem.name,
138
+ "hover:bg-neutral-800": device !== deviceItem.name,
139
+ }
140
+ )}
141
+ onClick={() => setDevice(deviceItem.name as "desktop" | "mobile")}
142
+ >
143
+ <deviceItem.icon className="text-sm" />
144
+ </button>
145
+ ))}
146
+ </div>
147
+ </div>
148
+ </footer>
149
+ );
150
+ }
components/editor/header/index.tsx ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ReactNode } from "react";
2
+ import { Eye, MessageCircleCode } from "lucide-react";
3
+
4
+ import Logo from "@/assets/logo.svg";
5
+
6
+ import { Button } from "@/components/ui/button";
7
+ import classNames from "classnames";
8
+ import Image from "next/image";
9
+
10
+ const TABS = [
11
+ {
12
+ value: "chat",
13
+ label: "Chat",
14
+ icon: MessageCircleCode,
15
+ },
16
+ {
17
+ value: "preview",
18
+ label: "Preview",
19
+ icon: Eye,
20
+ },
21
+ ];
22
+
23
+ export function Header({
24
+ tab,
25
+ onNewTab,
26
+ children,
27
+ }: {
28
+ tab: string;
29
+ onNewTab: (tab: string) => void;
30
+ children?: ReactNode;
31
+ }) {
32
+ return (
33
+ <header className="border-b bg-slate-200 border-slate-300 dark:bg-neutral-950 dark:border-neutral-800 px-3 lg:px-6 py-2 flex items-center max-lg:gap-3 justify-between lg:grid lg:grid-cols-3 z-20">
34
+ <div className="flex items-center justify-start gap-3">
35
+ <h1 className="text-neutral-900 dark:text-white text-lg lg:text-xl font-bold flex items-center justify-start">
36
+ <Image
37
+ src={Logo}
38
+ alt="DeepSite Logo"
39
+ className="size-6 lg:size-8 mr-2 invert-100 dark:invert-0"
40
+ />
41
+ <p className="max-md:hidden flex items-center justify-start">
42
+ DeepSite
43
+ <span className="font-mono bg-gradient-to-br from-sky-500 to-emerald-500 text-neutral-950 rounded-full text-xs ml-2 px-1.5 py-0.5">
44
+ {" "}
45
+ v2
46
+ </span>
47
+ </p>
48
+ </h1>
49
+ </div>
50
+ <div className="flex items-center justify-start lg:justify-center gap-1 max-lg:pl-3 flex-1 max-lg:border-l max-lg:border-l-neutral-800">
51
+ {TABS.map((item) => (
52
+ <Button
53
+ key={item.value}
54
+ variant={tab === item.value ? "secondary" : "ghost"}
55
+ className={classNames("", {
56
+ "opacity-60": tab !== item.value,
57
+ })}
58
+ size="sm"
59
+ onClick={() => onNewTab(item.value)}
60
+ >
61
+ <item.icon className="size-4" />
62
+ <span className="hidden md:inline">{item.label}</span>
63
+ </Button>
64
+ ))}
65
+ </div>
66
+ <div className="flex items-center justify-end gap-3">{children}</div>
67
+ </header>
68
+ );
69
+ }
components/editor/history/index.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { History as HistoryIcon } from "lucide-react";
2
+ import { HtmlHistory, Page } from "@/types";
3
+ import {
4
+ Popover,
5
+ PopoverContent,
6
+ PopoverTrigger,
7
+ } from "@/components/ui/popover";
8
+ import { Button } from "@/components/ui/button";
9
+
10
+ export function History({
11
+ history,
12
+ setPages,
13
+ }: {
14
+ history: HtmlHistory[];
15
+ setPages: (pages: Page[]) => void;
16
+ }) {
17
+ return (
18
+ <Popover>
19
+ <PopoverTrigger asChild>
20
+ <Button variant="ghost" size="sm" className="max-lg:hidden">
21
+ <HistoryIcon className="size-4 text-neutral-300" />
22
+ {history?.length} edit{history.length !== 1 ? "s" : ""}
23
+ </Button>
24
+ </PopoverTrigger>
25
+ <PopoverContent
26
+ className="!rounded-2xl !p-0 overflow-hidden !bg-neutral-900"
27
+ align="start"
28
+ >
29
+ <header className="text-sm px-4 py-3 border-b gap-2 bg-neutral-950 border-neutral-800 font-semibold text-neutral-200">
30
+ History
31
+ </header>
32
+ <main className="px-4 space-y-3">
33
+ <ul className="max-h-[250px] overflow-y-auto">
34
+ {history?.map((item, index) => (
35
+ <li
36
+ key={index}
37
+ className="text-gray-300 text-xs py-2 border-b border-gray-800 last:border-0 flex items-center justify-between gap-2"
38
+ >
39
+ <div className="">
40
+ <span className="line-clamp-1">{item.prompt}</span>
41
+ <span className="text-gray-500 text-[10px]">
42
+ {new Date(item.createdAt).toLocaleDateString("en-US", {
43
+ month: "2-digit",
44
+ day: "2-digit",
45
+ year: "2-digit",
46
+ }) +
47
+ " " +
48
+ new Date(item.createdAt).toLocaleTimeString("en-US", {
49
+ hour: "2-digit",
50
+ minute: "2-digit",
51
+ second: "2-digit",
52
+ hour12: false,
53
+ })}
54
+ </span>
55
+ </div>
56
+ <Button
57
+ variant="sky"
58
+ size="xs"
59
+ onClick={() => {
60
+ console.log(item);
61
+ setPages(item.pages);
62
+ }}
63
+ >
64
+ Select
65
+ </Button>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ </main>
70
+ </PopoverContent>
71
+ </Popover>
72
+ );
73
+ }
components/editor/index.tsx ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useMemo, useRef, useState } from "react";
3
+ import { toast } from "sonner";
4
+ import { editor } from "monaco-editor";
5
+ import Editor from "@monaco-editor/react";
6
+ import { CopyIcon } from "lucide-react";
7
+ import {
8
+ useCopyToClipboard,
9
+ useEvent,
10
+ useLocalStorage,
11
+ useMount,
12
+ useUnmount,
13
+ useUpdateEffect,
14
+ } from "react-use";
15
+ import classNames from "classnames";
16
+ import { useRouter, useSearchParams } from "next/navigation";
17
+
18
+ import { Header } from "@/components/editor/header";
19
+ import { Footer } from "@/components/editor/footer";
20
+ import { defaultHTML } from "@/lib/consts";
21
+ import { Preview } from "@/components/editor/preview";
22
+ import { useEditor } from "@/hooks/useEditor";
23
+ import { AskAI } from "@/components/editor/ask-ai";
24
+ import { DeployButton } from "./deploy-button";
25
+ import { Page, Project } from "@/types";
26
+ import { SaveButton } from "./save-button";
27
+ import { LoadProject } from "../my-projects/load-project";
28
+ import { isTheSameHtml } from "@/lib/compare-html-diff";
29
+ import { ListPages } from "./pages";
30
+
31
+ export const AppEditor = ({
32
+ project,
33
+ pages: initialPages,
34
+ images,
35
+ isNew,
36
+ }: {
37
+ project?: Project | null;
38
+ pages?: Page[];
39
+ images?: string[];
40
+ isNew?: boolean;
41
+ }) => {
42
+ const [htmlStorage, , removeHtmlStorage] = useLocalStorage("pages");
43
+ const [, copyToClipboard] = useCopyToClipboard();
44
+ const { htmlHistory, setHtmlHistory, prompts, setPrompts, pages, setPages } =
45
+ useEditor(
46
+ initialPages,
47
+ project?.prompts ?? [],
48
+ typeof htmlStorage === "string" ? htmlStorage : undefined
49
+ );
50
+
51
+ const searchParams = useSearchParams();
52
+ const router = useRouter();
53
+ const deploy = searchParams.get("deploy") === "true";
54
+
55
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
56
+ const preview = useRef<HTMLDivElement>(null);
57
+ const editor = useRef<HTMLDivElement>(null);
58
+ const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
59
+ const resizer = useRef<HTMLDivElement>(null);
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ const monacoRef = useRef<any>(null);
62
+
63
+ const [currentTab, setCurrentTab] = useState("chat");
64
+ const [currentPage, setCurrentPage] = useState("index.html");
65
+ const [device, setDevice] = useState<"desktop" | "mobile">("desktop");
66
+ const [isResizing, setIsResizing] = useState(false);
67
+ const [isAiWorking, setIsAiWorking] = useState(false);
68
+ const [isEditableModeEnabled, setIsEditableModeEnabled] = useState(false);
69
+ const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
70
+ null
71
+ );
72
+ const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
73
+
74
+ const resetLayout = () => {
75
+ if (!editor.current || !preview.current) return;
76
+
77
+ // lg breakpoint is 1024px based on useBreakpoint definition and Tailwind defaults
78
+ if (window.innerWidth >= 1024) {
79
+ // Set initial 1/3 - 2/3 sizes for large screens, accounting for resizer width
80
+ const resizerWidth = resizer.current?.offsetWidth ?? 8; // w-2 = 0.5rem = 8px
81
+ const availableWidth = window.innerWidth - resizerWidth;
82
+ const initialEditorWidth = availableWidth / 3; // Editor takes 1/3 of space
83
+ const initialPreviewWidth = availableWidth - initialEditorWidth; // Preview takes 2/3
84
+ editor.current.style.width = `${initialEditorWidth}px`;
85
+ preview.current.style.width = `${initialPreviewWidth}px`;
86
+ } else {
87
+ // Remove inline styles for smaller screens, let CSS flex-col handle it
88
+ editor.current.style.width = "";
89
+ preview.current.style.width = "";
90
+ }
91
+ };
92
+
93
+ const handleResize = (e: MouseEvent) => {
94
+ if (!editor.current || !preview.current || !resizer.current) return;
95
+
96
+ const resizerWidth = resizer.current.offsetWidth;
97
+ const minWidth = 100; // Minimum width for editor/preview
98
+ const maxWidth = window.innerWidth - resizerWidth - minWidth;
99
+
100
+ const editorWidth = e.clientX;
101
+ const clampedEditorWidth = Math.max(
102
+ minWidth,
103
+ Math.min(editorWidth, maxWidth)
104
+ );
105
+ const calculatedPreviewWidth =
106
+ window.innerWidth - clampedEditorWidth - resizerWidth;
107
+
108
+ editor.current.style.width = `${clampedEditorWidth}px`;
109
+ preview.current.style.width = `${calculatedPreviewWidth}px`;
110
+ };
111
+
112
+ const handleMouseDown = () => {
113
+ setIsResizing(true);
114
+ document.addEventListener("mousemove", handleResize);
115
+ document.addEventListener("mouseup", handleMouseUp);
116
+ };
117
+
118
+ const handleMouseUp = () => {
119
+ setIsResizing(false);
120
+ document.removeEventListener("mousemove", handleResize);
121
+ document.removeEventListener("mouseup", handleMouseUp);
122
+ };
123
+
124
+ useMount(() => {
125
+ if (deploy && project?._id) {
126
+ toast.success("Your project is deployed! 🎉", {
127
+ action: {
128
+ label: "See Project",
129
+ onClick: () => {
130
+ window.open(
131
+ `https://huggingface.co/spaces/${project?.space_id}`,
132
+ "_blank"
133
+ );
134
+ },
135
+ },
136
+ });
137
+ router.replace(`/projects/${project?.space_id}`);
138
+ }
139
+ if (htmlStorage) {
140
+ removeHtmlStorage();
141
+ toast.warning("Previous HTML content restored from local storage.");
142
+ }
143
+
144
+ resetLayout();
145
+ if (!resizer.current) return;
146
+ resizer.current.addEventListener("mousedown", handleMouseDown);
147
+ window.addEventListener("resize", resetLayout);
148
+ });
149
+ useUnmount(() => {
150
+ document.removeEventListener("mousemove", handleResize);
151
+ document.removeEventListener("mouseup", handleMouseUp);
152
+ if (resizer.current) {
153
+ resizer.current.removeEventListener("mousedown", handleMouseDown);
154
+ }
155
+ window.removeEventListener("resize", resetLayout);
156
+ });
157
+
158
+ // Prevent accidental navigation away when AI is working or content has changed
159
+ useEvent("beforeunload", (e) => {
160
+ if (isAiWorking || !isTheSameHtml(currentPageData?.html)) {
161
+ e.preventDefault();
162
+ return "";
163
+ }
164
+ });
165
+
166
+ useUpdateEffect(() => {
167
+ if (currentTab === "chat") {
168
+ // Reset editor width when switching to reasoning tab
169
+ resetLayout();
170
+ // re-add the event listener for resizing
171
+ if (resizer.current) {
172
+ resizer.current.addEventListener("mousedown", handleMouseDown);
173
+ }
174
+ } else {
175
+ if (preview.current) {
176
+ // Reset preview width when switching to preview tab
177
+ preview.current.style.width = "100%";
178
+ }
179
+ }
180
+ }, [currentTab]);
181
+
182
+ const handleEditorValidation = (markers: editor.IMarker[]) => {
183
+ console.log("Editor validation markers:", markers);
184
+ };
185
+
186
+ const currentPageData = useMemo(() => {
187
+ return (
188
+ pages.find((page) => page.path === currentPage) ?? {
189
+ path: "index.html",
190
+ html: defaultHTML,
191
+ }
192
+ );
193
+ }, [pages, currentPage]);
194
+
195
+ return (
196
+ <section className="h-[100dvh] bg-neutral-950 flex flex-col">
197
+ <Header tab={currentTab} onNewTab={setCurrentTab}>
198
+ <LoadProject
199
+ onSuccess={(project: Project) => {
200
+ router.push(`/projects/${project.space_id}`);
201
+ }}
202
+ />
203
+ {/* for these buttons pass the whole pages */}
204
+ {project?._id ? (
205
+ <SaveButton pages={pages} prompts={prompts} />
206
+ ) : (
207
+ <DeployButton pages={pages} prompts={prompts} />
208
+ )}
209
+ </Header>
210
+ <main className="bg-neutral-950 flex-1 max-lg:flex-col flex w-full max-lg:h-[calc(100%-82px)] relative">
211
+ {currentTab === "chat" && (
212
+ <>
213
+ <div
214
+ ref={editor}
215
+ className="bg-neutral-900 relative flex-1 overflow-hidden h-full flex flex-col gap-2 pb-3"
216
+ >
217
+ <ListPages
218
+ pages={pages}
219
+ currentPage={currentPage}
220
+ onSelectPage={(path, newPath) => {
221
+ if (newPath) {
222
+ setPages((prev) =>
223
+ prev.map((page) =>
224
+ page.path === path ? { ...page, path: newPath } : page
225
+ )
226
+ );
227
+ setCurrentPage(newPath);
228
+ } else {
229
+ setCurrentPage(path);
230
+ }
231
+ }}
232
+ onDeletePage={(path) => {
233
+ const newPages = pages.filter((page) => page.path !== path);
234
+ setPages(newPages);
235
+ if (currentPage === path) {
236
+ setCurrentPage(newPages[0]?.path ?? "index.html");
237
+ }
238
+ }}
239
+ onNewPage={() => {
240
+ setPages((prev) => [
241
+ ...prev,
242
+ {
243
+ path: `page-${prev.length + 1}.html`,
244
+ html: defaultHTML,
245
+ },
246
+ ]);
247
+ setCurrentPage(`page-${pages.length + 1}.html`);
248
+ }}
249
+ />
250
+ <CopyIcon
251
+ className="size-4 absolute top-14 right-5 text-neutral-500 hover:text-neutral-300 z-2 cursor-pointer"
252
+ onClick={() => {
253
+ copyToClipboard(currentPageData.html);
254
+ toast.success("HTML copied to clipboard!");
255
+ }}
256
+ />
257
+ <Editor
258
+ defaultLanguage="html"
259
+ theme="vs-dark"
260
+ className={classNames(
261
+ "h-full bg-neutral-900 transition-all duration-200 absolute left-0 top-0",
262
+ {
263
+ "pointer-events-none": isAiWorking,
264
+ }
265
+ )}
266
+ options={{
267
+ colorDecorators: true,
268
+ fontLigatures: true,
269
+ theme: "vs-dark",
270
+ minimap: { enabled: false },
271
+ scrollbar: {
272
+ horizontal: "hidden",
273
+ },
274
+ wordWrap: "on",
275
+ }}
276
+ value={currentPageData.html}
277
+ onChange={(value) => {
278
+ const newValue = value ?? "";
279
+ // setHtml(newValue);
280
+ setPages((prev) =>
281
+ prev.map((page) =>
282
+ page.path === currentPageData.path
283
+ ? { ...page, html: newValue }
284
+ : page
285
+ )
286
+ );
287
+ }}
288
+ onMount={(editor, monaco) => {
289
+ editorRef.current = editor;
290
+ monacoRef.current = monaco;
291
+ }}
292
+ onValidate={handleEditorValidation}
293
+ />
294
+ <AskAI
295
+ project={project}
296
+ images={images}
297
+ currentPage={currentPageData}
298
+ htmlHistory={htmlHistory}
299
+ previousPrompts={prompts}
300
+ onSuccess={(newPages, p: string) => {
301
+ const currentHistory = [...htmlHistory];
302
+ currentHistory.unshift({
303
+ pages: newPages,
304
+ createdAt: new Date(),
305
+ prompt: p,
306
+ });
307
+ setHtmlHistory(currentHistory);
308
+ setSelectedElement(null);
309
+ setSelectedFiles([]);
310
+ // if xs or sm
311
+ if (window.innerWidth <= 1024) {
312
+ setCurrentTab("preview");
313
+ }
314
+ // if (updatedLines && updatedLines?.length > 0) {
315
+ // const decorations = updatedLines.map((line) => ({
316
+ // range: new monacoRef.current.Range(
317
+ // line[0],
318
+ // 1,
319
+ // line[1],
320
+ // 1
321
+ // ),
322
+ // options: {
323
+ // inlineClassName: "matched-line",
324
+ // },
325
+ // }));
326
+ // setTimeout(() => {
327
+ // editorRef?.current
328
+ // ?.getModel()
329
+ // ?.deltaDecorations([], decorations);
330
+
331
+ // editorRef.current?.revealLine(updatedLines[0][0]);
332
+ // }, 100);
333
+ // }
334
+ }}
335
+ setPages={setPages}
336
+ pages={pages}
337
+ setCurrentPage={setCurrentPage}
338
+ isAiWorking={isAiWorking}
339
+ setisAiWorking={setIsAiWorking}
340
+ onNewPrompt={(prompt: string) => {
341
+ setPrompts((prev) => [...prev, prompt]);
342
+ }}
343
+ onScrollToBottom={() => {
344
+ editorRef.current?.revealLine(
345
+ editorRef.current?.getModel()?.getLineCount() ?? 0
346
+ );
347
+ }}
348
+ isNew={isNew}
349
+ isEditableModeEnabled={isEditableModeEnabled}
350
+ setIsEditableModeEnabled={setIsEditableModeEnabled}
351
+ selectedElement={selectedElement}
352
+ setSelectedElement={setSelectedElement}
353
+ setSelectedFiles={setSelectedFiles}
354
+ selectedFiles={selectedFiles}
355
+ />
356
+ </div>
357
+ <div
358
+ ref={resizer}
359
+ className="bg-neutral-800 hover:bg-sky-500 active:bg-sky-500 w-1.5 cursor-col-resize h-full max-lg:hidden"
360
+ />
361
+ </>
362
+ )}
363
+ <Preview
364
+ html={currentPageData?.html}
365
+ isResizing={isResizing}
366
+ isAiWorking={isAiWorking}
367
+ ref={preview}
368
+ device={device}
369
+ pages={pages}
370
+ setCurrentPage={setCurrentPage}
371
+ currentTab={currentTab}
372
+ isEditableModeEnabled={isEditableModeEnabled}
373
+ iframeRef={iframeRef}
374
+ onClickElement={(element) => {
375
+ setIsEditableModeEnabled(false);
376
+ setSelectedElement(element);
377
+ setCurrentTab("chat");
378
+ }}
379
+ />
380
+ </main>
381
+ <Footer
382
+ pages={pages}
383
+ htmlHistory={htmlHistory}
384
+ setPages={setPages}
385
+ iframeRef={iframeRef}
386
+ device={device}
387
+ isNew={isNew}
388
+ setDevice={setDevice}
389
+ />
390
+ </section>
391
+ );
392
+ };
components/editor/pages/index.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Page } from "@/types";
2
+ import { ListPagesItem } from "./page";
3
+
4
+ export function ListPages({
5
+ pages,
6
+ currentPage,
7
+ onSelectPage,
8
+ onDeletePage,
9
+ }: {
10
+ pages: Array<Page>;
11
+ currentPage: string;
12
+ onSelectPage: (path: string, newPath?: string) => void;
13
+ onNewPage: () => void;
14
+ onDeletePage: (path: string) => void;
15
+ }) {
16
+ return (
17
+ <div className="w-full flex items-center justify-start bg-neutral-950 overflow-auto flex-nowrap min-h-[44px]">
18
+ {pages.map((page, i) => (
19
+ <ListPagesItem
20
+ key={i}
21
+ page={page}
22
+ currentPage={currentPage}
23
+ onSelectPage={onSelectPage}
24
+ onDeletePage={onDeletePage}
25
+ index={i}
26
+ />
27
+ ))}
28
+ </div>
29
+ );
30
+ }
components/editor/pages/page.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import classNames from "classnames";
2
+ import { XIcon } from "lucide-react";
3
+
4
+ import { Button } from "@/components/ui/button";
5
+ import { Page } from "@/types";
6
+
7
+ export function ListPagesItem({
8
+ page,
9
+ currentPage,
10
+ onSelectPage,
11
+ onDeletePage,
12
+ index,
13
+ }: {
14
+ page: Page;
15
+ currentPage: string;
16
+ onSelectPage: (path: string, newPath?: string) => void;
17
+ onDeletePage: (path: string) => void;
18
+ index: number;
19
+ }) {
20
+ return (
21
+ <div
22
+ key={index}
23
+ className={classNames(
24
+ "pl-6 pr-1 py-3 text-neutral-400 cursor-pointer text-sm hover:bg-neutral-900 flex items-center justify-center gap-1 group text-nowrap border-r border-neutral-800",
25
+ {
26
+ "bg-neutral-900 !text-white": currentPage === page.path,
27
+ "!pr-6": index === 0, // Ensure the first item has padding on the right
28
+ }
29
+ )}
30
+ onClick={() => onSelectPage(page.path)}
31
+ title={page.path}
32
+ >
33
+ {/* {index > 0 && (
34
+ <Button
35
+ size="iconXsss"
36
+ variant="ghost"
37
+ onClick={(e) => {
38
+ e.stopPropagation();
39
+ // open the window modal to edit the name page
40
+ let newName = window.prompt(
41
+ "Enter new name for the page:",
42
+ page.path
43
+ );
44
+ if (newName && newName.trim() !== "") {
45
+ newName = newName.toLowerCase();
46
+ if (!newName.endsWith(".html")) {
47
+ newName = newName.replace(/\.[^/.]+$/, "");
48
+ newName = newName.replace(/\s+/g, "-");
49
+ newName += ".html";
50
+ }
51
+ onSelectPage(page.path, newName);
52
+ } else {
53
+ window.alert("Page name cannot be empty.");
54
+ }
55
+ }}
56
+ >
57
+ <EditIcon className="!h-3.5 text-neutral-400 cursor-pointer hover:text-neutral-300" />
58
+ </Button>
59
+ )} */}
60
+ {page.path}
61
+ {index > 0 && (
62
+ <Button
63
+ size="iconXsss"
64
+ variant="ghost"
65
+ className="group-hover:opacity-100 opacity-0"
66
+ onClick={(e) => {
67
+ e.stopPropagation();
68
+ if (
69
+ window.confirm(
70
+ "Are you sure you want to delete this page? This action cannot be undone."
71
+ )
72
+ ) {
73
+ onDeletePage(page.path);
74
+ }
75
+ }}
76
+ >
77
+ <XIcon className="h-3 text-neutral-400 cursor-pointer hover:text-neutral-300" />
78
+ </Button>
79
+ )}
80
+ </div>
81
+ );
82
+ }
components/editor/preview/index.tsx ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useUpdateEffect } from "react-use";
3
+ import { useMemo, useState } from "react";
4
+ import classNames from "classnames";
5
+ import { toast } from "sonner";
6
+ import { useThrottleFn } from "react-use";
7
+
8
+ import { cn } from "@/lib/utils";
9
+ import { GridPattern } from "@/components/magic-ui/grid-pattern";
10
+ import { htmlTagToText } from "@/lib/html-tag-to-text";
11
+ import { Page } from "@/types";
12
+
13
+ export const Preview = ({
14
+ html,
15
+ isResizing,
16
+ isAiWorking,
17
+ ref,
18
+ device,
19
+ currentTab,
20
+ iframeRef,
21
+ pages,
22
+ setCurrentPage,
23
+ isEditableModeEnabled,
24
+ onClickElement,
25
+ }: {
26
+ html: string;
27
+ isResizing: boolean;
28
+ isAiWorking: boolean;
29
+ pages: Page[];
30
+ setCurrentPage: React.Dispatch<React.SetStateAction<string>>;
31
+ ref: React.RefObject<HTMLDivElement | null>;
32
+ iframeRef?: React.RefObject<HTMLIFrameElement | null>;
33
+ device: "desktop" | "mobile";
34
+ currentTab: string;
35
+ isEditableModeEnabled?: boolean;
36
+ onClickElement?: (element: HTMLElement) => void;
37
+ }) => {
38
+ const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
39
+ null
40
+ );
41
+
42
+ const handleMouseOver = (event: MouseEvent) => {
43
+ if (iframeRef?.current) {
44
+ const iframeDocument = iframeRef.current.contentDocument;
45
+ if (iframeDocument) {
46
+ const targetElement = event.target as HTMLElement;
47
+ if (
48
+ hoveredElement !== targetElement &&
49
+ targetElement !== iframeDocument.body
50
+ ) {
51
+ setHoveredElement(targetElement);
52
+ targetElement.classList.add("hovered-element");
53
+ } else {
54
+ return setHoveredElement(null);
55
+ }
56
+ }
57
+ }
58
+ };
59
+ const handleMouseOut = () => {
60
+ setHoveredElement(null);
61
+ };
62
+ const handleClick = (event: MouseEvent) => {
63
+ if (iframeRef?.current) {
64
+ const iframeDocument = iframeRef.current.contentDocument;
65
+ if (iframeDocument) {
66
+ const targetElement = event.target as HTMLElement;
67
+ if (targetElement !== iframeDocument.body) {
68
+ onClickElement?.(targetElement);
69
+ }
70
+ }
71
+ }
72
+ };
73
+ const handleCustomNavigation = (event: MouseEvent) => {
74
+ if (iframeRef?.current) {
75
+ const iframeDocument = iframeRef.current.contentDocument;
76
+ if (iframeDocument) {
77
+ const findClosestAnchor = (
78
+ element: HTMLElement
79
+ ): HTMLAnchorElement | null => {
80
+ let current = element;
81
+ while (current && current !== iframeDocument.body) {
82
+ if (current.tagName === "A") {
83
+ return current as HTMLAnchorElement;
84
+ }
85
+ current = current.parentElement as HTMLElement;
86
+ }
87
+ return null;
88
+ };
89
+
90
+ const anchorElement = findClosestAnchor(event.target as HTMLElement);
91
+ if (anchorElement) {
92
+ let href = anchorElement.getAttribute("href");
93
+ if (href) {
94
+ event.stopPropagation();
95
+ event.preventDefault();
96
+
97
+ if (href.includes("#") && !href.includes(".html")) {
98
+ const targetElement = iframeDocument.querySelector(href);
99
+ if (targetElement) {
100
+ targetElement.scrollIntoView({ behavior: "smooth" });
101
+ }
102
+ return;
103
+ }
104
+
105
+ href = href.split(".html")[0] + ".html";
106
+ const isPageExist = pages.some((page) => page.path === href);
107
+ if (isPageExist) {
108
+ setCurrentPage(href);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ };
115
+
116
+ useUpdateEffect(() => {
117
+ const cleanupListeners = () => {
118
+ if (iframeRef?.current?.contentDocument) {
119
+ const iframeDocument = iframeRef.current.contentDocument;
120
+ iframeDocument.removeEventListener("mouseover", handleMouseOver);
121
+ iframeDocument.removeEventListener("mouseout", handleMouseOut);
122
+ iframeDocument.removeEventListener("click", handleClick);
123
+ }
124
+ };
125
+
126
+ if (iframeRef?.current) {
127
+ const iframeDocument = iframeRef.current.contentDocument;
128
+ if (iframeDocument) {
129
+ cleanupListeners();
130
+
131
+ if (isEditableModeEnabled) {
132
+ iframeDocument.addEventListener("mouseover", handleMouseOver);
133
+ iframeDocument.addEventListener("mouseout", handleMouseOut);
134
+ iframeDocument.addEventListener("click", handleClick);
135
+ }
136
+ }
137
+ }
138
+
139
+ return cleanupListeners;
140
+ }, [iframeRef, isEditableModeEnabled]);
141
+
142
+ const selectedElement = useMemo(() => {
143
+ if (!isEditableModeEnabled) return null;
144
+ if (!hoveredElement) return null;
145
+ return hoveredElement;
146
+ }, [hoveredElement, isEditableModeEnabled]);
147
+
148
+ const throttledHtml = useThrottleFn((html) => html, 1000, [html]);
149
+
150
+ return (
151
+ <div
152
+ ref={ref}
153
+ className={classNames(
154
+ "w-full border-l border-gray-900 h-full relative z-0 flex items-center justify-center",
155
+ {
156
+ "lg:p-4": currentTab !== "preview",
157
+ "max-lg:h-0": currentTab === "chat",
158
+ "max-lg:h-full": currentTab === "preview",
159
+ }
160
+ )}
161
+ onClick={(e) => {
162
+ if (isAiWorking) {
163
+ e.preventDefault();
164
+ e.stopPropagation();
165
+ toast.warning("Please wait for the AI to finish working.");
166
+ }
167
+ }}
168
+ >
169
+ <GridPattern
170
+ x={-1}
171
+ y={-1}
172
+ strokeDasharray={"4 2"}
173
+ className={cn(
174
+ "[mask-image:radial-gradient(900px_circle_at_center,white,transparent)]"
175
+ )}
176
+ />
177
+ {!isAiWorking && hoveredElement && selectedElement && (
178
+ <div
179
+ className="cursor-pointer absolute bg-sky-500/10 border-[2px] border-dashed border-sky-500 rounded-r-lg rounded-b-lg p-3 z-10 pointer-events-none"
180
+ style={{
181
+ top:
182
+ selectedElement.getBoundingClientRect().top +
183
+ (currentTab === "preview" ? 0 : 24),
184
+ left:
185
+ selectedElement.getBoundingClientRect().left +
186
+ (currentTab === "preview" ? 0 : 24),
187
+ width: selectedElement.getBoundingClientRect().width,
188
+ height: selectedElement.getBoundingClientRect().height,
189
+ }}
190
+ >
191
+ <span className="bg-sky-500 rounded-t-md text-sm text-neutral-100 px-2 py-0.5 -translate-y-7 absolute top-0 left-0">
192
+ {htmlTagToText(selectedElement.tagName.toLowerCase())}
193
+ </span>
194
+ </div>
195
+ )}
196
+ <iframe
197
+ id="preview-iframe"
198
+ ref={iframeRef}
199
+ title="output"
200
+ className={classNames(
201
+ "w-full select-none transition-all duration-200 bg-black h-full",
202
+ {
203
+ "pointer-events-none": isResizing || isAiWorking,
204
+ "lg:max-w-md lg:mx-auto lg:!rounded-[42px] lg:border-[8px] lg:border-neutral-700 lg:shadow-2xl lg:h-[80dvh] lg:max-h-[996px]":
205
+ device === "mobile",
206
+ "lg:border-[8px] lg:border-neutral-700 lg:shadow-2xl lg:rounded-[24px]":
207
+ currentTab !== "preview" && device === "desktop",
208
+ }
209
+ )}
210
+ srcDoc={isAiWorking ? (throttledHtml as string) : html}
211
+ onLoad={() => {
212
+ if (iframeRef?.current?.contentWindow?.document?.body) {
213
+ iframeRef.current.contentWindow.document.body.scrollIntoView({
214
+ block: isAiWorking ? "end" : "start",
215
+ inline: "nearest",
216
+ behavior: isAiWorking ? "instant" : "smooth",
217
+ });
218
+ }
219
+ // add event listener to all links in the iframe to handle navigation
220
+ if (iframeRef?.current?.contentWindow?.document) {
221
+ const links =
222
+ iframeRef.current.contentWindow.document.querySelectorAll("a");
223
+ links.forEach((link) => {
224
+ link.addEventListener("click", handleCustomNavigation);
225
+ });
226
+ }
227
+ }}
228
+ />
229
+ </div>
230
+ );
231
+ };
components/editor/save-button/index.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useState } from "react";
3
+ import { toast } from "sonner";
4
+ import { MdSave } from "react-icons/md";
5
+ import { useParams } from "next/navigation";
6
+
7
+ import Loading from "@/components/loading";
8
+ import { Button } from "@/components/ui/button";
9
+ import { api } from "@/lib/api";
10
+ import { Page } from "@/types";
11
+
12
+ export function SaveButton({
13
+ pages,
14
+ prompts,
15
+ }: {
16
+ pages: Page[];
17
+ prompts: string[];
18
+ }) {
19
+ // get params from URL
20
+ const { namespace, repoId } = useParams<{
21
+ namespace: string;
22
+ repoId: string;
23
+ }>();
24
+ const [loading, setLoading] = useState(false);
25
+
26
+ const updateSpace = async () => {
27
+ setLoading(true);
28
+
29
+ try {
30
+ const res = await api.put(`/me/projects/${namespace}/${repoId}`, {
31
+ pages,
32
+ prompts,
33
+ });
34
+ if (res.data.ok) {
35
+ toast.success("Your space is updated! 🎉", {
36
+ action: {
37
+ label: "See Space",
38
+ onClick: () => {
39
+ window.open(
40
+ `https://huggingface.co/spaces/${namespace}/${repoId}`,
41
+ "_blank"
42
+ );
43
+ },
44
+ },
45
+ });
46
+ } else {
47
+ toast.error(res?.data?.error || "Failed to update space");
48
+ }
49
+ } catch (err: any) {
50
+ toast.error(err.response?.data?.error || err.message);
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ };
55
+ return (
56
+ <>
57
+ <Button
58
+ variant="default"
59
+ className="max-lg:hidden !px-4 relative"
60
+ onClick={updateSpace}
61
+ >
62
+ <MdSave className="size-4" />
63
+ Publish your Project{" "}
64
+ {loading && <Loading className="ml-2 size-4 animate-spin" />}
65
+ </Button>
66
+ <Button
67
+ variant="default"
68
+ size="sm"
69
+ className="lg:hidden relative"
70
+ onClick={updateSpace}
71
+ >
72
+ Publish {loading && <Loading className="ml-2 size-4 animate-spin" />}
73
+ </Button>
74
+ </>
75
+ );
76
+ }
components/iframe-detector.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import IframeWarningModal from "./iframe-warning-modal";
5
+
6
+ export default function IframeDetector() {
7
+ const [showWarning, setShowWarning] = useState(false);
8
+
9
+ useEffect(() => {
10
+ // Helper function to check if a hostname is from allowed domains
11
+ const isAllowedDomain = (hostname: string) => {
12
+ const host = hostname.toLowerCase();
13
+ return (
14
+ host.endsWith(".huggingface.co") ||
15
+ host.endsWith(".hf.co") ||
16
+ host === "huggingface.co" ||
17
+ host === "hf.co"
18
+ );
19
+ };
20
+
21
+ // Check if the current window is in an iframe
22
+ const isInIframe = () => {
23
+ try {
24
+ return window.self !== window.top;
25
+ } catch {
26
+ // If we can't access window.top due to cross-origin restrictions,
27
+ // we're likely in an iframe
28
+ return true;
29
+ }
30
+ };
31
+
32
+ // Additional check: compare window location with parent location
33
+ const isEmbedded = () => {
34
+ try {
35
+ return window.location !== window.parent.location;
36
+ } catch {
37
+ // Cross-origin iframe
38
+ return true;
39
+ }
40
+ };
41
+
42
+ // Check if we're in an iframe from a non-allowed domain
43
+ const shouldShowWarning = () => {
44
+ if (!isInIframe() && !isEmbedded()) {
45
+ return false; // Not in an iframe
46
+ }
47
+
48
+ try {
49
+ // Try to get the parent's hostname
50
+ const parentHostname = window.parent.location.hostname;
51
+ return !isAllowedDomain(parentHostname);
52
+ } catch {
53
+ // Cross-origin iframe - try to get referrer instead
54
+ try {
55
+ if (document.referrer) {
56
+ const referrerUrl = new URL(document.referrer);
57
+ return !isAllowedDomain(referrerUrl.hostname);
58
+ }
59
+ } catch {
60
+ // If we can't determine the parent domain, assume it's not allowed
61
+ }
62
+ return true;
63
+ }
64
+ };
65
+
66
+ if (shouldShowWarning()) {
67
+ // Show warning modal instead of redirecting immediately
68
+ setShowWarning(true);
69
+ }
70
+ }, []);
71
+
72
+ return (
73
+ <IframeWarningModal isOpen={showWarning} onOpenChange={setShowWarning} />
74
+ );
75
+ }
components/iframe-warning-modal.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { Button } from "@/components/ui/button";
12
+ import { ExternalLink, AlertTriangle } from "lucide-react";
13
+
14
+ interface IframeWarningModalProps {
15
+ isOpen: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ }
18
+
19
+ export default function IframeWarningModal({
20
+ isOpen,
21
+ }: // onOpenChange,
22
+ IframeWarningModalProps) {
23
+ const handleVisitSite = () => {
24
+ window.open("https://deepsite.hf.co", "_blank");
25
+ };
26
+
27
+ return (
28
+ <Dialog open={isOpen} onOpenChange={() => {}}>
29
+ <DialogContent className="sm:max-w-md">
30
+ <DialogHeader>
31
+ <div className="flex items-center gap-2">
32
+ <AlertTriangle className="h-5 w-5 text-red-500" />
33
+ <DialogTitle>Unauthorized Embedding</DialogTitle>
34
+ </div>
35
+ <DialogDescription className="text-left">
36
+ You&apos;re viewing DeepSite through an unauthorized iframe. For the
37
+ best experience and security, please visit the official website
38
+ directly.
39
+ </DialogDescription>
40
+ </DialogHeader>
41
+
42
+ <div className="bg-muted/50 rounded-lg p-4 space-y-2">
43
+ <p className="text-sm font-medium">Why visit the official site?</p>
44
+ <ul className="text-sm text-muted-foreground space-y-1">
45
+ <li>• Better performance and security</li>
46
+ <li>• Full functionality access</li>
47
+ <li>• Latest features and updates</li>
48
+ <li>• Proper authentication support</li>
49
+ </ul>
50
+ </div>
51
+
52
+ <DialogFooter className="flex-col sm:flex-row gap-2">
53
+ <Button onClick={handleVisitSite} className="w-full sm:w-auto">
54
+ <ExternalLink className="mr-2 h-4 w-4" />
55
+ Visit Deepsite.hf.co
56
+ </Button>
57
+ </DialogFooter>
58
+ </DialogContent>
59
+ </Dialog>
60
+ );
61
+ }
components/invite-friends/index.tsx ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { TiUserAdd } from "react-icons/ti";
2
+ import { Link } from "lucide-react";
3
+ import { FaXTwitter } from "react-icons/fa6";
4
+ import { useCopyToClipboard } from "react-use";
5
+ import { toast } from "sonner";
6
+
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogTitle,
12
+ DialogTrigger,
13
+ } from "@/components/ui/dialog";
14
+
15
+ export function InviteFriends() {
16
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
17
+ const [_, copyToClipboard] = useCopyToClipboard();
18
+
19
+ return (
20
+ <Dialog>
21
+ <form>
22
+ <DialogTrigger asChild>
23
+ <Button
24
+ size="iconXs"
25
+ variant="outline"
26
+ className="!border-neutral-600 !text-neutral-400 !hover:!border-neutral-500 hover:!text-neutral-300"
27
+ >
28
+ <TiUserAdd className="size-4" />
29
+ </Button>
30
+ </DialogTrigger>
31
+ <DialogContent className="sm:max-w-lg lg:!p-8 !rounded-3xl !bg-white !border-neutral-100">
32
+ <DialogTitle className="hidden" />
33
+ <main>
34
+ <div className="flex items-center justify-start -space-x-4 mb-5">
35
+ <div className="size-11 rounded-full bg-pink-300 shadow-2xs flex items-center justify-center text-2xl">
36
+ 😎
37
+ </div>
38
+ <div className="size-11 rounded-full bg-amber-300 shadow-2xs flex items-center justify-center text-2xl z-2">
39
+ 😇
40
+ </div>
41
+ <div className="size-11 rounded-full bg-sky-300 shadow-2xs flex items-center justify-center text-2xl">
42
+ 😜
43
+ </div>
44
+ </div>
45
+ <p className="text-xl font-semibold text-neutral-950 max-w-[200px]">
46
+ Invite your friends to join us!
47
+ </p>
48
+ <p className="text-sm text-neutral-500 mt-2 max-w-sm">
49
+ Support us and share the love and let them know about our awesome
50
+ platform.
51
+ </p>
52
+ <div className="mt-4 space-x-3.5">
53
+ <a
54
+ href="https://x.com/intent/post?url=https://enzostvs-deepsite.hf.space/&text=Checkout%20this%20awesome%20Ai%20Tool!%20Vibe%20coding%20has%20never%20been%20so%20easy✨"
55
+ target="_blank"
56
+ rel="noopener noreferrer"
57
+ >
58
+ <Button
59
+ variant="lightGray"
60
+ size="sm"
61
+ className="!text-neutral-700"
62
+ >
63
+ <FaXTwitter className="size-4" />
64
+ Share on
65
+ </Button>
66
+ </a>
67
+ <Button
68
+ variant="lightGray"
69
+ size="sm"
70
+ className="!text-neutral-700"
71
+ onClick={() => {
72
+ copyToClipboard("https://enzostvs-deepsite.hf.space/");
73
+ toast.success("Invite link copied to clipboard!");
74
+ }}
75
+ >
76
+ <Link className="size-4" />
77
+ Copy Invite Link
78
+ </Button>
79
+ </div>
80
+ </main>
81
+ </DialogContent>
82
+ </form>
83
+ </Dialog>
84
+ );
85
+ }
components/loading/index.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import classNames from "classnames";
2
+
3
+ function Loading({
4
+ overlay = true,
5
+ className,
6
+ }: {
7
+ overlay?: boolean;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <div
12
+ className={classNames("", {
13
+ "absolute left-0 top-0 h-full w-full flex items-center justify-center z-20 bg-black/50 rounded-full":
14
+ overlay,
15
+ })}
16
+ >
17
+ <svg
18
+ className={`size-5 animate-spin text-white ${className}`}
19
+ xmlns="http://www.w3.org/2000/svg"
20
+ fill="none"
21
+ viewBox="0 0 24 24"
22
+ >
23
+ <circle
24
+ className="opacity-25"
25
+ cx="12"
26
+ cy="12"
27
+ r="10"
28
+ stroke="currentColor"
29
+ strokeWidth="4"
30
+ ></circle>
31
+ <path
32
+ className="opacity-75"
33
+ fill="currentColor"
34
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
35
+ ></path>
36
+ </svg>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export default Loading;
components/login-modal/index.tsx ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useLocalStorage } from "react-use";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
4
+ import { useUser } from "@/hooks/useUser";
5
+ import { isTheSameHtml } from "@/lib/compare-html-diff";
6
+ import { Page } from "@/types";
7
+
8
+ export const LoginModal = ({
9
+ open,
10
+ pages,
11
+ onClose,
12
+ title = "Log In to use DeepSite for free",
13
+ description = "Log In through your Hugging Face account to continue using DeepSite and increase your monthly free limit.",
14
+ }: {
15
+ open: boolean;
16
+ pages?: Page[];
17
+ onClose: React.Dispatch<React.SetStateAction<boolean>>;
18
+ title?: string;
19
+ description?: string;
20
+ }) => {
21
+ const { openLoginWindow } = useUser();
22
+ const [, setStorage] = useLocalStorage("pages");
23
+ const handleClick = async () => {
24
+ if (pages && !isTheSameHtml(pages[0].html)) {
25
+ setStorage(pages);
26
+ }
27
+ openLoginWindow();
28
+ onClose(false);
29
+ };
30
+ return (
31
+ <Dialog open={open} onOpenChange={onClose}>
32
+ <DialogContent className="sm:max-w-lg lg:!p-8 !rounded-3xl !bg-white !border-neutral-100">
33
+ <DialogTitle className="hidden" />
34
+ <main className="flex flex-col items-start text-left relative pt-2">
35
+ <div className="flex items-center justify-start -space-x-4 mb-5">
36
+ <div className="size-14 rounded-full bg-pink-200 shadow-2xs flex items-center justify-center text-3xl opacity-50">
37
+ 💪
38
+ </div>
39
+ <div className="size-16 rounded-full bg-amber-200 shadow-2xl flex items-center justify-center text-4xl z-2">
40
+ 😎
41
+ </div>
42
+ <div className="size-14 rounded-full bg-sky-200 shadow-2xs flex items-center justify-center text-3xl opacity-50">
43
+ 🙌
44
+ </div>
45
+ </div>
46
+ <p className="text-2xl font-bold text-neutral-950">{title}</p>
47
+ <p className="text-neutral-500 text-base mt-2 max-w-sm">
48
+ {description}
49
+ </p>
50
+ <Button
51
+ variant="black"
52
+ size="lg"
53
+ className="w-full !text-base !h-11 mt-8"
54
+ onClick={handleClick}
55
+ >
56
+ Log In to Continue
57
+ </Button>
58
+ </main>
59
+ </DialogContent>
60
+ </Dialog>
61
+ );
62
+ };