Spaces:
Sleeping
Sleeping
Commit
·
cececac
0
Parent(s):
Initial DeepSite v2 upload
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +41 -0
- Dockerfile +19 -0
- README.md +0 -0
- app/(public)/layout.tsx +15 -0
- app/(public)/page.tsx +44 -0
- app/(public)/projects/page.tsx +13 -0
- app/actions/auth.ts +18 -0
- app/actions/projects.ts +63 -0
- app/actions/rewrite-prompt.ts +35 -0
- app/api/ask-ai/route.ts +510 -0
- app/api/auth/route.ts +86 -0
- app/api/me/projects/[namespace]/[repoId]/images/route.ts +111 -0
- app/api/me/projects/[namespace]/[repoId]/route.ts +276 -0
- app/api/me/projects/route.ts +127 -0
- app/api/me/route.ts +25 -0
- app/api/re-design/route.ts +39 -0
- app/auth/callback/page.tsx +72 -0
- app/auth/page.tsx +28 -0
- app/favicon.ico +0 -0
- app/layout.tsx +112 -0
- app/projects/[namespace]/[repoId]/page.tsx +42 -0
- app/projects/new/page.tsx +5 -0
- assets/globals.css +146 -0
- assets/logo.svg +316 -0
- assets/space.svg +7 -0
- components.json +21 -0
- components/contexts/app-context.tsx +57 -0
- components/contexts/user-context.tsx +8 -0
- components/editor/ask-ai/follow-up-tooltip.tsx +36 -0
- components/editor/ask-ai/index.tsx +500 -0
- components/editor/ask-ai/re-imagine.tsx +146 -0
- components/editor/ask-ai/selected-files.tsx +47 -0
- components/editor/ask-ai/selected-html-element.tsx +57 -0
- components/editor/ask-ai/settings.tsx +202 -0
- components/editor/ask-ai/uploader.tsx +203 -0
- components/editor/deploy-button/content.tsx +111 -0
- components/editor/deploy-button/index.tsx +79 -0
- components/editor/footer/index.tsx +150 -0
- components/editor/header/index.tsx +69 -0
- components/editor/history/index.tsx +73 -0
- components/editor/index.tsx +392 -0
- components/editor/pages/index.tsx +30 -0
- components/editor/pages/page.tsx +82 -0
- components/editor/preview/index.tsx +231 -0
- components/editor/save-button/index.tsx +76 -0
- components/iframe-detector.tsx +75 -0
- components/iframe-warning-modal.tsx +61 -0
- components/invite-friends/index.tsx +85 -0
- components/loading/index.tsx +41 -0
- 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'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'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'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'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'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 |
+
};
|