Spaces:
Running
Running
novitacarlen
commited on
Commit
·
60fb4e9
1
Parent(s):
1aaa08e
feat: support share link
Browse files- package.json +1 -0
- pnpm-lock.yaml +10 -0
- src/components/preview.tsx +60 -2
- src/components/share-dialog.tsx +90 -0
- src/lib/sharelink.ts +47 -0
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 |
version: 15.2.4([email protected]([email protected]))([email protected])
|
@@ -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 |
version: 15.2.4([email protected]([email protected]))([email protected])
|
|
|
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 |
+
[email protected]: {}
|
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
|
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 |
+
}
|