enzostvs HF Staff commited on
Commit
88c1c3d
·
1 Parent(s): 92cd82b

add custom images upload

Browse files
app/api/ask-ai/route.ts CHANGED
@@ -197,7 +197,7 @@ export async function PUT(request: NextRequest) {
197
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
198
 
199
  const body = await request.json();
200
- const { prompt, previousPrompt, provider, selectedElementHtml, model, pages } =
201
  body;
202
 
203
  if (!prompt || pages.length === 0) {
@@ -281,7 +281,7 @@ export async function PUT(request: NextRequest) {
281
  selectedElementHtml
282
  ? `\n\nYou have to update ONLY the following element, NOTHING ELSE: \n\n\`\`\`html\n${selectedElementHtml}\n\`\`\``
283
  : ""
284
- }. Current pages: ${pages?.map((p: Page) => `- ${p.path} \n${p.html}`).join("\n")}.`,
285
  },
286
  {
287
  role: "user",
 
197
  const userToken = request.cookies.get(MY_TOKEN_KEY())?.value;
198
 
199
  const body = await request.json();
200
+ const { prompt, previousPrompt, provider, selectedElementHtml, model, pages, files } =
201
  body;
202
 
203
  if (!prompt || pages.length === 0) {
 
281
  selectedElementHtml
282
  ? `\n\nYou have to update ONLY the following element, NOTHING ELSE: \n\n\`\`\`html\n${selectedElementHtml}\n\`\`\``
283
  : ""
284
+ }. 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")}.` : ""}`,
285
  },
286
  {
287
  role: "user",
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 CHANGED
@@ -65,6 +65,9 @@ export async function GET(
65
  };
66
 
67
  const htmlFiles: Page[] = [];
 
 
 
68
 
69
  for await (const fileInfo of listFiles({repo, accessToken: user.token as string})) {
70
  if (fileInfo.path.endsWith(".html")) {
@@ -84,6 +87,13 @@ export async function GET(
84
  }
85
  }
86
  }
 
 
 
 
 
 
 
87
  }
88
 
89
  if (htmlFiles.length === 0) {
@@ -101,6 +111,7 @@ export async function GET(
101
  project: {
102
  ...project,
103
  pages: htmlFiles,
 
104
  },
105
  ok: true,
106
  },
 
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")) {
 
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) {
 
111
  project: {
112
  ...project,
113
  pages: htmlFiles,
114
+ images,
115
  },
116
  ok: true,
117
  },
app/projects/[namespace]/[repoId]/page.tsx CHANGED
@@ -36,5 +36,7 @@ export default async function ProjectNamespacePage({
36
  if (!data?.pages) {
37
  redirect("/projects");
38
  }
39
- return <AppEditor project={data} pages={data.pages} />;
 
 
40
  }
 
36
  if (!data?.pages) {
37
  redirect("/projects");
38
  }
39
+ return (
40
+ <AppEditor project={data} pages={data.pages} images={data.images ?? []} />
41
+ );
42
  }
components/editor/ask-ai/index.tsx CHANGED
@@ -10,8 +10,8 @@ import { FaStopCircle } from "react-icons/fa";
10
  import ProModal from "@/components/pro-modal";
11
  import { Button } from "@/components/ui/button";
12
  import { MODELS } from "@/lib/providers";
13
- import { HtmlHistory, Page } 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";
@@ -23,8 +23,12 @@ 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
 
