Optimize code editing

#1
by CarlenBai - opened
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
128
  next-themes:
129
  specifier: ^0.4.4
130
 
 
 
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
131
  next-themes:
132
  specifier: ^0.4.4
133
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
3281
+
3282
3283
  dependencies:
3284
  path-key: 3.1.1
 
3383
  dependencies:
3384
  reusify: 1.1.0
3385
 
3386
3387
+
3388
3389
  dependencies:
3390
  to-regex-range: 5.0.1
 
3501
 
3502
3503
 
3504
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
3616
+
3617
3618
  dependencies:
3619
  loose-envify: 1.4.0
 
3927
  d3-time: 3.1.0
3928
  d3-timer: 3.0.1
3929
 
3930
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
- <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
26
- <ModelProvider>
27
- <TooltipProvider>{children}</TooltipProvider>
28
- <Toaster richColors />
29
- </ModelProvider>
30
- </ThemeProvider>
 
 
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
- <div className="w-1/3 border-r border-novita-gray/20 flex flex-col relative">
199
- <CodeEditor code={code} isLoading={loading} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
- <AuthErrorPopup
202
- show={showAuthError}
203
- onClose={() => setShowAuthError(false)}
204
- />
 
 
 
 
205
 
206
- <PromptInput
207
- onSubmit={handlePromptSubmit}
208
- isLoading={loading}
209
- initialPrompt={prompt}
210
- onImproveError={setImproveError}
211
- />
212
- </div>
213
- <div className="w-2/3">
214
- <Preview
215
- ref={previewRef}
216
- initialHtml={code}
217
- onCodeChange={handleCodeChange}
218
- onLoadingChange={handleLoadingChange}
219
- onAuthErrorChange={setShowAuthError}
220
- onErrorChange={setGenerationError}
221
- currentVersion={currentVersionId || undefined}
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
- // Check if user is near bottom when scrolling
15
- const handleScroll = () => {
16
- if (!containerRef.current) return;
17
-
18
- const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
19
- // Consider "at bottom" if within 30px of the bottom
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  const isNearBottom = scrollHeight - scrollTop - clientHeight < 30;
21
  setIsAtBottom(isNearBottom);
22
  };
23
 
24
  useEffect(() => {
25
- const container = containerRef.current;
26
- if (container) {
27
- container.addEventListener('scroll', handleScroll);
28
- return () => container.removeEventListener('scroll', handleScroll);
 
29
  }
30
- }, []);
31
 
32
- useEffect(() => {
33
- // Only auto-scroll if user was already at the bottom
34
- if (isAtBottom && containerRef.current) {
35
- containerRef.current.scrollTop = containerRef.current.scrollHeight;
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
- }, [code, isLoading, isAtBottom]);
 
 
38
 
39
  return (
40
- <div className="flex-1 overflow-auto p-4">
41
  <div
42
  ref={containerRef}
43
- className="code-area p-4 rounded-md font-mono text-sm h-full overflow-auto border border-novita-gray/20"
44
  >
45
- <pre className="whitespace-pre-wrap">
46
- {code}
47
- {isLoading && <span className="inline-block h-4 w-2 bg-white/70 animate-pulse"></span>}
48
- </pre>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, Version } from "@/lib/types"
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, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#039;');
30
+
31
+ result = result.replace(/(&lt;style[^&]*?&gt;)([\s\S]*?)(&lt;\/style&gt;)/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(/(&lt;\/?)([a-zA-Z][a-zA-Z0-9-]*)([\s\S]*?)(&gt;)/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-]+)(=)(&quot;[^&quot;]*&quot;)/g,
42
+ '$1<span style="color: #92c5f8;">$2</span><span style="color: #d4d4d4;">$3</span><span style="color: #ce9178;">$4</span>')
43
+ .replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span style="color: #6a9955; font-style: italic;">$1</span>')
44
+ .replace(/(&lt;!DOCTYPE[^&]*?&gt;)/gi, '<span style="color: #c586c0;">$1</span>');
45
+
46
+ return result;
47
+ };