enzostvs HF Staff commited on
Commit
7722a19
·
1 Parent(s): 1940a61

some fixes

Browse files
components/editor/index.tsx CHANGED
@@ -16,6 +16,7 @@ import { AskAi } from "./ask-ai";
16
  import { Preview } from "./preview";
17
  import { SaveChangesPopup } from "./save-changes-popup";
18
  import Loading from "../loading";
 
19
 
20
  export const AppEditor = ({
21
  namespace,
@@ -37,7 +38,8 @@ export const AppEditor = ({
37
  saveChanges,
38
  pages,
39
  } = useEditor(namespace, repoId);
40
- const { isAiWorking } = useAi();
 
41
  const [, copyToClipboard] = useCopyToClipboard();
42
  const [showSavePopup, setShowSavePopup] = useState(false);
43
 
@@ -125,7 +127,7 @@ export const AppEditor = ({
125
  }}
126
  />
127
  </div>
128
- <Preview isNew={isNew} />
129
  </main>
130
 
131
  {/* Save Changes Popup */}
 
16
  import { Preview } from "./preview";
17
  import { SaveChangesPopup } from "./save-changes-popup";
18
  import Loading from "../loading";
19
+ import { LivePreviewRef } from "./live-preview";
20
 
21
  export const AppEditor = ({
22
  namespace,
 
38
  saveChanges,
39
  pages,
40
  } = useEditor(namespace, repoId);
41
+ const livePreviewRef = useRef<LivePreviewRef>(null);
42
+ const { isAiWorking } = useAi(undefined, livePreviewRef);
43
  const [, copyToClipboard] = useCopyToClipboard();
44
  const [showSavePopup, setShowSavePopup] = useState(false);
45
 
 
127
  }}
128
  />
129
  </div>
130
+ <Preview ref={livePreviewRef} isNew={isNew} />
131
  </main>
132
 
133
  {/* Save Changes Popup */}
components/editor/live-preview/index.tsx CHANGED
@@ -1,6 +1,12 @@
1
  "use client";
2
 
3
- import { useState, useEffect, useRef } from "react";
 
 
 
 
 
 
4
  import classNames from "classnames";
5
 
6
  import { Button } from "@/components/ui/button";
@@ -13,129 +19,147 @@ interface LivePreviewProps {
13
  className?: string;
14
  }
15
 
16
- export const LivePreview = ({
17
- currentPageData,
18
- isAiWorking,
19
- defaultHTML,
20
- className,
21
- }: LivePreviewProps) => {
22
- const [isMaximized, setIsMaximized] = useState(false);
23
- const [displayedHtml, setDisplayedHtml] = useState<string>("");
24
- const latestHtmlRef = useRef<string>("");
25
- const displayedHtmlRef = useRef<string>("");
26
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
27
-
28
- useEffect(() => {
29
- displayedHtmlRef.current = displayedHtml;
30
- }, [displayedHtml]);
31
-
32
- useEffect(() => {
33
- if (currentPageData?.html && currentPageData.html !== defaultHTML) {
34
- latestHtmlRef.current = currentPageData.html;
35
- }
36
- }, [currentPageData?.html, defaultHTML]);
37
 
38
- useEffect(() => {
39
- if (!currentPageData?.html || currentPageData.html === defaultHTML) {
40
- return;
41
- }
 
 
 
42
 
43
- if (!displayedHtml || !isAiWorking) {
44
- setDisplayedHtml(currentPageData.html);
 
 
 
45
  if (intervalRef.current) {
46
  clearInterval(intervalRef.current);
47
  intervalRef.current = null;
48
  }
49
- return;
50
- }
51
 
52
- if (isAiWorking && !intervalRef.current) {
53
- intervalRef.current = setInterval(() => {
54
- if (
55
- latestHtmlRef.current &&
56
- latestHtmlRef.current !== displayedHtmlRef.current
57
- ) {
58
- setDisplayedHtml(latestHtmlRef.current);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  }
60
- }, 3000);
61
- }
62
- }, [currentPageData?.html, defaultHTML, isAiWorking, displayedHtml]);
63
-
64
- useEffect(() => {
65
- if (!isAiWorking && intervalRef.current) {
66
- clearInterval(intervalRef.current);
67
- intervalRef.current = null;
68
- if (latestHtmlRef.current) {
69
- setDisplayedHtml(latestHtmlRef.current);
70
  }
71
- }
72
- }, [isAiWorking]);
73
 
74
- useEffect(() => {
75
- return () => {
76
- if (intervalRef.current) {
 
 
 
 
 
 
 
 
 
 
 
77
  clearInterval(intervalRef.current);
78
  intervalRef.current = null;
 
 
 
79
  }
80
- };
81
- }, []);
82
 
83
- if (!displayedHtml) {
84
- return null;
85
- }
 
 
 
 
 
86
 
87
- return (
88
- <div
89
- className={classNames(
90
- "absolute z-40 bg-white/95 backdrop-blur-sm border border-neutral-200 shadow-lg transition-all duration-500 ease-out transform scale-100 opacity-100 animate-in slide-in-from-bottom-4 zoom-in-95 rounded-xl",
91
- {
92
- "shadow-green-500/20 shadow-2xl border-green-200": isAiWorking,
93
- },
94
- className
95
- )}
96
- >
97
  <div
98
  className={classNames(
99
- "flex flex-col animate-in fade-in duration-300",
100
- isMaximized ? "w-[60dvw] h-[80dvh]" : "w-80 h-96"
 
 
 
101
  )}
102
  >
103
- <div className="flex items-center justify-between p-3 border-b border-neutral-200">
104
- <div className="flex items-center gap-2">
105
- <div className="size-2 bg-green-500 rounded-full animate-pulse shadow-sm shadow-green-500/50"></div>
106
- <span className="text-xs font-medium text-neutral-800">
107
- Live Preview
108
- </span>
109
- {isAiWorking && (
110
- <span className="text-xs text-green-600 font-medium animate-pulse">
111
- Updating
 
 
112
  </span>
113
- )}
114
- </div>
115
- <div className="flex items-center gap-1">
116
- <Button
117
- variant="outline"
118
- size="iconXs"
119
- className="!rounded-md !border-neutral-200 hover:bg-neutral-50"
120
- onClick={() => setIsMaximized(!isMaximized)}
121
- >
122
- {isMaximized ? (
123
- <Minimize className="text-neutral-400 size-3" />
124
- ) : (
125
- <Maximize className="text-neutral-400 size-3" />
126
  )}
127
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  </div>
129
- </div>
130
- <div className="flex-1 bg-black overflow-hidden relative rounded-b-xl">
131
- <iframe
132
- className="w-full h-full border-0"
133
- srcDoc={displayedHtml}
134
- sandbox="allow-scripts allow-same-origin"
135
- title="Live Preview"
136
- />
137
  </div>
138
  </div>
139
- </div>
140
- );
141
- };
 
 
 
1
  "use client";
2
 
3
+ import {
4
+ useState,
5
+ useEffect,
6
+ useRef,
7
+ forwardRef,
8
+ useImperativeHandle,
9
+ } from "react";
10
  import classNames from "classnames";
11
 
12
  import { Button } from "@/components/ui/button";
 
19
  className?: string;
20
  }
21
 
22
+ export interface LivePreviewRef {
23
+ reset: () => void;
24
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ export const LivePreview = forwardRef<LivePreviewRef, LivePreviewProps>(
27
+ ({ currentPageData, isAiWorking, defaultHTML, className }, ref) => {
28
+ const [isMaximized, setIsMaximized] = useState(false);
29
+ const [displayedHtml, setDisplayedHtml] = useState<string>("");
30
+ const latestHtmlRef = useRef<string>("");
31
+ const displayedHtmlRef = useRef<string>("");
32
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
33
 
34
+ const reset = () => {
35
+ setIsMaximized(false);
36
+ setDisplayedHtml("");
37
+ latestHtmlRef.current = "";
38
+ displayedHtmlRef.current = "";
39
  if (intervalRef.current) {
40
  clearInterval(intervalRef.current);
41
  intervalRef.current = null;
42
  }
43
+ };
 
44
 
45
+ useImperativeHandle(ref, () => ({
46
+ reset,
47
+ }));
48
+
49
+ useEffect(() => {
50
+ displayedHtmlRef.current = displayedHtml;
51
+ }, [displayedHtml]);
52
+
53
+ useEffect(() => {
54
+ if (currentPageData?.html && currentPageData.html !== defaultHTML) {
55
+ latestHtmlRef.current = currentPageData.html;
56
+ }
57
+ }, [currentPageData?.html, defaultHTML]);
58
+
59
+ useEffect(() => {
60
+ if (!currentPageData?.html || currentPageData.html === defaultHTML) {
61
+ return;
62
+ }
63
+
64
+ if (!displayedHtml || !isAiWorking) {
65
+ setDisplayedHtml(currentPageData.html);
66
+ if (intervalRef.current) {
67
+ clearInterval(intervalRef.current);
68
+ intervalRef.current = null;
69
  }
70
+ return;
 
 
 
 
 
 
 
 
 
71
  }
 
 
72
 
73
+ if (isAiWorking && !intervalRef.current) {
74
+ intervalRef.current = setInterval(() => {
75
+ if (
76
+ latestHtmlRef.current &&
77
+ latestHtmlRef.current !== displayedHtmlRef.current
78
+ ) {
79
+ setDisplayedHtml(latestHtmlRef.current);
80
+ }
81
+ }, 3000);
82
+ }
83
+ }, [currentPageData?.html, defaultHTML, isAiWorking, displayedHtml]);
84
+
85
+ useEffect(() => {
86
+ if (!isAiWorking && intervalRef.current) {
87
  clearInterval(intervalRef.current);
88
  intervalRef.current = null;
89
+ if (latestHtmlRef.current) {
90
+ setDisplayedHtml(latestHtmlRef.current);
91
+ }
92
  }
93
+ }, [isAiWorking]);
 
94
 
95
+ useEffect(() => {
96
+ return () => {
97
+ if (intervalRef.current) {
98
+ clearInterval(intervalRef.current);
99
+ intervalRef.current = null;
100
+ }
101
+ };
102
+ }, []);
103
 
104
+ if (!displayedHtml) {
105
+ return null;
106
+ }
107
+
108
+ return (
 
 
 
 
 
109
  <div
110
  className={classNames(
111
+ "absolute z-40 bg-white/95 backdrop-blur-sm border border-neutral-200 shadow-lg transition-all duration-500 ease-out transform scale-100 opacity-100 animate-in slide-in-from-bottom-4 zoom-in-95 rounded-xl",
112
+ {
113
+ "shadow-green-500/20 shadow-2xl border-green-200": isAiWorking,
114
+ },
115
+ className
116
  )}
117
  >
118
+ <div
119
+ className={classNames(
120
+ "flex flex-col animate-in fade-in duration-300",
121
+ isMaximized ? "w-[60dvw] h-[80dvh]" : "w-80 h-96"
122
+ )}
123
+ >
124
+ <div className="flex items-center justify-between p-3 border-b border-neutral-200">
125
+ <div className="flex items-center gap-2">
126
+ <div className="size-2 bg-green-500 rounded-full animate-pulse shadow-sm shadow-green-500/50"></div>
127
+ <span className="text-xs font-medium text-neutral-800">
128
+ Live Preview
129
  </span>
130
+ {isAiWorking && (
131
+ <span className="text-xs text-green-600 font-medium animate-pulse">
132
+ Updating
133
+ </span>
 
 
 
 
 
 
 
 
 
134
  )}
135
+ </div>
136
+ <div className="flex items-center gap-1">
137
+ <Button
138
+ variant="outline"
139
+ size="iconXs"
140
+ className="!rounded-md !border-neutral-200 hover:bg-neutral-50"
141
+ onClick={() => setIsMaximized(!isMaximized)}
142
+ >
143
+ {isMaximized ? (
144
+ <Minimize className="text-neutral-400 size-3" />
145
+ ) : (
146
+ <Maximize className="text-neutral-400 size-3" />
147
+ )}
148
+ </Button>
149
+ </div>
150
+ </div>
151
+ <div className="flex-1 bg-black overflow-hidden relative rounded-b-xl">
152
+ <iframe
153
+ className="w-full h-full border-0"
154
+ srcDoc={displayedHtml}
155
+ sandbox="allow-scripts allow-same-origin"
156
+ title="Live Preview"
157
+ />
158
  </div>
 
 
 
 
 
 
 
 
159
  </div>
160
  </div>
161
+ );
162
+ }
163
+ );
164
+
165
+ LivePreview.displayName = "LivePreview";
components/editor/preview/index.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { useRef, useState, useEffect } from "react";
4
  import { useUpdateEffect } from "react-use";
5
  import classNames from "classnames";
6
 
@@ -13,266 +13,234 @@ import { AnimatedBlobs } from "@/components/animated-blobs";
13
  import { AiLoading } from "../ask-ai/loading";
14
  import { defaultHTML } from "@/lib/consts";
15
  import { Button } from "@/components/ui/button";
16
- import { LivePreview } from "../live-preview";
17
  import { HistoryNotification } from "../history-notification";
18
  import { AlertCircle } from "lucide-react";
19
  import { api } from "@/lib/api";
20
  import { toast } from "sonner";
21
  import Loading from "@/components/loading";
22
 
23
- export const Preview = ({ isNew }: { isNew: boolean }) => {
24
- const {
25
- project,
26
- device,
27
- isLoadingProject,
28
- currentTab,
29
- currentCommit,
30
- setCurrentCommit,
31
- currentPageData,
32
- pages,
33
- setPages,
34
- setCurrentPage,
35
- } = useEditor();
36
- const {
37
- isEditableModeEnabled,
38
- setSelectedElement,
39
- isAiWorking,
40
- globalAiLoading,
41
- } = useAi();
 
42
 
43
- const iframeRef = useRef<HTMLIFrameElement>(null);
44
 
45
- const [hoveredElement, setHoveredElement] = useState<{
46
- tagName: string;
47
- rect: { top: number; left: number; width: number; height: number };
48
- } | null>(null);
49
- const [isPromotingVersion, setIsPromotingVersion] = useState(false);
50
- const [stableHtml, setStableHtml] = useState<string>("");
51
 
52
- useEffect(() => {
53
- if (!isAiWorking && !globalAiLoading && currentPageData?.html) {
54
- setStableHtml(currentPageData.html);
55
- }
56
- }, [isAiWorking, globalAiLoading, currentPageData?.html]);
57
-
58
- useEffect(() => {
59
- if (
60
- currentPageData?.html &&
61
- !stableHtml &&
62
- !isAiWorking &&
63
- !globalAiLoading
64
- ) {
65
- setStableHtml(currentPageData.html);
66
- }
67
- }, [currentPageData?.html, stableHtml, isAiWorking, globalAiLoading]);
68
 
69
- useUpdateEffect(() => {
70
- const cleanupListeners = () => {
71
- if (iframeRef?.current?.contentDocument) {
72
- const iframeDocument = iframeRef.current.contentDocument;
73
- iframeDocument.removeEventListener("mouseover", handleMouseOver);
74
- iframeDocument.removeEventListener("mouseout", handleMouseOut);
75
- iframeDocument.removeEventListener("click", handleClick);
 
76
  }
77
- };
 
 
 
 
 
 
 
 
 
 
78
 
79
- if (iframeRef?.current) {
80
- const iframeDocument = iframeRef.current.contentDocument;
81
- if (iframeDocument) {
82
- cleanupListeners();
83
 
84
- if (isEditableModeEnabled) {
85
- iframeDocument.addEventListener("mouseover", handleMouseOver);
86
- iframeDocument.addEventListener("mouseout", handleMouseOut);
87
- iframeDocument.addEventListener("click", handleClick);
 
88
  }
89
  }
90
- }
91
 
92
- return cleanupListeners;
93
- }, [iframeRef, isEditableModeEnabled]);
94
 
95
- const promoteVersion = async () => {
96
- setIsPromotingVersion(true);
97
- await api
98
- .post(
99
- `/me/projects/${project?.space_id}/commits/${currentCommit}/promote`
100
- )
101
- .then((res) => {
102
- if (res.data.ok) {
103
- setCurrentCommit(null);
104
- setPages(res.data.pages);
105
- setCurrentPage(res.data.pages[0].path);
106
- toast.success("Version promoted successfully");
107
- }
108
- })
109
- .catch((err) => {
110
- toast.error(err.response.data.error);
111
- });
112
- setIsPromotingVersion(false);
113
- };
114
 
115
- const handleMouseOver = (event: MouseEvent) => {
116
- if (iframeRef?.current) {
117
- const iframeDocument = iframeRef.current.contentDocument;
118
- if (iframeDocument) {
119
- const targetElement = event.target as HTMLElement;
120
- if (
121
- hoveredElement?.tagName !== targetElement.tagName ||
122
- hoveredElement?.rect.top !==
123
- targetElement.getBoundingClientRect().top ||
124
- hoveredElement?.rect.left !==
125
- targetElement.getBoundingClientRect().left ||
126
- hoveredElement?.rect.width !==
127
- targetElement.getBoundingClientRect().width ||
128
- hoveredElement?.rect.height !==
129
- targetElement.getBoundingClientRect().height
130
- ) {
131
- if (targetElement !== iframeDocument.body) {
132
- const rect = targetElement.getBoundingClientRect();
133
- setHoveredElement({
134
- tagName: targetElement.tagName,
135
- rect: {
136
- top: rect.top,
137
- left: rect.left,
138
- width: rect.width,
139
- height: rect.height,
140
- },
141
- });
142
- targetElement.classList.add("hovered-element");
143
- } else {
144
- return setHoveredElement(null);
 
145
  }
146
  }
147
  }
148
- }
149
- };
150
- const handleMouseOut = () => {
151
- setHoveredElement(null);
152
- };
153
- const handleClick = (event: MouseEvent) => {
154
- if (iframeRef?.current) {
155
- const iframeDocument = iframeRef.current.contentDocument;
156
- if (iframeDocument) {
157
- const targetElement = event.target as HTMLElement;
158
- if (targetElement !== iframeDocument.body) {
159
- setSelectedElement(targetElement);
160
  }
161
  }
162
- }
163
- };
164
 
165
- const handleCustomNavigation = (event: MouseEvent) => {
166
- if (iframeRef?.current) {
167
- const iframeDocument = iframeRef.current.contentDocument;
168
- if (iframeDocument) {
169
- const findClosestAnchor = (
170
- element: HTMLElement
171
- ): HTMLAnchorElement | null => {
172
- let current = element;
173
- while (current && current !== iframeDocument.body) {
174
- if (current.tagName === "A") {
175
- return current as HTMLAnchorElement;
 
 
176
  }
177
- current = current.parentElement as HTMLElement;
178
- }
179
- return null;
180
- };
181
 
182
- const anchorElement = findClosestAnchor(event.target as HTMLElement);
183
- if (anchorElement) {
184
- let href = anchorElement.getAttribute("href");
185
- if (href) {
186
- event.stopPropagation();
187
- event.preventDefault();
188
 
189
- if (href.includes("#") && !href.includes(".html")) {
190
- const targetElement = iframeDocument.querySelector(href);
191
- if (targetElement) {
192
- targetElement.scrollIntoView({ behavior: "smooth" });
 
 
193
  }
194
- return;
195
- }
196
 
197
- href = href.split(".html")[0] + ".html";
198
- const isPageExist = pages.some((page) => page.path === href);
199
- if (isPageExist) {
200
- setCurrentPage(href);
 
201
  }
202
  }
203
  }
204
  }
205
- }
206
- };
207
 
208
- return (
209
- <div
210
- className={classNames(
211
- "bg-neutral-900/30 w-full h-[calc(100dvh-57px)] flex flex-col items-center justify-center relative z-1 lg:border-l border-neutral-800",
212
- {
213
- "max-lg:h-0": currentTab === "chat",
214
- "max-lg:h-full": currentTab === "preview",
215
- }
216
- )}
217
- >
218
- <GridPattern
219
- x={-1}
220
- y={-1}
221
- strokeDasharray={"4 2"}
222
- className={cn(
223
- "[mask-image:radial-gradient(900px_circle_at_center,white,transparent)] opacity-40"
224
  )}
225
- />
226
- {!isAiWorking && hoveredElement && isEditableModeEnabled && (
227
- <div
228
- 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"
229
- style={{
230
- top: hoveredElement.rect.top,
231
- left: hoveredElement.rect.left,
232
- width: hoveredElement.rect.width,
233
- height: hoveredElement.rect.height,
234
- }}
235
- >
236
- <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">
237
- {htmlTagToText(hoveredElement.tagName.toLowerCase())}
238
- </span>
239
- </div>
240
- )}
241
- {isNew && !isLoadingProject ? (
242
- <iframe
243
- className={classNames(
244
- "w-full select-none transition-all duration-200 bg-black h-full",
245
- {
246
- "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]":
247
- device === "mobile",
248
- }
249
  )}
250
- srcDoc={defaultHTML}
251
  />
252
- ) : isLoadingProject || (globalAiLoading && !stableHtml) ? (
253
- <div className="w-full h-full flex items-center justify-center relative">
254
- <div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center">
255
- <AiLoading
256
- text={isLoadingProject ? "Fetching your project..." : undefined}
257
- className="flex-col"
258
- />
259
- <AnimatedBlobs />
260
- <AnimatedBlobs />
 
 
 
 
261
  </div>
262
- {!isLoadingProject && (
263
- <LivePreview
264
- currentPageData={currentPageData}
265
- isAiWorking={isAiWorking}
266
- defaultHTML={defaultHTML}
267
- className="bottom-4 left-4"
268
- />
269
- )}
270
- </div>
271
- ) : (
272
- <>
273
  <iframe
274
- id="preview-iframe"
275
- ref={iframeRef}
276
  className={classNames(
277
  "w-full select-none transition-all duration-200 bg-black h-full",
278
  {
@@ -280,51 +248,91 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
280
  device === "mobile",
281
  }
282
  )}
283
- src={
284
- currentCommit
285
- ? `https://${project?.space_id?.replaceAll(
286
- "/",
287
- "-"
288
- )}--rev-${currentCommit.slice(0, 7)}.static.hf.space`
289
- : undefined
290
- }
291
- srcDoc={!currentCommit ? stableHtml : undefined}
292
- onLoad={
293
- !currentCommit
294
- ? () => {
295
- if (iframeRef?.current?.contentWindow?.document?.body) {
296
- iframeRef.current.contentWindow.document.body.scrollIntoView(
297
- {
298
- block: isAiWorking ? "end" : "start",
299
- inline: "nearest",
300
- behavior: isAiWorking ? "instant" : "smooth",
301
- }
302
- );
303
- }
304
- // add event listener to all links in the iframe to handle navigation
305
- if (iframeRef?.current?.contentWindow?.document) {
306
- const links =
307
- iframeRef.current.contentWindow.document.querySelectorAll(
308
- "a"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  );
310
- links.forEach((link) => {
311
- link.addEventListener("click", handleCustomNavigation);
312
- });
 
 
 
 
 
 
 
 
 
 
 
313
  }
314
- }
315
- : undefined
316
- }
317
- sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
318
- allow="accelerometer; ambient-light-sensor; autoplay; battery; camera; clipboard-read; clipboard-write; display-capture; document-domain; encrypted-media; fullscreen; geolocation; gyroscope; layout-animations; legacy-image-formats; magnetometer; microphone; midi; oversized-images; payment; picture-in-picture; publickey-credentials-get; serial; sync-xhr; usb; vr ; wake-lock; xr-spatial-tracking"
319
- />
320
- <HistoryNotification
321
- isVisible={!!currentCommit}
322
- isPromotingVersion={isPromotingVersion}
323
- onPromoteVersion={promoteVersion}
324
- onGoBackToCurrent={() => setCurrentCommit(null)}
325
- />
326
- </>
327
- )}
328
- </div>
329
- );
330
- };
 
 
 
1
  "use client";
2
 
3
+ import { useRef, useState, useEffect, forwardRef } from "react";
4
  import { useUpdateEffect } from "react-use";
5
  import classNames from "classnames";
6
 
 
13
  import { AiLoading } from "../ask-ai/loading";
14
  import { defaultHTML } from "@/lib/consts";
15
  import { Button } from "@/components/ui/button";
16
+ import { LivePreview, LivePreviewRef } from "../live-preview";
17
  import { HistoryNotification } from "../history-notification";
18
  import { AlertCircle } from "lucide-react";
19
  import { api } from "@/lib/api";
20
  import { toast } from "sonner";
21
  import Loading from "@/components/loading";
22
 
23
+ export const Preview = forwardRef<LivePreviewRef, { isNew: boolean }>(
24
+ ({ isNew }, ref) => {
25
+ const {
26
+ project,
27
+ device,
28
+ isLoadingProject,
29
+ currentTab,
30
+ currentCommit,
31
+ setCurrentCommit,
32
+ currentPageData,
33
+ pages,
34
+ setPages,
35
+ setCurrentPage,
36
+ } = useEditor();
37
+ const {
38
+ isEditableModeEnabled,
39
+ setSelectedElement,
40
+ isAiWorking,
41
+ globalAiLoading,
42
+ } = useAi();
43
 
44
+ const iframeRef = useRef<HTMLIFrameElement>(null);
45
 
46
+ const [hoveredElement, setHoveredElement] = useState<{
47
+ tagName: string;
48
+ rect: { top: number; left: number; width: number; height: number };
49
+ } | null>(null);
50
+ const [isPromotingVersion, setIsPromotingVersion] = useState(false);
51
+ const [stableHtml, setStableHtml] = useState<string>("");
52
 
53
+ useEffect(() => {
54
+ if (!isAiWorking && !globalAiLoading && currentPageData?.html) {
55
+ setStableHtml(currentPageData.html);
56
+ }
57
+ }, [isAiWorking, globalAiLoading, currentPageData?.html]);
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ useEffect(() => {
60
+ if (
61
+ currentPageData?.html &&
62
+ !stableHtml &&
63
+ !isAiWorking &&
64
+ !globalAiLoading
65
+ ) {
66
+ setStableHtml(currentPageData.html);
67
  }
68
+ }, [currentPageData?.html, stableHtml, isAiWorking, globalAiLoading]);
69
+
70
+ useUpdateEffect(() => {
71
+ const cleanupListeners = () => {
72
+ if (iframeRef?.current?.contentDocument) {
73
+ const iframeDocument = iframeRef.current.contentDocument;
74
+ iframeDocument.removeEventListener("mouseover", handleMouseOver);
75
+ iframeDocument.removeEventListener("mouseout", handleMouseOut);
76
+ iframeDocument.removeEventListener("click", handleClick);
77
+ }
78
+ };
79
 
80
+ if (iframeRef?.current) {
81
+ const iframeDocument = iframeRef.current.contentDocument;
82
+ if (iframeDocument) {
83
+ cleanupListeners();
84
 
85
+ if (isEditableModeEnabled) {
86
+ iframeDocument.addEventListener("mouseover", handleMouseOver);
87
+ iframeDocument.addEventListener("mouseout", handleMouseOut);
88
+ iframeDocument.addEventListener("click", handleClick);
89
+ }
90
  }
91
  }
 
92
 
93
+ return cleanupListeners;
94
+ }, [iframeRef, isEditableModeEnabled]);
95
 
96
+ const promoteVersion = async () => {
97
+ setIsPromotingVersion(true);
98
+ await api
99
+ .post(
100
+ `/me/projects/${project?.space_id}/commits/${currentCommit}/promote`
101
+ )
102
+ .then((res) => {
103
+ if (res.data.ok) {
104
+ setCurrentCommit(null);
105
+ setPages(res.data.pages);
106
+ setCurrentPage(res.data.pages[0].path);
107
+ toast.success("Version promoted successfully");
108
+ }
109
+ })
110
+ .catch((err) => {
111
+ toast.error(err.response.data.error);
112
+ });
113
+ setIsPromotingVersion(false);
114
+ };
115
 
116
+ const handleMouseOver = (event: MouseEvent) => {
117
+ if (iframeRef?.current) {
118
+ const iframeDocument = iframeRef.current.contentDocument;
119
+ if (iframeDocument) {
120
+ const targetElement = event.target as HTMLElement;
121
+ if (
122
+ hoveredElement?.tagName !== targetElement.tagName ||
123
+ hoveredElement?.rect.top !==
124
+ targetElement.getBoundingClientRect().top ||
125
+ hoveredElement?.rect.left !==
126
+ targetElement.getBoundingClientRect().left ||
127
+ hoveredElement?.rect.width !==
128
+ targetElement.getBoundingClientRect().width ||
129
+ hoveredElement?.rect.height !==
130
+ targetElement.getBoundingClientRect().height
131
+ ) {
132
+ if (targetElement !== iframeDocument.body) {
133
+ const rect = targetElement.getBoundingClientRect();
134
+ setHoveredElement({
135
+ tagName: targetElement.tagName,
136
+ rect: {
137
+ top: rect.top,
138
+ left: rect.left,
139
+ width: rect.width,
140
+ height: rect.height,
141
+ },
142
+ });
143
+ targetElement.classList.add("hovered-element");
144
+ } else {
145
+ return setHoveredElement(null);
146
+ }
147
  }
148
  }
149
  }
150
+ };
151
+ const handleMouseOut = () => {
152
+ setHoveredElement(null);
153
+ };
154
+ const handleClick = (event: MouseEvent) => {
155
+ if (iframeRef?.current) {
156
+ const iframeDocument = iframeRef.current.contentDocument;
157
+ if (iframeDocument) {
158
+ const targetElement = event.target as HTMLElement;
159
+ if (targetElement !== iframeDocument.body) {
160
+ setSelectedElement(targetElement);
161
+ }
162
  }
163
  }
164
+ };
 
165
 
166
+ const handleCustomNavigation = (event: MouseEvent) => {
167
+ if (iframeRef?.current) {
168
+ const iframeDocument = iframeRef.current.contentDocument;
169
+ if (iframeDocument) {
170
+ const findClosestAnchor = (
171
+ element: HTMLElement
172
+ ): HTMLAnchorElement | null => {
173
+ let current = element;
174
+ while (current && current !== iframeDocument.body) {
175
+ if (current.tagName === "A") {
176
+ return current as HTMLAnchorElement;
177
+ }
178
+ current = current.parentElement as HTMLElement;
179
  }
180
+ return null;
181
+ };
 
 
182
 
183
+ const anchorElement = findClosestAnchor(event.target as HTMLElement);
184
+ if (anchorElement) {
185
+ let href = anchorElement.getAttribute("href");
186
+ if (href) {
187
+ event.stopPropagation();
188
+ event.preventDefault();
189
 
190
+ if (href.includes("#") && !href.includes(".html")) {
191
+ const targetElement = iframeDocument.querySelector(href);
192
+ if (targetElement) {
193
+ targetElement.scrollIntoView({ behavior: "smooth" });
194
+ }
195
+ return;
196
  }
 
 
197
 
198
+ href = href.split(".html")[0] + ".html";
199
+ const isPageExist = pages.some((page) => page.path === href);
200
+ if (isPageExist) {
201
+ setCurrentPage(href);
202
+ }
203
  }
204
  }
205
  }
206
  }
207
+ };
 
208
 
209
+ return (
210
+ <div
211
+ className={classNames(
212
+ "bg-neutral-900/30 w-full h-[calc(100dvh-57px)] flex flex-col items-center justify-center relative z-1 lg:border-l border-neutral-800",
213
+ {
214
+ "max-lg:h-0": currentTab === "chat",
215
+ "max-lg:h-full": currentTab === "preview",
216
+ }
 
 
 
 
 
 
 
 
217
  )}
218
+ >
219
+ <GridPattern
220
+ x={-1}
221
+ y={-1}
222
+ strokeDasharray={"4 2"}
223
+ className={cn(
224
+ "[mask-image:radial-gradient(900px_circle_at_center,white,transparent)] opacity-40"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  )}
 
226
  />
227
+ {!isAiWorking && hoveredElement && isEditableModeEnabled && (
228
+ <div
229
+ 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"
230
+ style={{
231
+ top: hoveredElement.rect.top,
232
+ left: hoveredElement.rect.left,
233
+ width: hoveredElement.rect.width,
234
+ height: hoveredElement.rect.height,
235
+ }}
236
+ >
237
+ <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">
238
+ {htmlTagToText(hoveredElement.tagName.toLowerCase())}
239
+ </span>
240
  </div>
241
+ )}
242
+ {isNew && !isLoadingProject && !globalAiLoading ? (
 
 
 
 
 
 
 
 
 
243
  <iframe
 
 
244
  className={classNames(
245
  "w-full select-none transition-all duration-200 bg-black h-full",
246
  {
 
248
  device === "mobile",
249
  }
250
  )}
251
+ srcDoc={defaultHTML}
252
+ />
253
+ ) : isLoadingProject || globalAiLoading ? (
254
+ <div className="w-full h-full flex items-center justify-center relative">
255
+ <div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center">
256
+ <AiLoading
257
+ text={isLoadingProject ? "Fetching your project..." : undefined}
258
+ className="flex-col"
259
+ />
260
+ <AnimatedBlobs />
261
+ <AnimatedBlobs />
262
+ </div>
263
+ {!isLoadingProject && (
264
+ <LivePreview
265
+ ref={ref}
266
+ currentPageData={currentPageData}
267
+ isAiWorking={isAiWorking}
268
+ defaultHTML={defaultHTML}
269
+ className="bottom-4 left-4"
270
+ />
271
+ )}
272
+ </div>
273
+ ) : (
274
+ <>
275
+ <iframe
276
+ id="preview-iframe"
277
+ ref={iframeRef}
278
+ className={classNames(
279
+ "w-full select-none transition-all duration-200 bg-black h-full",
280
+ {
281
+ "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]":
282
+ device === "mobile",
283
+ }
284
+ )}
285
+ src={
286
+ currentCommit
287
+ ? `https://${project?.space_id?.replaceAll(
288
+ "/",
289
+ "-"
290
+ )}--rev-${currentCommit.slice(0, 7)}.static.hf.space`
291
+ : undefined
292
+ }
293
+ srcDoc={!currentCommit ? stableHtml : undefined}
294
+ onLoad={
295
+ !currentCommit
296
+ ? () => {
297
+ if (iframeRef?.current?.contentWindow?.document?.body) {
298
+ iframeRef.current.contentWindow.document.body.scrollIntoView(
299
+ {
300
+ block: isAiWorking ? "end" : "start",
301
+ inline: "nearest",
302
+ behavior: isAiWorking ? "instant" : "smooth",
303
+ }
304
  );
305
+ }
306
+ // add event listener to all links in the iframe to handle navigation
307
+ if (iframeRef?.current?.contentWindow?.document) {
308
+ const links =
309
+ iframeRef.current.contentWindow.document.querySelectorAll(
310
+ "a"
311
+ );
312
+ links.forEach((link) => {
313
+ link.addEventListener(
314
+ "click",
315
+ handleCustomNavigation
316
+ );
317
+ });
318
+ }
319
  }
320
+ : undefined
321
+ }
322
+ sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
323
+ allow="accelerometer; ambient-light-sensor; autoplay; battery; camera; clipboard-read; clipboard-write; display-capture; document-domain; encrypted-media; fullscreen; geolocation; gyroscope; layout-animations; legacy-image-formats; magnetometer; microphone; midi; oversized-images; payment; picture-in-picture; publickey-credentials-get; serial; sync-xhr; usb; vr ; wake-lock; xr-spatial-tracking"
324
+ />
325
+ <HistoryNotification
326
+ isVisible={!!currentCommit}
327
+ isPromotingVersion={isPromotingVersion}
328
+ onPromoteVersion={promoteVersion}
329
+ onGoBackToCurrent={() => setCurrentCommit(null)}
330
+ />
331
+ </>
332
+ )}
333
+ </div>
334
+ );
335
+ }
336
+ );
337
+
338
+ Preview.displayName = "Preview";
components/my-projects/index.tsx CHANGED
@@ -94,7 +94,7 @@ export function MyProjects() {
94
  )}
95
  {projects.map((project: ProjectType) => (
96
  <ProjectCard
97
- key={project.name}
98
  project={project}
99
  onDelete={() => onDelete(project)}
100
  />
 
94
  )}
95
  {projects.map((project: ProjectType) => (
96
  <ProjectCard
97
+ key={project.id}
98
  project={project}
99
  onDelete={() => onDelete(project)}
100
  />
hooks/useAi.ts CHANGED
@@ -9,8 +9,9 @@ import { Page, EnhancedSettings } from "@/types";
9
  import { api } from "@/lib/api";
10
  import { useRouter } from "next/navigation";
11
  import { useUser } from "./useUser";
 
12
 
13
- export const useAi = (onScrollToBottom?: () => void) => {
14
  const client = useQueryClient();
15
  const audio = useRef<HTMLAudioElement | null>(null);
16
  const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages } = useEditor();
@@ -111,6 +112,10 @@ export const useAi = (onScrollToBottom?: () => void) => {
111
  });
112
  if (response.data.ok) {
113
  setIsAiWorking(false);
 
 
 
 
114
  router.replace(`/projects/${response.data.space.project.space_id}`);
115
  setProject(response.data.space);
116
  setProjects([...projects, response.data.space]);
@@ -459,6 +464,10 @@ export const useAi = (onScrollToBottom?: () => void) => {
459
  }
460
  setIsAiWorking(false);
461
  setIsThinking(false);
 
 
 
 
462
  };
463
 
464
  const selectedModel = useMemo(() => {
 
9
  import { api } from "@/lib/api";
10
  import { useRouter } from "next/navigation";
11
  import { useUser } from "./useUser";
12
+ import { LivePreviewRef } from "@/components/editor/live-preview";
13
 
14
+ export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefObject<LivePreviewRef | null>) => {
15
  const client = useQueryClient();
16
  const audio = useRef<HTMLAudioElement | null>(null);
17
  const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages } = useEditor();
 
112
  });
113
  if (response.data.ok) {
114
  setIsAiWorking(false);
115
+ // Reset live preview when project is created
116
+ if (livePreviewRef?.current) {
117
+ livePreviewRef.current.reset();
118
+ }
119
  router.replace(`/projects/${response.data.space.project.space_id}`);
120
  setProject(response.data.space);
121
  setProjects([...projects, response.data.space]);
 
464
  }
465
  setIsAiWorking(false);
466
  setIsThinking(false);
467
+ // Reset live preview when request is aborted
468
+ if (livePreviewRef?.current) {
469
+ livePreviewRef.current.reset();
470
+ }
471
  };
472
 
473
  const selectedModel = useMemo(() => {
hooks/useEditor.ts CHANGED
@@ -308,11 +308,11 @@ export const useEditor = (namespace?: string, repoId?: string) => {
308
  setHasUnsavedChanges(false);
309
  setLastSavedPages([]);
310
 
311
- client.invalidateQueries({ queryKey: ["editor.project"] });
312
- client.invalidateQueries({ queryKey: ["editor.pages"] });
313
- client.invalidateQueries({ queryKey: ["editor.files"] });
314
- client.invalidateQueries({ queryKey: ["editor.commits"] });
315
- client.invalidateQueries({ queryKey: ["editor.currentPage"] });
316
  client.invalidateQueries({ queryKey: ["editor.currentCommit"] });
317
  client.invalidateQueries({ queryKey: ["editor.lastSavedPages"] });
318
  client.invalidateQueries({ queryKey: ["editor.hasUnsavedChanges"] });
 
308
  setHasUnsavedChanges(false);
309
  setLastSavedPages([]);
310
 
311
+ // client.invalidateQueries({ queryKey: ["editor.project"] });
312
+ // client.invalidateQueries({ queryKey: ["editor.pages"] });
313
+ // client.invalidateQueries({ queryKey: ["editor.files"] });
314
+ // client.invalidateQueries({ queryKey: ["editor.commits"] });
315
+ // client.invalidateQueries({ queryKey: ["editor.currentPage"] });
316
  client.invalidateQueries({ queryKey: ["editor.currentCommit"] });
317
  client.invalidateQueries({ queryKey: ["editor.lastSavedPages"] });
318
  client.invalidateQueries({ queryKey: ["editor.hasUnsavedChanges"] });