Spaces:
Running
Running
manual saves
Browse files- app/api/ask/route.ts +5 -1
- app/api/me/projects/[namespace]/[repoId]/commits/[commitId]/promote/route.ts +56 -11
- app/api/me/projects/[namespace]/[repoId]/route.ts +1 -10
- app/api/me/projects/[namespace]/[repoId]/save/route.ts +64 -0
- components/editor/header/index.tsx +0 -1
- components/editor/history/index.tsx +14 -7
- components/editor/index.tsx +25 -1
- components/editor/pages/index.tsx +1 -1
- components/editor/pages/page.tsx +2 -2
- components/editor/preview/index.tsx +154 -240
- components/editor/save-changes-popup/index.tsx +133 -0
- hooks/useAi.ts +4 -1
- hooks/useEditor.ts +88 -1
- package-lock.json +43 -0
- package.json +1 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
| 112 |
|
| 113 |
return NextResponse.json(
|
| 114 |
{
|
| 115 |
ok: true,
|
| 116 |
message: "Version promoted successfully",
|
| 117 |
promotedCommit: commitId,
|
| 118 |
-
|
| 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("
|
| 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":
|
|
|
|
|
|
|
| 48 |
}
|
| 49 |
)}
|
| 50 |
>
|
|
@@ -64,21 +66,26 @@ export function History() {
|
|
| 64 |
hour12: false,
|
| 65 |
})}
|
| 66 |
</p>
|
| 67 |
-
{currentCommit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
<Button
|
| 69 |
variant="link"
|
| 70 |
size="xss"
|
| 71 |
className="text-gray-400 hover:text-gray-200"
|
| 72 |
onClick={() => {
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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-
|
| 25 |
{
|
| 26 |
-
"bg-neutral-900 !text-white
|
| 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 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 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 |
-
?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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",
|