novitacarlen commited on
Commit
60fb4e9
·
1 Parent(s): 1aaa08e

feat: support share link

Browse files
package.json CHANGED
@@ -47,6 +47,7 @@
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",
 
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
  "react": "^19",
pnpm-lock.yaml CHANGED
@@ -122,6 +122,9 @@ importers:
122
  lucide-react:
123
  specifier: ^0.454.0
124
  version: 0.454.0([email protected])
 
 
 
125
  next:
126
  specifier: 15.2.4
127
@@ -1656,6 +1659,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:
@@ -3463,6 +3471,8 @@ snapshots:
3463
 
3464
3465
 
 
 
3466
3467
  dependencies:
3468
  react: 19.1.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
 
1659
  engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1660
  hasBin: true
1661
 
1662
1663
+ resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
1664
+ engines: {node: ^18 || >=20}
1665
+ hasBin: true
1666
+
1667
1668
  resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
1669
  peerDependencies:
 
3471
 
3472
3473
 
3474
3475
+
3476
3477
  dependencies:
3478
  react: 19.1.0
src/components/preview.tsx CHANGED
@@ -2,11 +2,13 @@
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 +30,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);
@@ -257,6 +262,38 @@ 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 pl-2`}>
262
  { isPartialGenerating && (
@@ -271,6 +308,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 +357,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
 
13
  interface PreviewProps {
14
  initialHtml?: string;
 
30
  const [error, setError] = useState<string | null>(null);
31
  const [showAuthError, setShowAuthError] = useState(false);
32
  const [refreshKey, setRefreshKey] = useState(0);
33
+ const [isSharing, setIsSharing] = useState(false);
34
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
35
+ const [shareUrl, setShareUrl] = useState<string>("");
36
  const { selectedModelId } = useModel();
37
  const renderCount = useRef(0);
38
  const headUpdated = useRef(false);
 
262
  }
263
  };
264
 
265
+ const handleShare = async () => {
266
+ if (!html) {
267
+ setError("No HTML content to share");
268
+ return;
269
+ }
270
+
271
+ setIsSharing(true);
272
+ setError(null);
273
+
274
+ try {
275
+ const uploadedUrl = await generateShareLink(html, currentVersion || "");
276
+ setShareUrl(uploadedUrl);
277
+ setShareDialogOpen(true);
278
+ } catch (err) {
279
+ const errorMessage = (err as Error).message || 'Failed to share HTML';
280
+ setError(errorMessage);
281
+ if (onErrorChange) {
282
+ onErrorChange(errorMessage);
283
+ }
284
+ console.error('Error sharing HTML:', err);
285
+ } finally {
286
+ setIsSharing(false);
287
+ }
288
+ };
289
+
290
+ const handleShareDialogClose = (open: boolean) => {
291
+ setShareDialogOpen(open);
292
+ if (!open) {
293
+ setShareUrl("");
294
+ }
295
+ };
296
+
297
  return (
298
  <div className={`${isFullscreen ? 'fixed inset-0 z-10 bg-novita-dark' : 'h-full'} p-4 pl-2`}>
299
  { isPartialGenerating && (
 
308
  )}
309
  <div className="bg-white text-black h-full overflow-hidden relative isolation-auto">
310
  <div className="absolute top-3 right-3 flex gap-2 z-[100]">
311
+ <button
312
+ onClick={handleShare}
313
+ disabled={isSharing || !html}
314
+ 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"
315
+ aria-label="Share Link"
316
+ title="Share Link"
317
+ >
318
+ {isSharing ? (
319
+ <Loader2 className="h-3 w-3 mr-2 animate-spin" />
320
+ ) : (
321
+ <Share2 className="h-3 w-3 mr-2" />
322
+ )}
323
+ {isSharing ? 'Sharing...' : 'Share Link'}
324
+ </button>
325
+
326
  <button
327
  onClick={refreshPreview}
328
  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"
 
357
  title="Preview"
358
  />
359
  </div>
360
+
361
+ <ShareDialog
362
+ open={shareDialogOpen}
363
+ onOpenChange={handleShareDialogClose}
364
+ shareUrl={shareUrl}
365
+ />
366
  </div>
367
  )
368
  });
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
+ }