Spaces:
Running
Running
Optimize code editing
#1
by
CarlenBai
- opened
- package.json +2 -0
- pnpm-lock.yaml +51 -0
- src/app/layout.tsx +9 -6
- src/app/providers.tsx +21 -0
- src/components/app-container.tsx +74 -24
- src/components/code-editor.tsx +96 -24
- src/components/preview.tsx +66 -3
- src/components/prompt-input.tsx +6 -0
- src/components/share-dialog.tsx +90 -0
- src/lib/sharelink.ts +47 -0
- src/lib/utils.ts +41 -0
package.json
CHANGED
@@ -47,8 +47,10 @@
|
|
47 |
"embla-carousel-react": "8.5.1",
|
48 |
"input-otp": "1.4.1",
|
49 |
"lucide-react": "^0.454.0",
|
|
|
50 |
"next": "15.2.4",
|
51 |
"next-themes": "^0.4.4",
|
|
|
52 |
"react": "^19",
|
53 |
"react-day-picker": "8.10.1",
|
54 |
"react-dom": "^19",
|
|
|
47 |
"embla-carousel-react": "8.5.1",
|
48 |
"input-otp": "1.4.1",
|
49 |
"lucide-react": "^0.454.0",
|
50 |
+
"nanoid": "^5.1.5",
|
51 |
"next": "15.2.4",
|
52 |
"next-themes": "^0.4.4",
|
53 |
+
"posthog-js": "^1.249.2",
|
54 |
"react": "^19",
|
55 |
"react-day-picker": "8.10.1",
|
56 |
"react-dom": "^19",
|
pnpm-lock.yaml
CHANGED
@@ -122,12 +122,18 @@ importers:
|
|
122 |
lucide-react:
|
123 |
specifier: ^0.454.0
|
124 |
version: 0.454.0([email protected])
|
|
|
|
|
|
|
125 |
next:
|
126 |
specifier: 15.2.4
|
127 |
version: 15.2.4([email protected]([email protected]))([email protected])
|
128 |
next-themes:
|
129 |
specifier: ^0.4.4
|
130 |
version: 0.4.6([email protected]([email protected]))([email protected])
|
|
|
|
|
|
|
131 |
react:
|
132 |
specifier: ^19
|
133 |
version: 19.1.0
|
@@ -1400,6 +1406,9 @@ packages:
|
|
1400 |
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
1401 |
engines: {node: '>= 6'}
|
1402 |
|
|
|
|
|
|
|
1403 | |
1404 |
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
1405 |
engines: {node: '>= 8'}
|
@@ -1521,6 +1530,9 @@ packages:
|
|
1521 | |
1522 |
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
1523 |
|
|
|
|
|
|
|
1524 | |
1525 |
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
1526 |
engines: {node: '>=8'}
|
@@ -1656,6 +1668,11 @@ packages:
|
|
1656 |
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
1657 |
hasBin: true
|
1658 |
|
|
|
|
|
|
|
|
|
|
|
1659 | |
1660 |
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
1661 |
peerDependencies:
|
@@ -1776,6 +1793,20 @@ packages:
|
|
1776 |
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
|
1777 |
engines: {node: ^10 || ^12 || >=14}
|
1778 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1779 | |
1780 |
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
1781 |
|
@@ -2049,6 +2080,9 @@ packages:
|
|
2049 | |
2050 |
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
2051 |
|
|
|
|
|
|
|
2052 | |
2053 |
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
2054 |
engines: {node: '>= 8'}
|
@@ -3243,6 +3277,8 @@ snapshots:
|
|
3243 |
|
3244 | |
3245 |
|
|
|
|
|
3246 | |
3247 |
dependencies:
|
3248 |
path-key: 3.1.1
|
@@ -3347,6 +3383,8 @@ snapshots:
|
|
3347 |
dependencies:
|
3348 |
reusify: 1.1.0
|
3349 |
|
|
|
|
|
3350 | |
3351 |
dependencies:
|
3352 |
to-regex-range: 5.0.1
|
@@ -3463,6 +3501,8 @@ snapshots:
|
|
3463 |
|
3464 | |
3465 |
|
|
|
|
|
3466 | |
3467 |
dependencies:
|
3468 |
react: 19.1.0
|
@@ -3565,6 +3605,15 @@ snapshots:
|
|
3565 |
picocolors: 1.1.1
|
3566 |
source-map-js: 1.2.1
|
3567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3568 | |
3569 |
dependencies:
|
3570 |
loose-envify: 1.4.0
|
@@ -3878,6 +3927,8 @@ snapshots:
|
|
3878 |
d3-time: 3.1.0
|
3879 |
d3-timer: 3.0.1
|
3880 |
|
|
|
|
|
3881 | |
3882 |
dependencies:
|
3883 |
isexe: 2.0.0
|
|
|
122 |
lucide-react:
|
123 |
specifier: ^0.454.0
|
124 |
version: 0.454.0([email protected])
|
125 |
+
nanoid:
|
126 |
+
specifier: ^5.1.5
|
127 |
+
version: 5.1.5
|
128 |
next:
|
129 |
specifier: 15.2.4
|
130 |
version: 15.2.4([email protected]([email protected]))([email protected])
|
131 |
next-themes:
|
132 |
specifier: ^0.4.4
|
133 |
version: 0.4.6([email protected]([email protected]))([email protected])
|
134 |
+
posthog-js:
|
135 |
+
specifier: ^1.249.2
|
136 |
+
version: 1.249.2
|
137 |
react:
|
138 |
specifier: ^19
|
139 |
version: 19.1.0
|
|
|
1406 |
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
1407 |
engines: {node: '>= 6'}
|
1408 |
|
1409 | |
1410 |
+
resolution: {integrity: sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g==}
|
1411 |
+
|
1412 | |
1413 |
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
1414 |
engines: {node: '>= 8'}
|
|
|
1530 | |
1531 |
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
1532 |
|
1533 | |
1534 |
+
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
1535 |
+
|
1536 | |
1537 |
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
1538 |
engines: {node: '>=8'}
|
|
|
1668 |
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
1669 |
hasBin: true
|
1670 |
|
1671 | |
1672 |
+
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
|
1673 |
+
engines: {node: ^18 || >=20}
|
1674 |
+
hasBin: true
|
1675 |
+
|
1676 | |
1677 |
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
1678 |
peerDependencies:
|
|
|
1793 |
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
|
1794 |
engines: {node: ^10 || ^12 || >=14}
|
1795 |
|
1796 | |
1797 |
+
resolution: {integrity: sha512-OMXCO/IfcJBjYTuebVynMbp8Kq329yKEQSCAnkqLmi8W2Bt5bi7S5xxMwDM3Pm7818Uh0C40XMG3rAtYozId6Q==}
|
1798 |
+
peerDependencies:
|
1799 |
+
'@rrweb/types': 2.0.0-alpha.17
|
1800 |
+
rrweb-snapshot: 2.0.0-alpha.17
|
1801 |
+
peerDependenciesMeta:
|
1802 |
+
'@rrweb/types':
|
1803 |
+
optional: true
|
1804 |
+
rrweb-snapshot:
|
1805 |
+
optional: true
|
1806 |
+
|
1807 | |
1808 |
+
resolution: {integrity: sha512-1nMfdFjucm5hKvq0IClqZwK4FJkGXhRrQstOQ3P4vp8HxKrJEMFcY6RdBRVTdfQS/UlnX6gfbPuTvaqx/bDoeQ==}
|
1809 |
+
|
1810 | |
1811 |
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
1812 |
|
|
|
2080 | |
2081 |
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
2082 |
|
2083 | |
2084 |
+
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
|
2085 |
+
|
2086 | |
2087 |
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
2088 |
engines: {node: '>= 8'}
|
|
|
3277 |
|
3278 | |
3279 |
|
3280 |
+
[email protected]: {}
|
3281 |
+
|
3282 | |
3283 |
dependencies:
|
3284 |
path-key: 3.1.1
|
|
|
3383 |
dependencies:
|
3384 |
reusify: 1.1.0
|
3385 |
|
3386 |
+
[email protected]: {}
|
3387 |
+
|
3388 | |
3389 |
dependencies:
|
3390 |
to-regex-range: 5.0.1
|
|
|
3501 |
|
3502 | |
3503 |
|
3504 |
+
[email protected]: {}
|
3505 |
+
|
3506 | |
3507 |
dependencies:
|
3508 |
react: 19.1.0
|
|
|
3605 |
picocolors: 1.1.1
|
3606 |
source-map-js: 1.2.1
|
3607 |
|
3608 | |
3609 |
+
dependencies:
|
3610 |
+
core-js: 3.42.0
|
3611 |
+
fflate: 0.4.8
|
3612 |
+
preact: 10.26.8
|
3613 |
+
web-vitals: 4.2.4
|
3614 |
+
|
3615 |
+
[email protected]: {}
|
3616 |
+
|
3617 | |
3618 |
dependencies:
|
3619 |
loose-envify: 1.4.0
|
|
|
3927 |
d3-time: 3.1.0
|
3928 |
d3-timer: 3.0.1
|
3929 |
|
3930 |
+
[email protected]: {}
|
3931 |
+
|
3932 | |
3933 |
dependencies:
|
3934 |
isexe: 2.0.0
|
src/app/layout.tsx
CHANGED
@@ -6,6 +6,7 @@ import { ThemeProvider } from "@/components/theme-provider"
|
|
6 |
import { TooltipProvider } from "@/components/ui/tooltip"
|
7 |
import { Toaster } from "sonner"
|
8 |
import { ModelProvider } from "@/lib/contexts/model-context"
|
|
|
9 |
|
10 |
const inter = Inter({ subsets: ["latin"] })
|
11 |
|
@@ -22,12 +23,14 @@ export default function RootLayout({
|
|
22 |
return (
|
23 |
<html lang="en" suppressHydrationWarning>
|
24 |
<body className={inter.className} suppressHydrationWarning>
|
25 |
-
<
|
26 |
-
<
|
27 |
-
<
|
28 |
-
|
29 |
-
|
30 |
-
|
|
|
|
|
31 |
</body>
|
32 |
</html>
|
33 |
)
|
|
|
6 |
import { TooltipProvider } from "@/components/ui/tooltip"
|
7 |
import { Toaster } from "sonner"
|
8 |
import { ModelProvider } from "@/lib/contexts/model-context"
|
9 |
+
import { PostHogProvider } from './providers'
|
10 |
|
11 |
const inter = Inter({ subsets: ["latin"] })
|
12 |
|
|
|
23 |
return (
|
24 |
<html lang="en" suppressHydrationWarning>
|
25 |
<body className={inter.className} suppressHydrationWarning>
|
26 |
+
<PostHogProvider>
|
27 |
+
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
28 |
+
<ModelProvider>
|
29 |
+
<TooltipProvider>{children}</TooltipProvider>
|
30 |
+
<Toaster richColors />
|
31 |
+
</ModelProvider>
|
32 |
+
</ThemeProvider>
|
33 |
+
</PostHogProvider>
|
34 |
</body>
|
35 |
</html>
|
36 |
)
|
src/app/providers.tsx
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// app/providers.tsx
|
2 |
+
'use client'
|
3 |
+
|
4 |
+
import posthog from 'posthog-js'
|
5 |
+
import { PostHogProvider as PHProvider } from 'posthog-js/react'
|
6 |
+
import { useEffect } from 'react'
|
7 |
+
|
8 |
+
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
9 |
+
useEffect(() => {
|
10 |
+
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY || '', {
|
11 |
+
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
12 |
+
defaults: '2025-05-24',
|
13 |
+
})
|
14 |
+
}, [])
|
15 |
+
|
16 |
+
return (
|
17 |
+
<PHProvider client={posthog}>
|
18 |
+
{children}
|
19 |
+
</PHProvider>
|
20 |
+
)
|
21 |
+
}
|
src/components/app-container.tsx
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
"use client"
|
2 |
|
3 |
import { useState, useRef, useEffect } from "react"
|
|
|
4 |
import { CodeEditor } from "./code-editor"
|
5 |
import { Preview } from "./preview"
|
6 |
import { PromptInput } from "./prompt-input"
|
@@ -187,6 +188,30 @@ export function AppContainer() {
|
|
187 |
latestVersionIdRef.current = null;
|
188 |
}
|
189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
190 |
return (
|
191 |
<div className="flex flex-col h-screen bg-novita-dark text-white">
|
192 |
<Header
|
@@ -195,32 +220,57 @@ export function AppContainer() {
|
|
195 |
onClearAll={handleClearAll}
|
196 |
/>
|
197 |
<div className="flex flex-1 overflow-hidden relative">
|
198 |
-
<
|
199 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
|
201 |
-
<
|
202 |
-
|
203 |
-
|
204 |
-
|
|
|
|
|
|
|
|
|
205 |
|
206 |
-
<
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
/>
|
223 |
-
</div>
|
224 |
|
225 |
{initialLoading && (
|
226 |
<div className="absolute inset-0 bg-novita-dark/80 backdrop-blur-sm flex items-center justify-center z-[999]">
|
|
|
1 |
"use client"
|
2 |
|
3 |
import { useState, useRef, useEffect } from "react"
|
4 |
+
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"
|
5 |
import { CodeEditor } from "./code-editor"
|
6 |
import { Preview } from "./preview"
|
7 |
import { PromptInput } from "./prompt-input"
|
|
|
188 |
latestVersionIdRef.current = null;
|
189 |
}
|
190 |
|
191 |
+
const handleManualCodeEdit = (newCode: string) => {
|
192 |
+
setCode(newCode);
|
193 |
+
|
194 |
+
if (currentVersionId) {
|
195 |
+
updateVersionWithCode(newCode);
|
196 |
+
}
|
197 |
+
};
|
198 |
+
|
199 |
+
const updateVersionWithCode = (editedCode: string) => { // Get existing versions
|
200 |
+
const storedVersions = localStorage.getItem('novita-versions');
|
201 |
+
if (!storedVersions || !currentVersionId) return;
|
202 |
+
|
203 |
+
let versions: Version[] = JSON.parse(storedVersions);
|
204 |
+
|
205 |
+
const updatedVersions = versions.map(version => {
|
206 |
+
if (version.id === currentVersionId) {
|
207 |
+
return { ...version, code: editedCode };
|
208 |
+
}
|
209 |
+
return version;
|
210 |
+
});
|
211 |
+
|
212 |
+
localStorage.setItem('novita-versions', JSON.stringify(updatedVersions));
|
213 |
+
};
|
214 |
+
|
215 |
return (
|
216 |
<div className="flex flex-col h-screen bg-novita-dark text-white">
|
217 |
<Header
|
|
|
220 |
onClearAll={handleClearAll}
|
221 |
/>
|
222 |
<div className="flex flex-1 overflow-hidden relative">
|
223 |
+
<ResizablePanelGroup direction="horizontal" className="h-full">
|
224 |
+
<ResizablePanel
|
225 |
+
defaultSize={33}
|
226 |
+
minSize={20}
|
227 |
+
maxSize={60}
|
228 |
+
className="flex flex-col relative"
|
229 |
+
>
|
230 |
+
<CodeEditor
|
231 |
+
code={code}
|
232 |
+
isLoading={loading}
|
233 |
+
onCodeChange={handleManualCodeEdit}
|
234 |
+
/>
|
235 |
+
|
236 |
+
<AuthErrorPopup
|
237 |
+
show={showAuthError}
|
238 |
+
onClose={() => setShowAuthError(false)}
|
239 |
+
/>
|
240 |
+
|
241 |
+
<PromptInput
|
242 |
+
onSubmit={handlePromptSubmit}
|
243 |
+
isLoading={loading}
|
244 |
+
initialPrompt={prompt}
|
245 |
+
onImproveError={setImproveError}
|
246 |
+
/>
|
247 |
+
</ResizablePanel>
|
248 |
|
249 |
+
<ResizableHandle
|
250 |
+
withHandle={false}
|
251 |
+
className="w-1 bg-novita-gray/20 hover:bg-novita-gray/40 transition-colors duration-200 relative group cursor-col-resize"
|
252 |
+
>
|
253 |
+
<div className="absolute inset-y-0 left-1/2 w-3 -translate-x-1/2 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
254 |
+
<div className="w-0.5 h-8 bg-novita-gray/60 rounded-full" />
|
255 |
+
</div>
|
256 |
+
</ResizableHandle>
|
257 |
|
258 |
+
<ResizablePanel
|
259 |
+
defaultSize={67}
|
260 |
+
minSize={40}
|
261 |
+
className="flex flex-col"
|
262 |
+
>
|
263 |
+
<Preview
|
264 |
+
ref={previewRef}
|
265 |
+
initialHtml={code}
|
266 |
+
onCodeChange={handleCodeChange}
|
267 |
+
onLoadingChange={handleLoadingChange}
|
268 |
+
onAuthErrorChange={setShowAuthError}
|
269 |
+
onErrorChange={setGenerationError}
|
270 |
+
currentVersion={currentVersionId || undefined}
|
271 |
+
/>
|
272 |
+
</ResizablePanel>
|
273 |
+
</ResizablePanelGroup>
|
|
|
|
|
274 |
|
275 |
{initialLoading && (
|
276 |
<div className="absolute inset-0 bg-novita-dark/80 backdrop-blur-sm flex items-center justify-center z-[999]">
|
src/components/code-editor.tsx
CHANGED
@@ -1,51 +1,123 @@
|
|
1 |
"use client"
|
2 |
|
3 |
-
import { useEffect, useRef, useState } from "react";
|
|
|
4 |
|
5 |
interface CodeEditorProps {
|
6 |
code: string;
|
7 |
isLoading?: boolean;
|
|
|
8 |
}
|
9 |
|
10 |
-
export function CodeEditor({ code, isLoading = false }: CodeEditorProps) {
|
11 |
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
12 |
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
|
13 |
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
const isNearBottom = scrollHeight - scrollTop - clientHeight < 30;
|
21 |
setIsAtBottom(isNearBottom);
|
22 |
};
|
23 |
|
24 |
useEffect(() => {
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
|
|
29 |
}
|
30 |
-
}, []);
|
31 |
|
32 |
-
|
33 |
-
|
34 |
-
if (
|
35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
36 |
}
|
37 |
-
}
|
|
|
|
|
38 |
|
39 |
return (
|
40 |
-
<div className="flex-1 overflow-
|
41 |
<div
|
42 |
ref={containerRef}
|
43 |
-
className="code-area
|
44 |
>
|
45 |
-
<
|
46 |
-
{
|
47 |
-
|
48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
</div>
|
50 |
</div>
|
51 |
)
|
|
|
1 |
"use client"
|
2 |
|
3 |
+
import { useEffect, useRef, useState, useCallback } from "react";
|
4 |
+
import { debounce, highlightHTML } from "@/lib/utils";
|
5 |
|
6 |
interface CodeEditorProps {
|
7 |
code: string;
|
8 |
isLoading?: boolean;
|
9 |
+
onCodeChange?: (code: string) => void;
|
10 |
}
|
11 |
|
12 |
+
export function CodeEditor({ code, isLoading = false, onCodeChange }: CodeEditorProps) {
|
13 |
const containerRef = useRef<HTMLDivElement>(null);
|
14 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
15 |
+
const highlightRef = useRef<HTMLDivElement>(null);
|
16 |
const [isAtBottom, setIsAtBottom] = useState(true);
|
17 |
+
const [localCode, setLocalCode] = useState(code);
|
18 |
|
19 |
+
const debouncedSave = useCallback(
|
20 |
+
debounce((newCode: string) => {
|
21 |
+
if (onCodeChange) {
|
22 |
+
onCodeChange(newCode);
|
23 |
+
}
|
24 |
+
}, 1000),
|
25 |
+
[onCodeChange]
|
26 |
+
);
|
27 |
+
|
28 |
+
useEffect(() => {
|
29 |
+
setLocalCode(code);
|
30 |
+
}, [code]);
|
31 |
+
|
32 |
+
const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
33 |
+
const newCode = e.target.value;
|
34 |
+
setLocalCode(newCode);
|
35 |
+
debouncedSave(newCode);
|
36 |
+
};
|
37 |
+
|
38 |
+
// Sync scroll between textarea and highlight
|
39 |
+
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
40 |
+
if (highlightRef.current && textareaRef.current) {
|
41 |
+
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
42 |
+
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
43 |
+
}
|
44 |
+
|
45 |
+
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
46 |
const isNearBottom = scrollHeight - scrollTop - clientHeight < 30;
|
47 |
setIsAtBottom(isNearBottom);
|
48 |
};
|
49 |
|
50 |
useEffect(() => {
|
51 |
+
if (isAtBottom && textareaRef.current && document.activeElement !== textareaRef.current) {
|
52 |
+
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
|
53 |
+
if (highlightRef.current) {
|
54 |
+
highlightRef.current.scrollTop = highlightRef.current.scrollHeight;
|
55 |
+
}
|
56 |
}
|
57 |
+
}, [localCode, isLoading, isAtBottom]);
|
58 |
|
59 |
+
// Handle tab key for indentation
|
60 |
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
61 |
+
if (e.key === 'Tab') {
|
62 |
+
e.preventDefault();
|
63 |
+
const textarea = e.currentTarget;
|
64 |
+
const start = textarea.selectionStart;
|
65 |
+
const end = textarea.selectionEnd;
|
66 |
+
|
67 |
+
const newValue = localCode.substring(0, start) + ' ' + localCode.substring(end);
|
68 |
+
setLocalCode(newValue);
|
69 |
+
debouncedSave(newValue);
|
70 |
+
|
71 |
+
setTimeout(() => {
|
72 |
+
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
73 |
+
}, 0);
|
74 |
}
|
75 |
+
};
|
76 |
+
|
77 |
+
const highlightedCode = highlightHTML(localCode);
|
78 |
|
79 |
return (
|
80 |
+
<div className="flex-1 overflow-hidden p-4 pr-2">
|
81 |
<div
|
82 |
ref={containerRef}
|
83 |
+
className="code-area rounded-md font-mono text-sm h-full border border-novita-gray/20 relative overflow-hidden"
|
84 |
>
|
85 |
+
<div className="relative h-full">
|
86 |
+
{/* Syntax highlighted background - always visible */}
|
87 |
+
<div
|
88 |
+
ref={highlightRef}
|
89 |
+
className="absolute inset-0 overflow-auto p-4 pointer-events-none whitespace-pre-wrap font-mono text-sm leading-relaxed"
|
90 |
+
style={{
|
91 |
+
fontSize: '0.875rem',
|
92 |
+
lineHeight: '1.5',
|
93 |
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
|
94 |
+
color: '#d4d4d4'
|
95 |
+
}}
|
96 |
+
dangerouslySetInnerHTML={{ __html: highlightedCode + '\n' }}
|
97 |
+
/>
|
98 |
+
|
99 |
+
{/* Editable textarea - always transparent */}
|
100 |
+
<textarea
|
101 |
+
ref={textareaRef}
|
102 |
+
value={localCode}
|
103 |
+
onChange={handleCodeChange}
|
104 |
+
onScroll={handleScroll}
|
105 |
+
onKeyDown={handleKeyDown}
|
106 |
+
disabled={isLoading}
|
107 |
+
className="absolute inset-0 w-full h-full bg-transparent text-transparent caret-white resize-none outline-none whitespace-pre-wrap font-mono text-sm leading-relaxed p-4 selection:bg-blue-500/30"
|
108 |
+
style={{
|
109 |
+
fontSize: '0.875rem',
|
110 |
+
lineHeight: '1.5',
|
111 |
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
|
112 |
+
}}
|
113 |
+
placeholder="Your generated code will appear here..."
|
114 |
+
spellCheck={false}
|
115 |
+
/>
|
116 |
+
</div>
|
117 |
+
|
118 |
+
{isLoading && (
|
119 |
+
<span className="absolute bottom-4 right-4 inline-block h-4 w-2 bg-white/70 animate-pulse z-10"></span>
|
120 |
+
)}
|
121 |
</div>
|
122 |
</div>
|
123 |
)
|
src/components/preview.tsx
CHANGED
@@ -2,11 +2,14 @@
|
|
2 |
|
3 |
import { useState, forwardRef, useImperativeHandle, useEffect, useRef } from "react"
|
4 |
import { DEFAULT_HTML } from "@/lib/constants"
|
5 |
-
import { PreviewRef
|
6 |
import { MinimizeIcon, MaximizeIcon, DownloadIcon, RefreshIcon } from "./ui/icons"
|
7 |
import { useModel } from "@/lib/contexts/model-context"
|
8 |
-
import { Loader2 } from "lucide-react"
|
9 |
import { cn } from "@/lib/utils"
|
|
|
|
|
|
|
10 |
|
11 |
interface PreviewProps {
|
12 |
initialHtml?: string;
|
@@ -28,6 +31,9 @@ export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
|
|
28 |
const [error, setError] = useState<string | null>(null);
|
29 |
const [showAuthError, setShowAuthError] = useState(false);
|
30 |
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
|
|
|
31 |
const { selectedModelId } = useModel();
|
32 |
const renderCount = useRef(0);
|
33 |
const headUpdated = useRef(false);
|
@@ -132,6 +138,8 @@ export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
|
|
132 |
// Only include html in the request if it's not DEFAULT_HTML
|
133 |
const isDefaultHtml = initialHtml === DEFAULT_HTML;
|
134 |
|
|
|
|
|
135 |
const response = await fetch('/api/generate-code', {
|
136 |
method: 'POST',
|
137 |
headers: {
|
@@ -147,6 +155,7 @@ export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
|
|
147 |
});
|
148 |
|
149 |
if (!response.ok) {
|
|
|
150 |
// Check specifically for 401 error (authentication required)
|
151 |
if (response.status === 401 || response.status === 403) {
|
152 |
try {
|
@@ -244,6 +253,7 @@ export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
|
|
244 |
}
|
245 |
} catch (err) {
|
246 |
const errorMessage = (err as Error).message || 'An error occurred while generating code';
|
|
|
247 |
setError(errorMessage);
|
248 |
if (onErrorChange) {
|
249 |
onErrorChange(errorMessage);
|
@@ -257,8 +267,40 @@ export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
|
|
257 |
}
|
258 |
};
|
259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
260 |
return (
|
261 |
-
<div className={`${isFullscreen ? 'fixed inset-0 z-10 bg-novita-dark' : 'h-full'} p-4`}>
|
262 |
{ isPartialGenerating && (
|
263 |
<div className="w-full bg-slate-50 border-b border-slate-200 py-2 px-4">
|
264 |
<div className="container mx-auto flex items-center justify-center">
|
@@ -271,6 +313,21 @@ export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
|
|
271 |
)}
|
272 |
<div className="bg-white text-black h-full overflow-hidden relative isolation-auto">
|
273 |
<div className="absolute top-3 right-3 flex gap-2 z-[100]">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
274 |
<button
|
275 |
onClick={refreshPreview}
|
276 |
className="bg-novita-gray/90 text-white p-2 rounded-md shadow-md hover:bg-novita-gray/70 transition-colors flex items-center justify-center"
|
@@ -305,6 +362,12 @@ export const Preview = forwardRef<PreviewRef, PreviewProps>(function Preview(
|
|
305 |
title="Preview"
|
306 |
/>
|
307 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
308 |
</div>
|
309 |
)
|
310 |
});
|
|
|
2 |
|
3 |
import { useState, forwardRef, useImperativeHandle, useEffect, useRef } from "react"
|
4 |
import { DEFAULT_HTML } from "@/lib/constants"
|
5 |
+
import { PreviewRef } from "@/lib/types"
|
6 |
import { MinimizeIcon, MaximizeIcon, DownloadIcon, RefreshIcon } from "./ui/icons"
|
7 |
import { useModel } from "@/lib/contexts/model-context"
|
8 |
+
import { Loader2, Share2 } from "lucide-react"
|
9 |
import { cn } from "@/lib/utils"
|
10 |
+
import { generateShareLink } from "@/lib/sharelink"
|
11 |
+
import { ShareDialog } from "./share-dialog"
|
12 |
+
import posthog from 'posthog-js'
|
13 |
|
14 |
interface PreviewProps {
|
15 |
initialHtml?: string;
|
|
|
31 |
const [error, setError] = useState<string | null>(null);
|
32 |
const [showAuthError, setShowAuthError] = useState(false);
|
33 |
const [refreshKey, setRefreshKey] = useState(0);
|
34 |
+
const [isSharing, setIsSharing] = useState(false);
|
35 |
+
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
36 |
+
const [shareUrl, setShareUrl] = useState<string>("");
|
37 |
const { selectedModelId } = useModel();
|
38 |
const renderCount = useRef(0);
|
39 |
const headUpdated = useRef(false);
|
|
|
138 |
// Only include html in the request if it's not DEFAULT_HTML
|
139 |
const isDefaultHtml = initialHtml === DEFAULT_HTML;
|
140 |
|
141 |
+
posthog.capture("Generate code", {"model": selectedModelId});
|
142 |
+
|
143 |
const response = await fetch('/api/generate-code', {
|
144 |
method: 'POST',
|
145 |
headers: {
|
|
|
155 |
});
|
156 |
|
157 |
if (!response.ok) {
|
158 |
+
posthog.capture("Generate code", {"type": "failed", "model": selectedModelId, "status": response.status});
|
159 |
// Check specifically for 401 error (authentication required)
|
160 |
if (response.status === 401 || response.status === 403) {
|
161 |
try {
|
|
|
253 |
}
|
254 |
} catch (err) {
|
255 |
const errorMessage = (err as Error).message || 'An error occurred while generating code';
|
256 |
+
posthog.capture("Generate code", {"type": "failed", "model": selectedModelId, "error": errorMessage});
|
257 |
setError(errorMessage);
|
258 |
if (onErrorChange) {
|
259 |
onErrorChange(errorMessage);
|
|
|
267 |
}
|
268 |
};
|
269 |
|
270 |
+
const handleShare = async () => {
|
271 |
+
if (!html) {
|
272 |
+
setError("No HTML content to share");
|
273 |
+
return;
|
274 |
+
}
|
275 |
+
|
276 |
+
setIsSharing(true);
|
277 |
+
setError(null);
|
278 |
+
|
279 |
+
try {
|
280 |
+
const uploadedUrl = await generateShareLink(html);
|
281 |
+
setShareUrl(uploadedUrl);
|
282 |
+
setShareDialogOpen(true);
|
283 |
+
} catch (err) {
|
284 |
+
const errorMessage = (err as Error).message || 'Failed to share HTML';
|
285 |
+
setError(errorMessage);
|
286 |
+
if (onErrorChange) {
|
287 |
+
onErrorChange(errorMessage);
|
288 |
+
}
|
289 |
+
console.error('Error sharing HTML:', err);
|
290 |
+
} finally {
|
291 |
+
setIsSharing(false);
|
292 |
+
}
|
293 |
+
};
|
294 |
+
|
295 |
+
const handleShareDialogClose = (open: boolean) => {
|
296 |
+
setShareDialogOpen(open);
|
297 |
+
if (!open) {
|
298 |
+
setShareUrl("");
|
299 |
+
}
|
300 |
+
};
|
301 |
+
|
302 |
return (
|
303 |
+
<div className={`${isFullscreen ? 'fixed inset-0 z-10 bg-novita-dark' : 'h-full'} p-4 pl-2`}>
|
304 |
{ isPartialGenerating && (
|
305 |
<div className="w-full bg-slate-50 border-b border-slate-200 py-2 px-4">
|
306 |
<div className="container mx-auto flex items-center justify-center">
|
|
|
313 |
)}
|
314 |
<div className="bg-white text-black h-full overflow-hidden relative isolation-auto">
|
315 |
<div className="absolute top-3 right-3 flex gap-2 z-[100]">
|
316 |
+
<button
|
317 |
+
onClick={handleShare}
|
318 |
+
disabled={isSharing || !html}
|
319 |
+
className="bg-novita-gray/90 text-white p-2 px-3 text-xs rounded-md shadow-md hover:bg-novita-gray/70 transition-colors flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
|
320 |
+
aria-label="Share Link"
|
321 |
+
title="Share Link"
|
322 |
+
>
|
323 |
+
{isSharing ? (
|
324 |
+
<Loader2 className="h-3 w-3 mr-2 animate-spin" />
|
325 |
+
) : (
|
326 |
+
<Share2 className="h-3 w-3 mr-2" />
|
327 |
+
)}
|
328 |
+
{isSharing ? 'Sharing...' : 'Share Link'}
|
329 |
+
</button>
|
330 |
+
|
331 |
<button
|
332 |
onClick={refreshPreview}
|
333 |
className="bg-novita-gray/90 text-white p-2 rounded-md shadow-md hover:bg-novita-gray/70 transition-colors flex items-center justify-center"
|
|
|
362 |
title="Preview"
|
363 |
/>
|
364 |
</div>
|
365 |
+
|
366 |
+
<ShareDialog
|
367 |
+
open={shareDialogOpen}
|
368 |
+
onOpenChange={handleShareDialogClose}
|
369 |
+
shareUrl={shareUrl}
|
370 |
+
/>
|
371 |
</div>
|
372 |
)
|
373 |
});
|
src/components/prompt-input.tsx
CHANGED
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|
11 |
import { FullscreenToggle } from "./ui/fullscreen-toggle"
|
12 |
import { AuthErrorPopup } from "./auth-error-popup"
|
13 |
import { getInferenceToken } from "@/lib/auth"
|
|
|
14 |
|
15 |
interface PromptInputProps {
|
16 |
onSubmit: (prompt: string, colors: string[]) => Promise<void>;
|
@@ -74,6 +75,8 @@ export function PromptInput({
|
|
74 |
const isAuthenticated = await checkAuth();
|
75 |
if (!isAuthenticated) return;
|
76 |
|
|
|
|
|
77 |
// Clear previous errors
|
78 |
setImproveError(null);
|
79 |
setShowAuthError(false);
|
@@ -89,6 +92,8 @@ export function PromptInput({
|
|
89 |
})
|
90 |
|
91 |
if (!response.ok) {
|
|
|
|
|
92 |
// Handle auth error with openLogin flag
|
93 |
if (response.status === 401) {
|
94 |
const errorData = await response.json();
|
@@ -135,6 +140,7 @@ export function PromptInput({
|
|
135 |
}
|
136 |
}
|
137 |
} catch (error) {
|
|
|
138 |
console.error("Error improving prompt:", error)
|
139 |
setImproveError(error instanceof Error ? error.message : "Failed to improve prompt")
|
140 |
} finally {
|
|
|
11 |
import { FullscreenToggle } from "./ui/fullscreen-toggle"
|
12 |
import { AuthErrorPopup } from "./auth-error-popup"
|
13 |
import { getInferenceToken } from "@/lib/auth"
|
14 |
+
import posthog from 'posthog-js'
|
15 |
|
16 |
interface PromptInputProps {
|
17 |
onSubmit: (prompt: string, colors: string[]) => Promise<void>;
|
|
|
75 |
const isAuthenticated = await checkAuth();
|
76 |
if (!isAuthenticated) return;
|
77 |
|
78 |
+
posthog.capture("Improve prompt", {});
|
79 |
+
|
80 |
// Clear previous errors
|
81 |
setImproveError(null);
|
82 |
setShowAuthError(false);
|
|
|
92 |
})
|
93 |
|
94 |
if (!response.ok) {
|
95 |
+
posthog.capture("Improve prompt", {"type": "failed", "status": response.status});
|
96 |
+
|
97 |
// Handle auth error with openLogin flag
|
98 |
if (response.status === 401) {
|
99 |
const errorData = await response.json();
|
|
|
140 |
}
|
141 |
}
|
142 |
} catch (error) {
|
143 |
+
posthog.capture("Improve prompt", {"type": "failed", "error": error});
|
144 |
console.error("Error improving prompt:", error)
|
145 |
setImproveError(error instanceof Error ? error.message : "Failed to improve prompt")
|
146 |
} finally {
|
src/components/share-dialog.tsx
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import { useState } from "react"
|
4 |
+
import { Copy, Check, AlertTriangle } from "lucide-react"
|
5 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
6 |
+
import { Button } from "./ui/button"
|
7 |
+
|
8 |
+
interface ShareDialogProps {
|
9 |
+
open: boolean;
|
10 |
+
onOpenChange: (open: boolean) => void;
|
11 |
+
shareUrl: string;
|
12 |
+
}
|
13 |
+
|
14 |
+
export function ShareDialog({ open, onOpenChange, shareUrl }: ShareDialogProps) {
|
15 |
+
const [copied, setCopied] = useState(false);
|
16 |
+
|
17 |
+
const handleCopyLink = async () => {
|
18 |
+
try {
|
19 |
+
await navigator.clipboard.writeText(shareUrl);
|
20 |
+
setCopied(true);
|
21 |
+
setTimeout(() => setCopied(false), 2000);
|
22 |
+
} catch (err) {
|
23 |
+
console.error('Failed to copy link:', err);
|
24 |
+
}
|
25 |
+
};
|
26 |
+
|
27 |
+
const handleClose = () => {
|
28 |
+
onOpenChange(false);
|
29 |
+
setCopied(false);
|
30 |
+
};
|
31 |
+
|
32 |
+
return (
|
33 |
+
<Dialog open={open} onOpenChange={handleClose}>
|
34 |
+
<DialogContent className="sm:max-w-md">
|
35 |
+
<DialogHeader>
|
36 |
+
<DialogTitle>Share Your Creation</DialogTitle>
|
37 |
+
</DialogHeader>
|
38 |
+
|
39 |
+
<div className="flex items-start space-x-2 p-3 rounded-md">
|
40 |
+
<AlertTriangle className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
41 |
+
<p className="text-sm text-muted-foreground">
|
42 |
+
<strong>Note:</strong> This shared link is temporary and may not be permanently available.
|
43 |
+
</p>
|
44 |
+
</div>
|
45 |
+
|
46 |
+
<div className="flex items-center space-x-2">
|
47 |
+
<div className="grid flex-1 gap-2">
|
48 |
+
<label htmlFor="link" className="sr-only">
|
49 |
+
Link
|
50 |
+
</label>
|
51 |
+
<input
|
52 |
+
id="link"
|
53 |
+
value={shareUrl}
|
54 |
+
readOnly
|
55 |
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
56 |
+
/>
|
57 |
+
</div>
|
58 |
+
<Button
|
59 |
+
type="button"
|
60 |
+
size="sm"
|
61 |
+
className="px-3"
|
62 |
+
onClick={handleCopyLink}
|
63 |
+
>
|
64 |
+
<span className="sr-only">Copy</span>
|
65 |
+
{copied ? (
|
66 |
+
<Check className="h-4 w-4" />
|
67 |
+
) : (
|
68 |
+
<Copy className="h-4 w-4" />
|
69 |
+
)}
|
70 |
+
</Button>
|
71 |
+
</div>
|
72 |
+
<div className="flex justify-end space-x-2">
|
73 |
+
<Button
|
74 |
+
type="button"
|
75 |
+
variant="outline"
|
76 |
+
onClick={handleClose}
|
77 |
+
>
|
78 |
+
Close
|
79 |
+
</Button>
|
80 |
+
<Button
|
81 |
+
type="button"
|
82 |
+
onClick={() => window.open(shareUrl, '_blank')}
|
83 |
+
>
|
84 |
+
Open Link
|
85 |
+
</Button>
|
86 |
+
</div>
|
87 |
+
</DialogContent>
|
88 |
+
</Dialog>
|
89 |
+
);
|
90 |
+
}
|
src/lib/sharelink.ts
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { nanoid } from "nanoid";
|
2 |
+
const FILE_NAME_KEY = "novita-anysite-share-filename";
|
3 |
+
const BASE_URL = "https://anysite-gallery.novita.ai";
|
4 |
+
|
5 |
+
function getShareFilename(): string {
|
6 |
+
let filename = localStorage.getItem(FILE_NAME_KEY);
|
7 |
+
|
8 |
+
if (!filename) {
|
9 |
+
filename = `${nanoid()}`;
|
10 |
+
localStorage.setItem(FILE_NAME_KEY, filename);
|
11 |
+
}
|
12 |
+
|
13 |
+
return filename;
|
14 |
+
}
|
15 |
+
|
16 |
+
export async function generateShareLink(html: string) {
|
17 |
+
const filename = getShareFilename();
|
18 |
+
|
19 |
+
const response = await fetch(`${BASE_URL}/api/upload-code`, {
|
20 |
+
method: "POST",
|
21 |
+
headers: {
|
22 |
+
"Content-Type": "application/json",
|
23 |
+
},
|
24 |
+
body: JSON.stringify({
|
25 |
+
filename: `${filename}.html`,
|
26 |
+
code: html,
|
27 |
+
}),
|
28 |
+
});
|
29 |
+
|
30 |
+
if (!response.ok) {
|
31 |
+
throw new Error(
|
32 |
+
`Failed to upload: ${response.status} ${response.statusText}`
|
33 |
+
);
|
34 |
+
}
|
35 |
+
|
36 |
+
const result = await response.json();
|
37 |
+
|
38 |
+
if (!result.success) {
|
39 |
+
throw new Error(result.message || "Failed to upload HTML");
|
40 |
+
}
|
41 |
+
|
42 |
+
const uploadedUrl = result.data?.url;
|
43 |
+
if (!uploadedUrl) {
|
44 |
+
throw new Error("No URL returned from upload service");
|
45 |
+
}
|
46 |
+
return `${BASE_URL}/${filename}`;
|
47 |
+
}
|
src/lib/utils.ts
CHANGED
@@ -4,3 +4,44 @@ import { twMerge } from "tailwind-merge"
|
|
4 |
export function cn(...inputs: ClassValue[]) {
|
5 |
return twMerge(clsx(inputs))
|
6 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
export function cn(...inputs: ClassValue[]) {
|
5 |
return twMerge(clsx(inputs))
|
6 |
}
|
7 |
+
|
8 |
+
export function debounce<T extends (...args: any[]) => any>(
|
9 |
+
func: T,
|
10 |
+
wait: number
|
11 |
+
): (...args: Parameters<T>) => void {
|
12 |
+
let timeout: NodeJS.Timeout;
|
13 |
+
return (...args: Parameters<T>) => {
|
14 |
+
clearTimeout(timeout);
|
15 |
+
timeout = setTimeout(() => func(...args), wait);
|
16 |
+
};
|
17 |
+
}
|
18 |
+
|
19 |
+
export function highlightHTML(code: string): string {
|
20 |
+
if (!code) return '';
|
21 |
+
|
22 |
+
let result = code;
|
23 |
+
|
24 |
+
result = result
|
25 |
+
.replace(/&/g, '&')
|
26 |
+
.replace(/</g, '<')
|
27 |
+
.replace(/>/g, '>')
|
28 |
+
.replace(/"/g, '"')
|
29 |
+
.replace(/'/g, ''');
|
30 |
+
|
31 |
+
result = result.replace(/(<style[^&]*?>)([\s\S]*?)(<\/style>)/gi, (match, openTag, cssContent, closeTag) => {
|
32 |
+
const highlighted = cssContent
|
33 |
+
.replace(/([a-zA-Z-]+)(\s*:\s*)([^;]+)(;?)/g, '<span style="color: #9cdcfe;">$1</span><span style="color: #d4d4d4;">$2</span><span style="color: #ce9178;">$3</span><span style="color: #d4d4d4;">$4</span>')
|
34 |
+
.replace(/(\{|\})/g, '<span style="color: #ffd700;">$1</span>');
|
35 |
+
return openTag + highlighted + closeTag;
|
36 |
+
});
|
37 |
+
|
38 |
+
result = result
|
39 |
+
.replace(/(<\/?)([a-zA-Z][a-zA-Z0-9-]*)([\s\S]*?)(>)/g,
|
40 |
+
'<span style="color: #569cd6;">$1</span><span style="color: #4ec9b0;">$2</span><span style="color: #9cdcfe;">$3</span><span style="color: #569cd6;">$4</span>')
|
41 |
+
.replace(/(\s)([a-zA-Z-]+)(=)("[^"]*")/g,
|
42 |
+
'$1<span style="color: #92c5f8;">$2</span><span style="color: #d4d4d4;">$3</span><span style="color: #ce9178;">$4</span>')
|
43 |
+
.replace(/(<!--[\s\S]*?-->)/g, '<span style="color: #6a9955; font-style: italic;">$1</span>')
|
44 |
+
.replace(/(<!DOCTYPE[^&]*?>)/gi, '<span style="color: #c586c0;">$1</span>');
|
45 |
+
|
46 |
+
return result;
|
47 |
+
};
|