enzostvs HF Staff commited on
Commit
d157265
·
1 Parent(s): 4fb6d26

manual saves

Browse files
app/api/ask/route.ts CHANGED
@@ -514,7 +514,7 @@ export async function PUT(request: NextRequest) {
514
  files.push(file);
515
  });
516
 
517
- uploadFiles({
518
  repo: {
519
  type: "space",
520
  name: repoId,
@@ -528,6 +528,10 @@ export async function PUT(request: NextRequest) {
528
  ok: true,
529
  updatedLines,
530
  pages: updatedPages,
 
 
 
 
531
  });
532
  } else {
533
  return NextResponse.json(
 
514
  files.push(file);
515
  });
516
 
517
+ const response = await uploadFiles({
518
  repo: {
519
  type: "space",
520
  name: repoId,
 
528
  ok: true,
529
  updatedLines,
530
  pages: updatedPages,
531
+ commit: {
532
+ ...response.commit,
533
+ title: prompt,
534
+ }
535
  });
536
  } else {
537
  return NextResponse.json(
app/api/me/projects/[namespace]/[repoId]/commits/[commitId]/promote/route.ts CHANGED
@@ -1,7 +1,8 @@
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { RepoDesignation, listFiles, spaceInfo, uploadFiles } from "@huggingface/hub";
3
 
4
  import { isAuthenticated } from "@/lib/auth";
 
5
 
6
  export async function POST(
7
  req: NextRequest,
@@ -50,7 +51,9 @@ export async function POST(
50
 
51
  // Fetch files from the specific commit
52
  const files: File[] = [];
 
53
  const allowedExtensions = ["html", "md", "css", "js", "json", "txt"];
 
54
 
55
  // Get all files from the specific commit
56
  for await (const fileInfo of listFiles({
@@ -61,6 +64,8 @@ export async function POST(
61
  const fileExtension = fileInfo.path.split('.').pop()?.toLowerCase();
62
 
63
  if (allowedExtensions.includes(fileExtension || "")) {
 
 
64
  // Fetch the file content from the specific commit
65
  const response = await fetch(
66
  `https://huggingface.co/spaces/${namespace}/${repoId}/raw/${commitId}/${fileInfo.path}`
@@ -73,6 +78,11 @@ export async function POST(
73
  switch (fileExtension) {
74
  case "html":
75
  mimeType = "text/html";
 
 
 
 
 
76
  break;
77
  case "css":
78
  mimeType = "text/css";
@@ -94,28 +104,63 @@ export async function POST(
94
  }
95
  }
96
 
97
- if (files.length === 0) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  return NextResponse.json(
99
- { ok: false, error: "No files found in the specified commit" },
100
  { status: 404 }
101
  );
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
104
  // Upload the files to the main branch with a promotion commit message
105
- await uploadFiles({
106
- repo,
107
- files,
108
- accessToken: user.token as string,
109
- commitTitle: `Promote version ${commitId.slice(0, 7)} to main`,
110
- commitDescription: `Promoted commit ${commitId} to main branch`,
111
- });
 
 
112
 
113
  return NextResponse.json(
114
  {
115
  ok: true,
116
  message: "Version promoted successfully",
117
  promotedCommit: commitId,
118
- filesPromoted: files.length
119
  },
120
  { status: 200 }
121
  );
 
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { RepoDesignation, listFiles, spaceInfo, uploadFiles, deleteFiles } from "@huggingface/hub";
3
 
4
  import { isAuthenticated } from "@/lib/auth";
5
+ import { Page } from "@/types";
6
 
7
  export async function POST(
8
  req: NextRequest,
 
51
 
52
  // Fetch files from the specific commit
53
  const files: File[] = [];
54
+ const pages: Page[] = [];
55
  const allowedExtensions = ["html", "md", "css", "js", "json", "txt"];
56
+ const commitFilePaths: Set<string> = new Set();
57
 
58
  // Get all files from the specific commit
59
  for await (const fileInfo of listFiles({
 
64
  const fileExtension = fileInfo.path.split('.').pop()?.toLowerCase();
65
 
66
  if (allowedExtensions.includes(fileExtension || "")) {
67
+ commitFilePaths.add(fileInfo.path);
68
+
69
  // Fetch the file content from the specific commit
70
  const response = await fetch(
71
  `https://huggingface.co/spaces/${namespace}/${repoId}/raw/${commitId}/${fileInfo.path}`
 
78
  switch (fileExtension) {
79
  case "html":
80
  mimeType = "text/html";
81
+ // Add HTML files to pages array for client-side setPages
82
+ pages.push({
83
+ path: fileInfo.path,
84
+ html: content,
85
+ });
86
  break;
87
  case "css":
88
  mimeType = "text/css";
 
104
  }
105
  }
106
 
107
+ // Get files currently in main branch to identify files to delete
108
+ const mainBranchFilePaths: Set<string> = new Set();
109
+ for await (const fileInfo of listFiles({
110
+ repo,
111
+ accessToken: user.token as string,
112
+ revision: "main",
113
+ })) {
114
+ const fileExtension = fileInfo.path.split('.').pop()?.toLowerCase();
115
+
116
+ if (allowedExtensions.includes(fileExtension || "")) {
117
+ mainBranchFilePaths.add(fileInfo.path);
118
+ }
119
+ }
120
+
121
+ // Identify files to delete (exist in main but not in commit)
122
+ const filesToDelete: string[] = [];
123
+ for (const mainFilePath of mainBranchFilePaths) {
124
+ if (!commitFilePaths.has(mainFilePath)) {
125
+ filesToDelete.push(mainFilePath);
126
+ }
127
+ }
128
+
129
+ if (files.length === 0 && filesToDelete.length === 0) {
130
  return NextResponse.json(
131
+ { ok: false, error: "No files found in the specified commit and no files to delete" },
132
  { status: 404 }
133
  );
134
  }
135
 
136
+ // Delete files that exist in main but not in the commit being promoted
137
+ if (filesToDelete.length > 0) {
138
+ await deleteFiles({
139
+ repo,
140
+ paths: filesToDelete,
141
+ accessToken: user.token as string,
142
+ commitTitle: `Removed files from promoting ${commitId.slice(0, 7)}`,
143
+ commitDescription: `Removed files that don't exist in commit ${commitId}:\n${filesToDelete.map(path => `- ${path}`).join('\n')}`,
144
+ });
145
+ }
146
+
147
  // Upload the files to the main branch with a promotion commit message
148
+ if (files.length > 0) {
149
+ await uploadFiles({
150
+ repo,
151
+ files,
152
+ accessToken: user.token as string,
153
+ commitTitle: `Promote version ${commitId.slice(0, 7)} to main`,
154
+ commitDescription: `Promoted commit ${commitId} to main branch`,
155
+ });
156
+ }
157
 
158
  return NextResponse.json(
159
  {
160
  ok: true,
161
  message: "Version promoted successfully",
162
  promotedCommit: commitId,
163
+ pages: pages,
164
  },
165
  { status: 200 }
166
  );
app/api/me/projects/[namespace]/[repoId]/route.ts CHANGED
@@ -96,15 +96,6 @@ export async function GET(
96
  { status: 403 }
97
  );
98
  }
99
- // if (space.private) {
100
- // return NextResponse.json(
101
- // {
102
- // ok: false,
103
- // error: "Space must be public to access it",
104
- // },
105
- // { status: 403 }
106
- // );
107
- // }
108
 
109
  const repo: RepoDesignation = {
110
  type: "space",
@@ -145,7 +136,7 @@ export async function GET(
145
  }
146
  const commits: Commit[] = [];
147
  for await (const commit of listCommits({ repo, accessToken: user.token as string })) {
148
- if (commit.title.includes("initial commit") || commit.title.includes("image(s)") || commit.title.includes("Promote version")) {
149
  continue;
150
  }
151
  commits.push({
 
96
  { status: 403 }
97
  );
98
  }
 
 
 
 
 
 
 
 
 
99
 
100
  const repo: RepoDesignation = {
101
  type: "space",
 
136
  }
137
  const commits: Commit[] = [];
138
  for await (const commit of listCommits({ repo, accessToken: user.token as string })) {
139
+ if (commit.title.includes("initial commit") || commit.title.includes("image(s)") || commit.title.includes("Removed files from promoting")) {
140
  continue;
141
  }
142
  commits.push({
app/api/me/projects/[namespace]/[repoId]/save/route.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { uploadFiles } from "@huggingface/hub";
3
+
4
+ import { isAuthenticated } from "@/lib/auth";
5
+ import { Page } from "@/types";
6
+
7
+ export async function PUT(
8
+ req: NextRequest,
9
+ { params }: { params: Promise<{ namespace: string; repoId: string }> }
10
+ ) {
11
+ const user = await isAuthenticated();
12
+ if (user instanceof NextResponse || !user) {
13
+ return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
14
+ }
15
+
16
+ const param = await params;
17
+ const { namespace, repoId } = param;
18
+ const { pages, commitTitle = "Manual changes saved" } = await req.json();
19
+
20
+ if (!pages || !Array.isArray(pages) || pages.length === 0) {
21
+ return NextResponse.json(
22
+ { ok: false, error: "Pages are required" },
23
+ { status: 400 }
24
+ );
25
+ }
26
+
27
+ try {
28
+ // Prepare files for upload
29
+ const files: File[] = [];
30
+ pages.forEach((page: Page) => {
31
+ const file = new File([page.html], page.path, { type: "text/html" });
32
+ files.push(file);
33
+ });
34
+
35
+ // Upload files to HuggingFace Hub
36
+ const response = await uploadFiles({
37
+ repo: {
38
+ type: "space",
39
+ name: `${namespace}/${repoId}`,
40
+ },
41
+ files,
42
+ commitTitle,
43
+ accessToken: user.token as string,
44
+ });
45
+
46
+ return NextResponse.json({
47
+ ok: true,
48
+ pages,
49
+ commit: {
50
+ ...response.commit,
51
+ title: commitTitle,
52
+ }
53
+ });
54
+ } catch (error: any) {
55
+ console.error("Error saving manual changes:", error);
56
+ return NextResponse.json(
57
+ {
58
+ ok: false,
59
+ error: error.message || "Failed to save changes",
60
+ },
61
+ { status: 500 }
62
+ );
63
+ }
64
+ }
components/editor/header/index.tsx CHANGED
@@ -20,7 +20,6 @@ import {
20
  export function Header() {
21
  const { project } = useEditor();
22
  const { user, openLoginWindow } = useUser();
23
- console.log(project);
24
  return (
25
  <header className="border-b bg-neutral-950 dark:border-neutral-800 grid grid-cols-3 lg:flex items-center max-lg:gap-3 justify-between z-20">
26
  <div className="flex items-center justify-between lg:max-w-[600px] lg:w-full py-2 px-2 lg:px-3 lg:pl-6 gap-3">
 
20
  export function Header() {
21
  const { project } = useEditor();
22
  const { user, openLoginWindow } = useUser();
 
23
  return (
24
  <header className="border-b bg-neutral-950 dark:border-neutral-800 grid grid-cols-3 lg:flex items-center max-lg:gap-3 justify-between z-20">
25
  <div className="flex items-center justify-between lg:max-w-[600px] lg:w-full py-2 px-2 lg:px-3 lg:pl-6 gap-3">
components/editor/history/index.tsx CHANGED
@@ -44,7 +44,9 @@ export function History() {
44
  className={classNames(
45
  "px-4 text-gray-200 py-2 border-b border-gray-800 last:border-0 space-y-1",
46
  {
47
- "bg-blue-500/10": currentCommit === item.oid,
 
 
48
  }
49
  )}
50
  >
@@ -64,21 +66,26 @@ export function History() {
64
  hour12: false,
65
  })}
66
  </p>
67
- {currentCommit !== item.oid ? (
 
 
 
 
 
68
  <Button
69
  variant="link"
70
  size="xss"
71
  className="text-gray-400 hover:text-gray-200"
72
  onClick={() => {
73
- setCurrentCommit(item.oid);
 
 
 
 
74
  }}
75
  >
76
  See version
77
  </Button>
78
- ) : (
79
- <span className="text-blue-500 bg-blue-500/10 border border-blue-500/20 rounded-full text-[10px] px-2 py-0.5">
80
- Current version
81
- </span>
82
  )}
83
  </div>
84
  </li>
 
44
  className={classNames(
45
  "px-4 text-gray-200 py-2 border-b border-gray-800 last:border-0 space-y-1",
46
  {
47
+ "bg-blue-500/10":
48
+ currentCommit === item.oid ||
49
+ (index === 0 && currentCommit === null),
50
  }
51
  )}
52
  >
 
66
  hour12: false,
67
  })}
68
  </p>
69
+ {currentCommit === item.oid ||
70
+ (index === 0 && currentCommit === null) ? (
71
+ <span className="text-blue-500 bg-blue-500/10 border border-blue-500/20 rounded-full text-[10px] px-2 py-0.5">
72
+ Current version
73
+ </span>
74
+ ) : (
75
  <Button
76
  variant="link"
77
  size="xss"
78
  className="text-gray-400 hover:text-gray-200"
79
  onClick={() => {
80
+ if (index === 0) {
81
+ setCurrentCommit(null);
82
+ } else {
83
+ setCurrentCommit(item.oid);
84
+ }
85
  }}
86
  >
87
  See version
88
  </Button>
 
 
 
 
89
  )}
90
  </div>
91
  </li>
components/editor/index.tsx CHANGED
@@ -1,5 +1,5 @@
1
  "use client";
2
- import { useMemo, useRef, useState } from "react";
3
  import { useCopyToClipboard } from "react-use";
4
  import { CopyIcon } from "lucide-react";
5
  import { toast } from "sonner";
@@ -14,6 +14,7 @@ import { useAi } from "@/hooks/useAi";
14
  import { ListPages } from "./pages";
15
  import { AskAi } from "./ask-ai";
16
  import { Preview } from "./preview";
 
17
  import Loading from "../loading";
18
 
19
  export const AppEditor = ({
@@ -32,14 +33,27 @@ export const AppEditor = ({
32
  currentPageData,
33
  currentTab,
34
  currentCommit,
 
 
 
35
  } = useEditor(namespace, repoId);
36
  const { isAiWorking } = useAi();
37
  const [, copyToClipboard] = useCopyToClipboard();
 
38
 
39
  const monacoRef = useRef<any>(null);
40
  const editor = useRef<HTMLDivElement>(null);
41
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
42
 
 
 
 
 
 
 
 
 
 
43
  return (
44
  <section className="h-screen w-full bg-neutral-950 flex flex-col">
45
  <Header />
@@ -113,6 +127,16 @@ export const AppEditor = ({
113
  </div>
114
  <Preview isNew={isNew} />
115
  </main>
 
 
 
 
 
 
 
 
 
 
116
  </section>
117
  );
118
  };
 
1
  "use client";
2
+ import { useMemo, useRef, useState, useEffect } from "react";
3
  import { useCopyToClipboard } from "react-use";
4
  import { CopyIcon } from "lucide-react";
5
  import { toast } from "sonner";
 
14
  import { ListPages } from "./pages";
15
  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 = ({
 
33
  currentPageData,
34
  currentTab,
35
  currentCommit,
36
+ hasUnsavedChanges,
37
+ saveChanges,
38
+ pages,
39
  } = useEditor(namespace, repoId);
40
  const { isAiWorking } = useAi();
41
  const [, copyToClipboard] = useCopyToClipboard();
42
+ const [showSavePopup, setShowSavePopup] = useState(false);
43
 
44
  const monacoRef = useRef<any>(null);
45
  const editor = useRef<HTMLDivElement>(null);
46
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
47
 
48
+ // Show save popup when there are unsaved changes
49
+ useEffect(() => {
50
+ if (hasUnsavedChanges && !isAiWorking) {
51
+ setShowSavePopup(true);
52
+ } else {
53
+ setShowSavePopup(false);
54
+ }
55
+ }, [hasUnsavedChanges, isAiWorking]);
56
+
57
  return (
58
  <section className="h-screen w-full bg-neutral-950 flex flex-col">
59
  <Header />
 
127
  </div>
128
  <Preview isNew={isNew} />
129
  </main>
130
+
131
+ {/* Save Changes Popup */}
132
+ <SaveChangesPopup
133
+ isOpen={showSavePopup}
134
+ onClose={() => setShowSavePopup(false)}
135
+ onSave={saveChanges}
136
+ hasUnsavedChanges={hasUnsavedChanges}
137
+ pages={pages}
138
+ project={project}
139
+ />
140
  </section>
141
  );
142
  };
components/editor/pages/index.tsx CHANGED
@@ -5,7 +5,7 @@ import { useEditor } from "@/hooks/useEditor";
5
  export function ListPages() {
6
  const { pages, setPages, currentPage, setCurrentPage } = useEditor();
7
  return (
8
- <div className="w-full flex items-center justify-start bg-neutral-950 overflow-auto flex-nowrap overflow-y-hidden">
9
  {pages.map((page: Page, i: number) => (
10
  <ListPagesItem
11
  key={page.path ?? i}
 
5
  export function ListPages() {
6
  const { pages, setPages, currentPage, setCurrentPage } = useEditor();
7
  return (
8
+ <div className="w-full flex items-center justify-start bg-neutral-950 overflow-auto flex-nowrap min-h-[45px]">
9
  {pages.map((page: Page, i: number) => (
10
  <ListPagesItem
11
  key={page.path ?? i}
components/editor/pages/page.tsx CHANGED
@@ -21,9 +21,9 @@ export function ListPagesItem({
21
  <div
22
  key={index}
23
  className={classNames(
24
- "pl-6 pr-1 py-4 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 border-b": currentPage === page.path,
27
  "!pr-6": index === 0, // Ensure the first item has padding on the right
28
  }
29
  )}
 
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
  )}
components/editor/preview/index.tsx CHANGED
@@ -30,6 +30,7 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
30
  setCurrentCommit,
31
  currentPageData,
32
  pages,
 
33
  setCurrentPage,
34
  } = useEditor();
35
  const {
@@ -37,154 +38,10 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
37
  setSelectedElement,
38
  isAiWorking,
39
  globalAiLoading,
40
- setIsEditableModeEnabled,
41
  } = useAi();
42
 
43
  const iframeRef = useRef<HTMLIFrameElement>(null);
44
 
45
- // Inject event handling script
46
- const injectInteractivityScript = (html: string) => {
47
- const interactivityScript = `
48
- <script>
49
- // Add event listeners and communicate with parent
50
- document.addEventListener('DOMContentLoaded', function() {
51
- let hoveredElement = null;
52
- let isEditModeEnabled = false;
53
-
54
- document.addEventListener('mouseover', function(event) {
55
- if (event.target !== document.body && event.target !== document.documentElement) {
56
- hoveredElement = event.target;
57
-
58
- const rect = event.target.getBoundingClientRect();
59
- const message = {
60
- type: 'ELEMENT_HOVERED',
61
- data: {
62
- tagName: event.target.tagName,
63
- rect: {
64
- top: rect.top,
65
- left: rect.left,
66
- width: rect.width,
67
- height: rect.height
68
- },
69
- element: event.target.outerHTML
70
- }
71
- };
72
- parent.postMessage(message, '*');
73
- }
74
- });
75
-
76
- document.addEventListener('mouseout', function(event) {
77
- hoveredElement = null;
78
-
79
- parent.postMessage({
80
- type: 'ELEMENT_MOUSE_OUT'
81
- }, '*');
82
- });
83
-
84
- // Handle clicks - prevent default only in edit mode
85
- document.addEventListener('click', function(event) {
86
- if (isEditModeEnabled) {
87
- event.preventDefault();
88
- event.stopPropagation();
89
-
90
- const rect = event.target.getBoundingClientRect();
91
- parent.postMessage({
92
- type: 'ELEMENT_CLICKED',
93
- data: {
94
- tagName: event.target.tagName,
95
- rect: {
96
- top: rect.top,
97
- left: rect.left,
98
- width: rect.width,
99
- height: rect.height
100
- },
101
- element: event.target.outerHTML
102
- }
103
- }, '*');
104
- } else {
105
- // Handle link clicks to navigate between pages
106
- const link = event.target.closest('a');
107
- if (link && link.href) {
108
- event.preventDefault();
109
-
110
- const url = new URL(link.href, window.location.href);
111
-
112
- // Check if it's a relative link (same origin)
113
- if (url.origin === window.location.origin || link.href.startsWith('/') || link.href.startsWith('./') || link.href.startsWith('../') || !link.href.includes('://')) {
114
- // Extract the path from the link
115
- let targetPath = link.getAttribute('href') || '';
116
-
117
- // Handle relative paths
118
- if (targetPath.startsWith('./')) {
119
- targetPath = targetPath.substring(2);
120
- } else if (targetPath.startsWith('/')) {
121
- targetPath = targetPath.substring(1);
122
- }
123
-
124
- // If no extension, assume .html
125
- if (!targetPath.includes('.') && !targetPath.includes('?') && !targetPath.includes('#')) {
126
- targetPath = targetPath === '' ? 'index.html' : targetPath + '.html';
127
- }
128
-
129
- // Send message to parent to navigate to the page
130
- parent.postMessage({
131
- type: 'NAVIGATE_TO_PAGE',
132
- data: {
133
- targetPath: targetPath
134
- }
135
- }, '*');
136
- } else {
137
- // External link - open in new tab
138
- window.open(link.href, '_blank');
139
- }
140
- }
141
- }
142
- });
143
-
144
- // Prevent form submissions when in edit mode
145
- document.addEventListener('submit', function(event) {
146
- if (isEditModeEnabled) {
147
- event.preventDefault();
148
- event.stopPropagation();
149
- }
150
- });
151
-
152
- // Prevent other navigation events when in edit mode
153
- document.addEventListener('keydown', function(event) {
154
- if (isEditModeEnabled && event.key === 'Enter' && (event.target.tagName === 'A' || event.target.tagName === 'BUTTON')) {
155
- event.preventDefault();
156
- event.stopPropagation();
157
- }
158
- });
159
-
160
- // Listen for messages from parent
161
- window.addEventListener('message', function(event) {
162
- if (event.data.type === 'ENABLE_EDIT_MODE') {
163
- isEditModeEnabled = true;
164
- document.body.style.userSelect = 'none';
165
- document.body.style.pointerEvents = 'auto';
166
- } else if (event.data.type === 'DISABLE_EDIT_MODE') {
167
- isEditModeEnabled = false;
168
- document.body.style.userSelect = '';
169
- document.body.style.pointerEvents = '';
170
- }
171
- });
172
-
173
- // Notify parent that script is ready
174
- parent.postMessage({
175
- type: 'IFRAME_SCRIPT_READY'
176
- }, '*');
177
- });
178
- </script>
179
- `;
180
-
181
- // Inject the script before closing body tag, or at the end if no body tag
182
- if (html.includes("</body>")) {
183
- return html.replace("</body>", `${interactivityScript}</body>`);
184
- } else {
185
- return html + interactivityScript;
186
- }
187
- };
188
  const [hoveredElement, setHoveredElement] = useState<{
189
  tagName: string;
190
  rect: { top: number; left: number; width: number; height: number };
@@ -192,99 +49,12 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
192
  const [isPromotingVersion, setIsPromotingVersion] = useState(false);
193
  const [stableHtml, setStableHtml] = useState<string>("");
194
 
195
- // Handle PostMessage communication with iframe
196
- useEffect(() => {
197
- const handleMessage = (event: MessageEvent) => {
198
- // Verify origin for security
199
- if (!event.origin.includes(window.location.origin)) {
200
- return;
201
- }
202
-
203
- const { type, data } = event.data;
204
- switch (type) {
205
- case "IFRAME_SCRIPT_READY":
206
- if (iframeRef.current?.contentWindow) {
207
- iframeRef.current.contentWindow.postMessage(
208
- {
209
- type: isEditableModeEnabled
210
- ? "ENABLE_EDIT_MODE"
211
- : "DISABLE_EDIT_MODE",
212
- },
213
- "*"
214
- );
215
- }
216
- break;
217
- case "ELEMENT_HOVERED":
218
- if (isEditableModeEnabled) {
219
- setHoveredElement(data);
220
- }
221
- break;
222
- case "ELEMENT_MOUSE_OUT":
223
- if (isEditableModeEnabled) {
224
- setHoveredElement(null);
225
- }
226
- break;
227
- case "ELEMENT_CLICKED":
228
- if (isEditableModeEnabled) {
229
- const mockElement = {
230
- tagName: data.tagName,
231
- getBoundingClientRect: () => data.rect,
232
- outerHTML: data.element,
233
- };
234
- setSelectedElement(mockElement as any);
235
- setIsEditableModeEnabled(false);
236
- }
237
- break;
238
- case "NAVIGATE_TO_PAGE":
239
- // Handle navigation between pages by updating currentPageData
240
- if (data.targetPath) {
241
- // Find the page in the pages array
242
- const targetPage = pages.find(
243
- (page) => page.path === data.targetPath
244
- );
245
- if (targetPage) {
246
- setCurrentPage(data.targetPath);
247
- } else {
248
- // If page doesn't exist, you might want to create it or show an error
249
- console.warn(`Page not found: ${data.targetPath}`);
250
- toast.error(`Page not found: ${data.targetPath}`);
251
- }
252
- }
253
- break;
254
- }
255
- };
256
-
257
- window.addEventListener("message", handleMessage);
258
- return () => window.removeEventListener("message", handleMessage);
259
- }, [setSelectedElement, isEditableModeEnabled, pages, setCurrentPage]);
260
-
261
- // Send edit mode state to iframe and clear hover state when disabled
262
- useUpdateEffect(() => {
263
- if (iframeRef.current?.contentWindow) {
264
- iframeRef.current.contentWindow.postMessage(
265
- {
266
- type: isEditableModeEnabled
267
- ? "ENABLE_EDIT_MODE"
268
- : "DISABLE_EDIT_MODE",
269
- },
270
- "*"
271
- );
272
- }
273
-
274
- // Clear hover state when edit mode is disabled
275
- if (!isEditableModeEnabled) {
276
- setHoveredElement(null);
277
- }
278
- }, [isEditableModeEnabled, stableHtml]);
279
-
280
- // Update stable HTML only when AI finishes working to prevent blinking
281
  useEffect(() => {
282
  if (!isAiWorking && !globalAiLoading && currentPageData?.html) {
283
  setStableHtml(currentPageData.html);
284
  }
285
  }, [isAiWorking, globalAiLoading, currentPageData?.html]);
286
 
287
- // Initialize stable HTML when component first loads
288
  useEffect(() => {
289
  if (
290
  currentPageData?.html &&
@@ -296,6 +66,32 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
296
  }
297
  }, [currentPageData?.html, stableHtml, isAiWorking, globalAiLoading]);
298
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  const promoteVersion = async () => {
300
  setIsPromotingVersion(true);
301
  await api
@@ -305,6 +101,8 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
305
  .then((res) => {
306
  if (res.data.ok) {
307
  setCurrentCommit(null);
 
 
308
  toast.success("Version promoted successfully");
309
  }
310
  })
@@ -314,6 +112,99 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
314
  setIsPromotingVersion(false);
315
  };
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  return (
318
  <div
319
  className={classNames(
@@ -358,7 +249,7 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
358
  )}
359
  srcDoc={defaultHTML}
360
  />
361
- ) : isLoadingProject || globalAiLoading ? (
362
  <div className="w-full h-full flex items-center justify-center relative">
363
  <div className="py-10 w-full relative z-1 max-w-3xl mx-auto text-center">
364
  <AiLoading
@@ -368,12 +259,14 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
368
  <AnimatedBlobs />
369
  <AnimatedBlobs />
370
  </div>
371
- <LivePreview
372
- currentPageData={currentPageData}
373
- isAiWorking={isAiWorking}
374
- defaultHTML={defaultHTML}
375
- className="bottom-4 left-4"
376
- />
 
 
377
  </div>
378
  ) : (
379
  <>
@@ -395,9 +288,30 @@ export const Preview = ({ isNew }: { isNew: boolean }) => {
395
  )}--rev-${currentCommit.slice(0, 7)}.static.hf.space`
396
  : undefined
397
  }
398
- srcDoc={
 
399
  !currentCommit
400
- ? injectInteractivityScript(stableHtml || "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  : undefined
402
  }
403
  sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"
 
30
  setCurrentCommit,
31
  currentPageData,
32
  pages,
33
+ setPages,
34
  setCurrentPage,
35
  } = useEditor();
36
  const {
 
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 };
 
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 &&
 
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
 
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
  })
 
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(
 
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
 
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
  <>
 
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"
components/editor/save-changes-popup/index.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { toast } from "sonner";
4
+ import { Save, X, ChevronUp, ChevronDown } from "lucide-react";
5
+ import { motion, AnimatePresence } from "framer-motion";
6
+ import classNames from "classnames";
7
+
8
+ import { Page } from "@/types";
9
+ import { api } from "@/lib/api";
10
+ import { Button } from "@/components/ui/button";
11
+ import Loading from "@/components/loading";
12
+
13
+ interface SaveChangesPopupProps {
14
+ isOpen: boolean;
15
+ onClose: () => void;
16
+ onSave: () => Promise<void>;
17
+ hasUnsavedChanges: boolean;
18
+ pages: Page[];
19
+ project?: any;
20
+ }
21
+
22
+ export const SaveChangesPopup = ({
23
+ isOpen,
24
+ onClose,
25
+ onSave,
26
+ hasUnsavedChanges,
27
+ pages,
28
+ project,
29
+ }: SaveChangesPopupProps) => {
30
+ const [isSaving, setIsSaving] = useState(false);
31
+ const [isCollapsed, setIsCollapsed] = useState(false);
32
+
33
+ const handleSave = async () => {
34
+ if (!project || !hasUnsavedChanges) return;
35
+
36
+ setIsSaving(true);
37
+ try {
38
+ await onSave();
39
+ toast.success("Changes saved successfully!");
40
+ onClose();
41
+ } catch (error: any) {
42
+ toast.error(error.message || "Failed to save changes");
43
+ } finally {
44
+ setIsSaving(false);
45
+ }
46
+ };
47
+
48
+ if (!hasUnsavedChanges || !isOpen) return null;
49
+
50
+ return (
51
+ <AnimatePresence>
52
+ <motion.div
53
+ initial={{ opacity: 0, scale: 0.9, y: 20 }}
54
+ animate={{ opacity: 1, scale: 1, y: 0 }}
55
+ exit={{ opacity: 0, scale: 0.9, y: 20 }}
56
+ className={classNames(
57
+ "absolute bottom-4 right-4 z-10 bg-white/95 backdrop-blur-sm border border-neutral-200 rounded-xl shadow-lg transition-all duration-300 ease-in-out"
58
+ )}
59
+ >
60
+ {isCollapsed ? (
61
+ // Collapsed state
62
+ <div className="flex items-center gap-2 p-3">
63
+ <Save className="size-4 text-neutral-600" />
64
+ <span className="text-xs text-neutral-600 font-medium">
65
+ Unsaved Changes
66
+ </span>
67
+ <Button
68
+ variant="outline"
69
+ size="iconXs"
70
+ className="!rounded-md !border-neutral-200"
71
+ onClick={() => setIsCollapsed(false)}
72
+ >
73
+ <ChevronUp className="text-neutral-400 size-3" />
74
+ </Button>
75
+ </div>
76
+ ) : (
77
+ // Expanded state
78
+ <div className="p-4 max-w-sm w-full">
79
+ <div className="flex items-start gap-3">
80
+ <Save className="size-4 text-neutral-600 translate-y-1.5" />
81
+ <div className="flex-1 min-w-0">
82
+ <div className="flex items-center justify-between mb-1">
83
+ <div className="flex items-center gap-2">
84
+ <p className="font-semibold text-sm text-neutral-800">
85
+ Unsaved Changes
86
+ </p>
87
+ </div>
88
+ <Button
89
+ variant="outline"
90
+ size="iconXs"
91
+ className="!rounded-md !border-neutral-200"
92
+ onClick={() => setIsCollapsed(true)}
93
+ >
94
+ <ChevronDown className="text-neutral-400 size-3" />
95
+ </Button>
96
+ </div>
97
+ <p className="text-xs text-neutral-600 leading-relaxed mb-3">
98
+ You have unsaved changes in your project. Save them to
99
+ preserve your work.
100
+ </p>
101
+ <div className="flex items-center gap-2">
102
+ <Button
103
+ size="xs"
104
+ variant="black"
105
+ className="!pr-3"
106
+ onClick={handleSave}
107
+ disabled={isSaving}
108
+ >
109
+ {isSaving ? (
110
+ <Loading overlay={false} />
111
+ ) : (
112
+ <Save className="size-3" />
113
+ )}
114
+ Save Changes
115
+ </Button>
116
+ <Button
117
+ size="xs"
118
+ variant="outline"
119
+ className="!text-neutral-600 !border-neutral-200"
120
+ disabled={isSaving}
121
+ onClick={onClose}
122
+ >
123
+ Later
124
+ </Button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ )}
130
+ </motion.div>
131
+ </AnimatePresence>
132
+ );
133
+ };
hooks/useAi.ts CHANGED
@@ -13,7 +13,7 @@ import { useUser } from "./useUser";
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 } = useEditor();
17
  const [controller, setController] = useState<AbortController | null>(null);
18
  const [storageProvider, setStorageProvider] = useLocalStorage("provider", "auto");
19
  const [storageModel, setStorageModel] = useLocalStorage("model", MODELS[0].value);
@@ -183,6 +183,7 @@ export const useAi = (onScrollToBottom?: () => void) => {
183
  const newPages = formatPages(contentResponse);
184
  const projectName = contentResponse.match(/<<<<<<< PROJECT_NAME_START ([\s\S]*?) >>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
185
  setPages(newPages);
 
186
  createNewProject(prompt, newPages, projectName);
187
  setPrompts([...prompts, prompt]);
188
 
@@ -307,6 +308,8 @@ export const useAi = (onScrollToBottom?: () => void) => {
307
  ) as HTMLIFrameElement;
308
 
309
  setPages(res.pages);
 
 
310
  setPrompts(
311
  [...prompts, prompt]
312
  )
 
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();
17
  const [controller, setController] = useState<AbortController | null>(null);
18
  const [storageProvider, setStorageProvider] = useLocalStorage("provider", "auto");
19
  const [storageModel, setStorageModel] = useLocalStorage("model", MODELS[0].value);
 
183
  const newPages = formatPages(contentResponse);
184
  const projectName = contentResponse.match(/<<<<<<< PROJECT_NAME_START ([\s\S]*?) >>>>>>> PROJECT_NAME_END/)?.[1]?.trim();
185
  setPages(newPages);
186
+ setLastSavedPages([...newPages]); // Mark initial pages as saved
187
  createNewProject(prompt, newPages, projectName);
188
  setPrompts([...prompts, prompt]);
189
 
 
308
  ) as HTMLIFrameElement;
309
 
310
  setPages(res.pages);
311
+ setLastSavedPages([...res.pages]); // Mark AI changes as saved
312
+ setCommits([res.commit, ...commits]);
313
  setPrompts(
314
  [...prompts, prompt]
315
  )
hooks/useEditor.ts CHANGED
@@ -13,7 +13,7 @@ export const useEditor = (namespace?: string, repoId?: string) => {
13
  const router = useRouter();
14
  const [pagesStorage,, removePagesStorage] = useLocalStorage<Page[]>("pages");
15
 
16
- const { data: project, isLoading: isLoadingProject } = useQuery({
17
  queryKey: ["editor.project"],
18
  queryFn: async () => {
19
  try {
@@ -224,6 +224,84 @@ export const useEditor = (namespace?: string, repoId?: string) => {
224
  uploadFilesMutation.mutate({ files, project });
225
  };
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  useUpdateEffect(() => {
228
  if (namespace && repoId) {
229
  client.invalidateQueries({ queryKey: ["editor.project"] });
@@ -232,6 +310,8 @@ export const useEditor = (namespace?: string, repoId?: string) => {
232
  client.invalidateQueries({ queryKey: ["editor.commits"] });
233
  client.invalidateQueries({ queryKey: ["editor.currentPage"] });
234
  client.invalidateQueries({ queryKey: ["editor.currentCommit"] });
 
 
235
  }
236
  }, [namespace, repoId])
237
 
@@ -253,10 +333,17 @@ export const useEditor = (namespace?: string, repoId?: string) => {
253
  setCurrentTab,
254
  uploadFiles,
255
  commits,
 
256
  currentCommit,
257
  setCurrentCommit,
258
  setProject,
259
  isUploading: uploadFilesMutation.isPending,
260
  globalEditorLoading: uploadFilesMutation.isPending || isLoadingProject,
 
 
 
 
 
 
261
  };
262
  };
 
13
  const router = useRouter();
14
  const [pagesStorage,, removePagesStorage] = useLocalStorage<Page[]>("pages");
15
 
16
+ const { data: project, isFetching: isLoadingProject } = useQuery({
17
  queryKey: ["editor.project"],
18
  queryFn: async () => {
19
  try {
 
224
  uploadFilesMutation.mutate({ files, project });
225
  };
226
 
227
+ // Unsaved changes tracking
228
+ const { data: lastSavedPages = [] } = useQuery<Page[]>({
229
+ queryKey: ["editor.lastSavedPages"],
230
+ queryFn: async () => [],
231
+ refetchOnWindowFocus: false,
232
+ refetchOnReconnect: false,
233
+ refetchOnMount: false,
234
+ initialData: [],
235
+ });
236
+ const setLastSavedPages = (newPages: Page[]) => {
237
+ client.setQueryData(["editor.lastSavedPages"], newPages);
238
+ };
239
+
240
+ const { data: hasUnsavedChanges = false } = useQuery({
241
+ queryKey: ["editor.hasUnsavedChanges"],
242
+ queryFn: async () => false,
243
+ refetchOnWindowFocus: false,
244
+ refetchOnReconnect: false,
245
+ refetchOnMount: false,
246
+ });
247
+ const setHasUnsavedChanges = (hasChanges: boolean) => {
248
+ client.setQueryData(["editor.hasUnsavedChanges"], hasChanges);
249
+ };
250
+
251
+ // Save changes mutation
252
+ const saveChangesMutation = useMutation({
253
+ mutationFn: async ({ pages, project, namespace, repoId }: { pages: Page[]; project: any; namespace?: string; repoId?: string }) => {
254
+ if (!project?.space_id || !namespace || !repoId) {
255
+ throw new Error("Project not found or missing parameters");
256
+ }
257
+
258
+ const response = await api.put(`/me/projects/${namespace}/${repoId}/save`, {
259
+ pages,
260
+ commitTitle: "Manual changes saved"
261
+ });
262
+
263
+ if (!response.data.ok) {
264
+ throw new Error(response.data.message || "Failed to save changes");
265
+ }
266
+
267
+ return response.data;
268
+ },
269
+ onSuccess: (data) => {
270
+ setLastSavedPages([...pages]);
271
+ setHasUnsavedChanges(false);
272
+ if (data.commit) {
273
+ setCommits((prev) => [data.commit, ...prev]);
274
+ }
275
+ },
276
+ });
277
+
278
+ const saveChanges = async () => {
279
+ if (!project || !hasUnsavedChanges || !namespace || !repoId) return;
280
+ return saveChangesMutation.mutateAsync({ pages, project, namespace, repoId });
281
+ };
282
+
283
+ // Check for unsaved changes when pages change
284
+ const checkForUnsavedChanges = () => {
285
+ if (pages.length === 0 || lastSavedPages.length === 0) return;
286
+
287
+ const hasChanges = JSON.stringify(pages) !== JSON.stringify(lastSavedPages);
288
+ setHasUnsavedChanges(hasChanges);
289
+ };
290
+
291
+ // Update last saved pages when project loads
292
+ useUpdateEffect(() => {
293
+ if (project && pages.length > 0 && lastSavedPages.length === 0) {
294
+ setLastSavedPages([...pages]);
295
+ }
296
+ }, [project, pages]);
297
+
298
+ // Check for changes when pages change
299
+ useUpdateEffect(() => {
300
+ if (lastSavedPages.length > 0) {
301
+ checkForUnsavedChanges();
302
+ }
303
+ }, [pages, lastSavedPages]);
304
+
305
  useUpdateEffect(() => {
306
  if (namespace && repoId) {
307
  client.invalidateQueries({ queryKey: ["editor.project"] });
 
310
  client.invalidateQueries({ queryKey: ["editor.commits"] });
311
  client.invalidateQueries({ queryKey: ["editor.currentPage"] });
312
  client.invalidateQueries({ queryKey: ["editor.currentCommit"] });
313
+ client.invalidateQueries({ queryKey: ["editor.lastSavedPages"] });
314
+ client.invalidateQueries({ queryKey: ["editor.hasUnsavedChanges"] });
315
  }
316
  }, [namespace, repoId])
317
 
 
333
  setCurrentTab,
334
  uploadFiles,
335
  commits,
336
+ setCommits,
337
  currentCommit,
338
  setCurrentCommit,
339
  setProject,
340
  isUploading: uploadFilesMutation.isPending,
341
  globalEditorLoading: uploadFilesMutation.isPending || isLoadingProject,
342
+ // Unsaved changes functionality
343
+ hasUnsavedChanges,
344
+ saveChanges,
345
+ isSaving: saveChangesMutation.isPending,
346
+ lastSavedPages,
347
+ setLastSavedPages,
348
  };
349
  };
package-lock.json CHANGED
@@ -32,6 +32,7 @@
32
  "classnames": "^2.5.1",
33
  "clsx": "^2.1.1",
34
  "date-fns": "^4.1.0",
 
35
  "log4js": "^6.9.1",
36
  "log4js-json-layout": "^2.2.3",
37
  "lucide-react": "^0.542.0",
@@ -3872,6 +3873,33 @@
3872
  "node": ">= 6"
3873
  }
3874
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3875
  "node_modules/fs-extra": {
3876
  "version": "8.1.0",
3877
  "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -4827,6 +4855,21 @@
4827
  "url": "https://opencollective.com/mongoose"
4828
  }
4829
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4830
  "node_modules/mpath": {
4831
  "version": "0.9.0",
4832
  "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
 
32
  "classnames": "^2.5.1",
33
  "clsx": "^2.1.1",
34
  "date-fns": "^4.1.0",
35
+ "framer-motion": "^12.23.22",
36
  "log4js": "^6.9.1",
37
  "log4js-json-layout": "^2.2.3",
38
  "lucide-react": "^0.542.0",
 
3873
  "node": ">= 6"
3874
  }
3875
  },
3876
+ "node_modules/framer-motion": {
3877
+ "version": "12.23.22",
3878
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz",
3879
+ "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==",
3880
+ "license": "MIT",
3881
+ "dependencies": {
3882
+ "motion-dom": "^12.23.21",
3883
+ "motion-utils": "^12.23.6",
3884
+ "tslib": "^2.4.0"
3885
+ },
3886
+ "peerDependencies": {
3887
+ "@emotion/is-prop-valid": "*",
3888
+ "react": "^18.0.0 || ^19.0.0",
3889
+ "react-dom": "^18.0.0 || ^19.0.0"
3890
+ },
3891
+ "peerDependenciesMeta": {
3892
+ "@emotion/is-prop-valid": {
3893
+ "optional": true
3894
+ },
3895
+ "react": {
3896
+ "optional": true
3897
+ },
3898
+ "react-dom": {
3899
+ "optional": true
3900
+ }
3901
+ }
3902
+ },
3903
  "node_modules/fs-extra": {
3904
  "version": "8.1.0",
3905
  "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
 
4855
  "url": "https://opencollective.com/mongoose"
4856
  }
4857
  },
4858
+ "node_modules/motion-dom": {
4859
+ "version": "12.23.21",
4860
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz",
4861
+ "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==",
4862
+ "license": "MIT",
4863
+ "dependencies": {
4864
+ "motion-utils": "^12.23.6"
4865
+ }
4866
+ },
4867
+ "node_modules/motion-utils": {
4868
+ "version": "12.23.6",
4869
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
4870
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
4871
+ "license": "MIT"
4872
+ },
4873
  "node_modules/mpath": {
4874
  "version": "0.9.0",
4875
  "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
package.json CHANGED
@@ -32,6 +32,7 @@
32
  "classnames": "^2.5.1",
33
  "clsx": "^2.1.1",
34
  "date-fns": "^4.1.0",
 
35
  "log4js": "^6.9.1",
36
  "log4js-json-layout": "^2.2.3",
37
  "lucide-react": "^0.542.0",
 
32
  "classnames": "^2.5.1",
33
  "clsx": "^2.1.1",
34
  "date-fns": "^4.1.0",
35
+ "framer-motion": "^12.23.22",
36
  "log4js": "^6.9.1",
37
  "log4js-json-layout": "^2.2.3",
38
  "lucide-react": "^0.542.0",