27
  export function AskAI({
 
 
28
  currentPage,
29
  previousPrompts,
30
  onScrollToBottom,
@@ -35,13 +39,17 @@ export function AskAI({
35
  htmlHistory,
36
  selectedElement,
37
  setSelectedElement,
 
 
38
  setIsEditableModeEnabled,
39
  onNewPrompt,
40
  onSuccess,
41
  setPages,
42
  setCurrentPage,
43
  }: {
 
44
  currentPage: Page;
 
45
  pages: Page[];
46
  onScrollToBottom: () => void;
47
  previousPrompts: string[];
@@ -55,6 +63,8 @@ export function AskAI({
55
  setIsEditableModeEnabled: React.Dispatch<React.SetStateAction<boolean>>;
56
  selectedElement?: HTMLElement | null;
57
  setSelectedElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
 
 
58
  setPages: React.Dispatch<React.SetStateAction<Page[]>>;
59
  setCurrentPage: React.Dispatch<React.SetStateAction<string>>;
60
  }) {
@@ -72,6 +82,8 @@ export function AskAI({
72
  const [isThinking, setIsThinking] = useState(true);
73
  const [think, setThink] = useState("");
74
  const [isFollowUp, setIsFollowUp] = useState(true);
 
 
75
 
76
  const {
77
  callAiNewProject,
@@ -110,7 +122,8 @@ export function AskAI({
110
  model,
111
  provider,
112
  previousPrompt,
113
- selectedElementHtml
 
114
  );
115
 
116
  if (result?.error) {
@@ -254,6 +267,13 @@ export function AskAI({
254
  </main>
255
  </div>
256
  )}
 
 
 
 
 
 
 
257
  {selectedElement && (
258
  <div className="px-4 pt-3">
259
  <SelectedHtmlElement
@@ -264,21 +284,25 @@ export function AskAI({
264
  </div>
265
  )}
266
  <div className="w-full relative flex items-center justify-between">
267
- {isAiWorking && (
268
  <div className="absolute bg-neutral-800 rounded-lg top-0 left-4 w-[calc(100%-30px)] h-full z-1 flex items-start pt-3.5 justify-between max-lg:text-sm">
269
  <div className="flex items-center justify-start gap-2">
270
  <Loading overlay={false} className="!size-4" />
271
  <p className="text-neutral-400 text-sm">
272
- AI is {isThinking ? "thinking" : "coding"}...{" "}
 
 
273
  </p>
274
  </div>
275
- <div
276
- 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"
277
- onClick={stopController}
278
- >
279
- <FaStopCircle />
280
- Stop generation
281
- </div>
 
 
282
  </div>
283
  )}
284
  <textarea
@@ -307,6 +331,22 @@ export function AskAI({
307
  </div>
308
  <div className="flex items-center justify-between gap-2 px-4 pb-3 mt-2">
309
  <div className="flex-1 flex items-center justify-start gap-1.5">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  <ReImagine onRedesign={(md) => callAi(md)} />
311
  {!isSameHtml && (
312
  <Tooltip>
@@ -335,7 +375,7 @@ export function AskAI({
335
  </TooltipContent>
336
  </Tooltip>
337
  )}
338
- <InviteFriends />
339
  </div>
340
  <div className="flex items-center justify-end gap-2">
341
  <Settings
@@ -364,7 +404,7 @@ export function AskAI({
364
  onClose={() => setOpenProModal(false)}
365
  />
366
  {pages.length === 1 && (
367
- <div className="border border-sky-500/20 bg-sky-500/20 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">
368
  <span className="rounded-full text-[10px] font-semibold bg-white text-neutral-900 px-1.5 py-0.5">
369
  NEW
370
  </span>
 
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";
 
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
+ project,
31
+ images,
32
  currentPage,
33
  previousPrompts,
34
  onScrollToBottom,
 
39
  htmlHistory,
40
  selectedElement,
41
  setSelectedElement,
42
+ selectedFiles,
43
+ setSelectedFiles,
44
  setIsEditableModeEnabled,
45
  onNewPrompt,
46
  onSuccess,
47
  setPages,
48
  setCurrentPage,
49
  }: {
50
+ project?: Project | null;
51
  currentPage: Page;
52
+ images?: string[];
53
  pages: Page[];
54
  onScrollToBottom: () => void;
55
  previousPrompts: string[];
 
63
  setIsEditableModeEnabled: React.Dispatch<React.SetStateAction<boolean>>;
64
  selectedElement?: HTMLElement | null;
65
  setSelectedElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
66
+ selectedFiles: string[];
67
+ setSelectedFiles: React.Dispatch<React.SetStateAction<string[]>>;
68
  setPages: React.Dispatch<React.SetStateAction<Page[]>>;
69
  setCurrentPage: React.Dispatch<React.SetStateAction<string>>;
70
  }) {
 
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,
 
122
  model,
123
  provider,
124
  previousPrompt,
125
+ selectedElementHtml,
126
+ selectedFiles
127
  );
128
 
129
  if (result?.error) {
 
267
  </main>
268
  </div>
269
  )}
270
+ <SelectedFiles
271
+ files={selectedFiles}
272
+ isAiWorking={isAiWorking}
273
+ onDelete={(file) =>
274
+ setSelectedFiles((prev) => prev.filter((f) => f !== file))
275
+ }
276
+ />
277
  {selectedElement && (
278
  <div className="px-4 pt-3">
279
  <SelectedHtmlElement
 
284
  </div>
285
  )}
286
  <div className="w-full relative flex items-center justify-between">
287
+ {(isAiWorking || isUploading) && (
288
  <div className="absolute bg-neutral-800 rounded-lg top-0 left-4 w-[calc(100%-30px)] h-full z-1 flex items-start pt-3.5 justify-between max-lg:text-sm">
289
  <div className="flex items-center justify-start gap-2">
290
  <Loading overlay={false} className="!size-4" />
291
  <p className="text-neutral-400 text-sm">
292
+ {isUploading
293
+ ? "Uploading images..."
294
+ : `AI is ${isThinking ? "thinking" : "coding"}...`}
295
  </p>
296
  </div>
297
+ {isAiWorking && (
298
+ <div
299
+ 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"
300
+ onClick={stopController}
301
+ >
302
+ <FaStopCircle />
303
+ Stop generation
304
+ </div>
305
+ )}
306
  </div>
307
  )}
308
  <textarea
 
331
  </div>
332
  <div className="flex items-center justify-between gap-2 px-4 pb-3 mt-2">
333
  <div className="flex-1 flex items-center justify-start gap-1.5">
334
+ <Uploader
335
+ pages={pages}
336
+ onLoading={setIsUploading}
337
+ isLoading={isUploading}
338
+ onFiles={setFiles}
339
+ onSelectFile={(file) => {
340
+ if (selectedFiles.includes(file)) {
341
+ setSelectedFiles((prev) => prev.filter((f) => f !== file));
342
+ } else {
343
+ setSelectedFiles((prev) => [...prev, file]);
344
+ }
345
+ }}
346
+ files={files}
347
+ selectedFiles={selectedFiles}
348
+ project={project}
349
+ />
350
  <ReImagine onRedesign={(md) => callAi(md)} />
351
  {!isSameHtml && (
352
  <Tooltip>
 
375
  </TooltipContent>
376
  </Tooltip>
377
  )}
378
+ {/* <InviteFriends /> */}
379
  </div>
380
  <div className="flex items-center justify-end gap-2">
381
  <Settings
 
404
  onClose={() => setOpenProModal(false)}
405
  />
406
  {pages.length === 1 && (
407
+ <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">
408
  <span className="rounded-full text-[10px] font-semibold bg-white text-neutral-900 px-1.5 py-0.5">
409
  NEW
410
  </span>
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/uploader.tsx ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
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
+ >
191
+ <Images className="size-4" />
192
+ </Button>
193
+ <LoginModal
194
+ open={open}
195
+ onClose={() => setOpen(false)}
196
+ pages={pages}
197
+ title="Log In to add Custom Images"
198
+ description="Log In through your Hugging Face account to publish your project and increase your monthly free limit."
199
+ />
200
+ </>
201
+ );
202
+ };
components/editor/deploy-button/content.tsx ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Rocket } from "lucide-react";
2
+ import Image from "next/image";
3
+
4
+ import Loading from "@/components/loading";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import SpaceIcon from "@/assets/space.svg";
8
+ import { Page } from "@/types";
9
+ import { api } from "@/lib/api";
10
+ import { toast } from "sonner";
11
+ import { useState } from "react";
12
+ import { useRouter } from "next/navigation";
13
+
14
+ export const DeployButtonContent = ({
15
+ pages,
16
+ options,
17
+ prompts,
18
+ }: {
19
+ pages: Page[];
20
+ options?: {
21
+ title?: string;
22
+ description?: string;
23
+ };
24
+ prompts: string[];
25
+ }) => {
26
+ const router = useRouter();
27
+ const [loading, setLoading] = useState(false);
28
+
29
+ const [config, setConfig] = useState({
30
+ title: "",
31
+ });
32
+
33
+ const createSpace = async () => {
34
+ if (!config.title) {
35
+ toast.error("Please enter a title for your space.");
36
+ return;
37
+ }
38
+ setLoading(true);
39
+
40
+ try {
41
+ const res = await api.post("/me/projects", {
42
+ title: config.title,
43
+ pages,
44
+ prompts,
45
+ });
46
+ if (res.data.ok) {
47
+ router.push(`/projects/${res.data.path}?deploy=true`);
48
+ } else {
49
+ toast.error(res?.data?.error || "Failed to create space");
50
+ }
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ } catch (err: any) {
53
+ toast.error(err.response?.data?.error || err.message);
54
+ } finally {
55
+ setLoading(false);
56
+ }
57
+ };
58
+
59
+ return (
60
+ <>
61
+ <header className="bg-neutral-50 p-6 border-b border-neutral-200/60">
62
+ <div className="flex items-center justify-center -space-x-4 mb-3">
63
+ <div className="size-9 rounded-full bg-amber-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
64
+ 🚀
65
+ </div>
66
+ <div className="size-11 rounded-full bg-red-200 shadow-2xl flex items-center justify-center z-2">
67
+ <Image src={SpaceIcon} alt="Space Icon" className="size-7" />
68
+ </div>
69
+ <div className="size-9 rounded-full bg-sky-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
70
+ 👻
71
+ </div>
72
+ </div>
73
+ <p className="text-xl font-semibold text-neutral-950">
74
+ Publish as Space!
75
+ </p>
76
+ <p className="text-sm text-neutral-500 mt-1.5">
77
+ {options?.description ??
78
+ "Save and Publish your project to a Space on the Hub. Spaces are a way to share your project with the world."}
79
+ </p>
80
+ </header>
81
+ <main className="space-y-4 p-6">
82
+ <div>
83
+ <p className="text-sm text-neutral-700 mb-2">
84
+ Choose a title for your space:
85
+ </p>
86
+ <Input
87
+ type="text"
88
+ placeholder="My Awesome Website"
89
+ value={config.title}
90
+ onChange={(e) => setConfig({ ...config, title: e.target.value })}
91
+ className="!bg-white !border-neutral-300 !text-neutral-800 !placeholder:text-neutral-400 selection:!bg-blue-100"
92
+ />
93
+ </div>
94
+ <div>
95
+ <p className="text-sm text-neutral-700 mb-2">
96
+ Then, let&apos;s publish it!
97
+ </p>
98
+ <Button
99
+ variant="black"
100
+ onClick={createSpace}
101
+ className="relative w-full"
102
+ disabled={loading}
103
+ >
104
+ Publish Space <Rocket className="size-4" />
105
+ {loading && <Loading className="ml-2 size-4 animate-spin" />}
106
+ </Button>
107
+ </div>
108
+ </main>
109
+ </>
110
+ );
111
+ };
components/editor/deploy-button/index.tsx CHANGED
@@ -1,24 +1,17 @@
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
  import { useState } from "react";
3
- import { toast } from "sonner";
4
- import Image from "next/image";
5
- import { useRouter } from "next/navigation";
6
  import { MdSave } from "react-icons/md";
7
- import { Rocket } from "lucide-react";
8
 
9
- import SpaceIcon from "@/assets/space.svg";
10
- import Loading from "@/components/loading";
11
  import { Button } from "@/components/ui/button";
12
  import {
13
  Popover,
14
  PopoverContent,
15
  PopoverTrigger,
16
  } from "@/components/ui/popover";
17
- import { Input } from "@/components/ui/input";
18
- import { api } from "@/lib/api";
19
  import { LoginModal } from "@/components/login-modal";
20
  import { useUser } from "@/hooks/useUser";
21
  import { Page } from "@/types";
 
22
 
23
  export function DeployButton({
24
  pages,
@@ -27,40 +20,9 @@ export function DeployButton({
27
  pages: Page[];
28
  prompts: string[];
29
  }) {
30
- const router = useRouter();
31
  const { user } = useUser();
32
- const [loading, setLoading] = useState(false);
33
  const [open, setOpen] = useState(false);
34
 
35
- const [config, setConfig] = useState({
36
- title: "",
37
- });
38
-
39
- const createSpace = async () => {
40
- if (!config.title) {
41
- toast.error("Please enter a title for your space.");
42
- return;
43
- }
44
- setLoading(true);
45
-
46
- try {
47
- const res = await api.post("/me/projects", {
48
- title: config.title,
49
- pages,
50
- prompts,
51
- });
52
- if (res.data.ok) {
53
- router.push(`/projects/${res.data.path}?deploy=true`);
54
- } else {
55
- toast.error(res?.data?.error || "Failed to create space");
56
- }
57
- } catch (err: any) {
58
- toast.error(err.response?.data?.error || err.message);
59
- } finally {
60
- setLoading(false);
61
- }
62
- };
63
-
64
  return (
65
  <div className="flex items-center justify-end gap-5">
66
  <div className="relative flex items-center justify-end">
@@ -81,62 +43,7 @@ export function DeployButton({
81
  className="!rounded-2xl !p-0 !bg-white !border-neutral-200 min-w-xs text-center overflow-hidden"
82
  align="end"
83
  >
84
- <header className="bg-neutral-50 p-6 border-b border-neutral-200/60">
85
- <div className="flex items-center justify-center -space-x-4 mb-3">
86
- <div className="size-9 rounded-full bg-amber-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
87
- 🚀
88
- </div>
89
- <div className="size-11 rounded-full bg-red-200 shadow-2xl flex items-center justify-center z-2">
90
- <Image
91
- src={SpaceIcon}
92
- alt="Space Icon"
93
- className="size-7"
94
- />
95
- </div>
96
- <div className="size-9 rounded-full bg-sky-200 shadow-2xs flex items-center justify-center text-xl opacity-50">
97
- 👻
98
- </div>
99
- </div>
100
- <p className="text-xl font-semibold text-neutral-950">
101
- Publish as Space!
102
- </p>
103
- <p className="text-sm text-neutral-500 mt-1.5">
104
- Save and Publish your project to a Space on the Hub. Spaces
105
- are a way to share your project with the world.
106
- </p>
107
- </header>
108
- <main className="space-y-4 p-6">
109
- <div>
110
- <p className="text-sm text-neutral-700 mb-2">
111
- Choose a title for your space:
112
- </p>
113
- <Input
114
- type="text"
115
- placeholder="My Awesome Website"
116
- value={config.title}
117
- onChange={(e) =>
118
- setConfig({ ...config, title: e.target.value })
119
- }
120
- className="!bg-white !border-neutral-300 !text-neutral-800 !placeholder:text-neutral-400 selection:!bg-blue-100"
121
- />
122
- </div>
123
- <div>
124
- <p className="text-sm text-neutral-700 mb-2">
125
- Then, let&apos;s publish it!
126
- </p>
127
- <Button
128
- variant="black"
129
- onClick={createSpace}
130
- className="relative w-full"
131
- disabled={loading}
132
- >
133
- Publish Space <Rocket className="size-4" />
134
- {loading && (
135
- <Loading className="ml-2 size-4 animate-spin" />
136
- )}
137
- </Button>
138
- </div>
139
- </main>
140
  </PopoverContent>
141
  </Popover>
142
  ) : (
 
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,
 
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">
 
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
  ) : (
components/editor/footer/index.tsx CHANGED
@@ -8,6 +8,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
 
12
  const DEVICES = [
13
  {
@@ -21,14 +22,12 @@ const DEVICES = [
21
  ];
22
 
23
  export function Footer({
24
- onReset,
25
  htmlHistory,
26
  setPages,
27
  device,
28
  setDevice,
29
  iframeRef,
30
  }: {
31
- onReset: () => void;
32
  htmlHistory?: HtmlHistory[];
33
  device: "desktop" | "mobile";
34
  setPages: (pages: Page[]) => void;
@@ -62,10 +61,12 @@ export function Footer({
62
  <UserMenu className="!p-1 !pr-3 !h-auto" />
63
  ))}
64
  {user && <p className="text-neutral-700">|</p>}
65
- <Button size="sm" variant="secondary" onClick={onReset}>
66
- <MdAdd className="text-sm" />
67
- New <span className="max-lg:hidden">Project</span>
68
- </Button>
 
 
69
  {htmlHistory && htmlHistory.length > 0 && (
70
  <>
71
  <p className="text-neutral-700">|</p>
 
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
 
13
  const DEVICES = [
14
  {
 
22
  ];
23
 
24
  export function Footer({
 
25
  htmlHistory,
26
  setPages,
27
  device,
28
  setDevice,
29
  iframeRef,
30
  }: {
 
31
  htmlHistory?: HtmlHistory[];
32
  device: "desktop" | "mobile";
33
  setPages: (pages: Page[]) => void;
 
61
  <UserMenu className="!p-1 !pr-3 !h-auto" />
62
  ))}
63
  {user && <p className="text-neutral-700">|</p>}
64
+ <Link href="/projects/new">
65
+ <Button size="sm" variant="secondary">
66
+ <MdAdd className="text-sm" />
67
+ New <span className="max-lg:hidden">Project</span>
68
+ </Button>
69
+ </Link>
70
  {htmlHistory && htmlHistory.length > 0 && (
71
  <>
72
  <p className="text-neutral-700">|</p>
components/editor/index.tsx CHANGED
@@ -31,10 +31,12 @@ import { ListPages } from "./pages";
31
  export const AppEditor = ({
32
  project,
33
  pages: initialPages,
 
34
  isNew,
35
  }: {
36
  project?: Project | null;
37
  pages?: Page[];
 
38
  isNew?: boolean;
39
  }) => {
40
  const [htmlStorage, , removeHtmlStorage] = useLocalStorage("pages");
@@ -63,6 +65,7 @@ export const AppEditor = ({
63
  const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
64
  null
65
  );
 
66
 
67
  const resetLayout = () => {
68
  if (!editor.current || !preview.current) return;
@@ -285,6 +288,8 @@ export const AppEditor = ({
285
  onValidate={handleEditorValidation}
286
  />
287
  <AskAI
 
 
288
  currentPage={currentPageData}
289
  htmlHistory={htmlHistory}
290
  previousPrompts={project?.prompts ?? []}
@@ -297,6 +302,7 @@ export const AppEditor = ({
297
  });
298
  setHtmlHistory(currentHistory);
299
  setSelectedElement(null);
 
300
  // if xs or sm
301
  if (window.innerWidth <= 1024) {
302
  setCurrentTab("preview");
@@ -340,6 +346,8 @@ export const AppEditor = ({
340
  setIsEditableModeEnabled={setIsEditableModeEnabled}
341
  selectedElement={selectedElement}
342
  setSelectedElement={setSelectedElement}
 
 
343
  />
344
  </div>
345
  <div
@@ -367,21 +375,6 @@ export const AppEditor = ({
367
  />
368
  </main>
369
  <Footer
370
- onReset={() => {
371
- if (isAiWorking) {
372
- toast.warning("Please wait for the AI to finish working.");
373
- return;
374
- }
375
- if (
376
- window.confirm("You're about to reset the editor. Are you sure?")
377
- ) {
378
- // setHtml(defaultHTML);
379
- removeHtmlStorage();
380
- editorRef.current?.revealLine(
381
- editorRef.current?.getModel()?.getLineCount() ?? 0
382
- );
383
- }
384
- }}
385
  htmlHistory={htmlHistory}
386
  setPages={setPages}
387
  iframeRef={iframeRef}
 
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");
 
65
  const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
66
  null
67
  );
68
+ const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
69
 
70
  const resetLayout = () => {
71
  if (!editor.current || !preview.current) return;
 
288
  onValidate={handleEditorValidation}
289
  />
290
  <AskAI
291
+ project={project}
292
+ images={images}
293
  currentPage={currentPageData}
294
  htmlHistory={htmlHistory}
295
  previousPrompts={project?.prompts ?? []}
 
302
  });
303
  setHtmlHistory(currentHistory);
304
  setSelectedElement(null);
305
+ setSelectedFiles([]);
306
  // if xs or sm
307
  if (window.innerWidth <= 1024) {
308
  setCurrentTab("preview");
 
346
  setIsEditableModeEnabled={setIsEditableModeEnabled}
347
  selectedElement={selectedElement}
348
  setSelectedElement={setSelectedElement}
349
+ setSelectedFiles={setSelectedFiles}
350
+ selectedFiles={selectedFiles}
351
  />
352
  </div>
353
  <div
 
375
  />
376
  </main>
377
  <Footer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  htmlHistory={htmlHistory}
379
  setPages={setPages}
380
  iframeRef={iframeRef}
hooks/useCallAi.ts CHANGED
@@ -237,7 +237,7 @@ export const useCallAi = ({
237
  }
238
  };
239
 
240
- const callAiFollowUp = async (prompt: string, model: string | undefined, provider: string | undefined, previousPrompt: string, selectedElementHtml?: string) => {
241
  if (isAiWorking) return;
242
  if (!prompt.trim()) return;
243
 
@@ -258,6 +258,7 @@ export const useCallAi = ({
258
  model,
259
  pages,
260
  selectedElementHtml,
 
261
  }),
262
  headers: {
263
  "Content-Type": "application/json",
 
237
  }
238
  };
239
 
240
+ const callAiFollowUp = async (prompt: string, model: string | undefined, provider: string | undefined, previousPrompt: string, selectedElementHtml?: string, files?: string[]) => {
241
  if (isAiWorking) return;
242
  if (!prompt.trim()) return;
243
 
 
258
  model,
259
  pages,
260
  selectedElementHtml,
261
+ files,
262
  }),
263
  headers: {
264
  "Content-Type": "application/json",
next.config.ts CHANGED
@@ -24,6 +24,9 @@ const nextConfig: NextConfig = {
24
 
25
  return config;
26
  },
 
 
 
27
  };
28
 
29
  export default nextConfig;
 
24
 
25
  return config;
26
  },
27
+ images: {
28
+ remotePatterns: [new URL('https://huggingface.co/**')],
29
+ },
30
  };
31
 
32
  export default nextConfig;