From 5a8ccdaab700ee12933f2db89c775e5da292e1ce Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Tue, 10 Sep 2024 21:38:52 +0800 Subject: [PATCH 1/2] add share feature --- app/api/get-share/route.ts | 23 ++++++++++++ app/api/share/route.ts | 37 +++++++++++++++++++ app/api/test/route.ts | 3 +- app/layout.tsx | 2 + app/share/[id]/_sharepagecomponent.tsx | 12 ++++++ app/share/[id]/page.tsx | 10 +++++ components/build-deploy-panel.tsx | 12 +++++- components/ui/toaster.tsx | 2 +- components/workspace/share-link.tsx | 45 +++++++++++++++++++++++ components/workspace/use-cli-commands.tsx | 45 ++++++++++++++++++++++- data/client.ts | 12 ++++++ package-lock.json | 40 ++++++++++++++++++++ package.json | 2 + 13 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 app/api/get-share/route.ts create mode 100644 app/api/share/route.ts create mode 100644 app/share/[id]/_sharepagecomponent.tsx create mode 100644 app/share/[id]/page.tsx create mode 100644 components/workspace/share-link.tsx diff --git a/app/api/get-share/route.ts b/app/api/get-share/route.ts new file mode 100644 index 0000000..8699424 --- /dev/null +++ b/app/api/get-share/route.ts @@ -0,0 +1,23 @@ +import { getBuildServerBaseUrl } from "@/lib/env"; +import { unzipSync } from "fflate"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + throw new Error("no id"); + } + + const res = await fetch( + `${getBuildServerBaseUrl()}/playground/share/get/${id}` + ); + + const data = await res.text(); + + const zipData = Buffer.from(data, "base64"); + + const unzipped = unzipSync(zipData); + + return Response.json(unzipped); +} diff --git a/app/api/share/route.ts b/app/api/share/route.ts new file mode 100644 index 0000000..7af5a54 --- /dev/null +++ b/app/api/share/route.ts @@ -0,0 +1,37 @@ +import { FileContent } from "@/data/db"; +import { getBuildServerBaseUrl } from "@/lib/env"; +import { fileContentToZip } from "@/lib/file-content-to-zip"; +import { type NextRequest } from "next/server"; +import { v4 as uuidv4 } from "uuid"; + +export async function POST(request: NextRequest) { + const { files } = (await request.json()) as { files: FileContent[] }; + + const zippedData = fileContentToZip(files); + + const formData = new FormData(); + const filePath = uuidv4() + ".zip"; + formData.append( + "file", + new File([zippedData], filePath, { type: "application/zip" }), + filePath + ); + + const requestInit: RequestInit = { + method: "POST", + body: formData, + redirect: "follow", + }; + + const response = await fetch( + `${getBuildServerBaseUrl()}/playground/share/create`, + requestInit + ); + + if (!response.ok) { + const { message } = await response.json(); + return Response.json({ error: message }, { status: response.status }); + } + + return Response.json(await response.json()); +} diff --git a/app/api/test/route.ts b/app/api/test/route.ts index bebfc3c..3e86d25 100644 --- a/app/api/test/route.ts +++ b/app/api/test/route.ts @@ -1,9 +1,8 @@ import { FileContent } from "@/data/db"; -import { getBuildServerBaseUrl, getSolangBuildServerBaseUrl } from "@/lib/env"; +import { getBuildServerBaseUrl } from "@/lib/env"; import { fileContentToZip } from "@/lib/file-content-to-zip"; import { type NextRequest } from "next/server"; import { v4 as uuidv4 } from "uuid"; -import { strFromU8, strToU8 } from "fflate"; export async function POST(request: NextRequest) { const { files } = (await request.json()) as { files: FileContent[] }; diff --git a/app/layout.tsx b/app/layout.tsx index 2394bfd..0f5cc7d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import clsx from "clsx"; import { GoogleAnalytics } from "@next/third-parties/google"; import { getGoogleAnalyticsTag } from "@/lib/env"; import Providers from "@/components/providers"; +import { Toaster } from "@/components/ui/toaster" const font = Poppins({ weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], @@ -30,6 +31,7 @@ export default function RootLayout({ children }: PropsWithChildren) {
{children}
+ {gaId ? : null} diff --git a/app/share/[id]/_sharepagecomponent.tsx b/app/share/[id]/_sharepagecomponent.tsx new file mode 100644 index 0000000..08092a1 --- /dev/null +++ b/app/share/[id]/_sharepagecomponent.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useShare } from "@/data/client"; + +export function SharePageComponent({ id }: { id: string }) { + const { data, isLoading, error } = useShare(id); + + if (isLoading) return

Loading...

; + else if (error) return

Error: {String(error)}

; + + return

{JSON.stringify(data)}

; +} diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx new file mode 100644 index 0000000..e4258f4 --- /dev/null +++ b/app/share/[id]/page.tsx @@ -0,0 +1,10 @@ +import { SharePageComponent } from "./_sharepagecomponent"; + + + +export default function Page({ params: {id} }: { params: { id: string } }) { + + return ( + + ); +} diff --git a/components/build-deploy-panel.tsx b/components/build-deploy-panel.tsx index bb5b7c3..9509f7d 100644 --- a/components/build-deploy-panel.tsx +++ b/components/build-deploy-panel.tsx @@ -6,7 +6,7 @@ import { useCliCommands } from "./workspace/use-cli-commands"; import useSWR, { mutate } from "swr"; import { db } from "@/data/db"; import { useWorkspaceId } from "./workspace/use-workspace-id"; -import { Download, Rocket, ShieldCheck, Wrench, TestTube2 } from "lucide-react"; +import { Download, Rocket, ShieldCheck, Wrench, TestTube2, Link2 } from "lucide-react"; import UploadModal from "./workspace/upload-modal"; import { Tooltip } from "./tooltip"; @@ -100,6 +100,16 @@ export function BuildDeployPanel() { }, icon: Download, }, + { + disabled: false, + title: "Share", + onClick: async () => { + try { + await commands.share(); + } catch (err) {} + }, + icon: Link2, + }, ]; return ( diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx index e223385..fe7103d 100644 --- a/components/ui/toaster.tsx +++ b/components/ui/toaster.tsx @@ -1,5 +1,6 @@ "use client" +import { useToast } from "@/components/ui/use-toast" import { Toast, ToastClose, @@ -8,7 +9,6 @@ import { ToastTitle, ToastViewport, } from "@/components/ui/toast" -import { useToast } from "@/components/ui/use-toast" export function Toaster() { const { toasts } = useToast() diff --git a/components/workspace/share-link.tsx b/components/workspace/share-link.tsx new file mode 100644 index 0000000..546cf8e --- /dev/null +++ b/components/workspace/share-link.tsx @@ -0,0 +1,45 @@ +"use client"; +import { Copy, CopyCheck } from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { useToast } from "@/components/ui/use-toast"; + +export function ShareLink({ id }: { id: string }) { + const { toast } = useToast(); + const origin = + typeof window !== "undefined" && window.location.origin + ? window.location.origin + : ""; + const [copied, setCopied] = useState(false); + + const url = useMemo(() => { + return `${origin}/share/${id}`; + }, [id, origin]); + + return ( +
+ + {url} + + { + setCopied(true); + + toast({ + title: "Link Copied", + description: "Share link has been copied to your clipboard.", + + }); + }} + > + {copied ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/components/workspace/use-cli-commands.tsx b/components/workspace/use-cli-commands.tsx index 0fc2f56..84d7f65 100644 --- a/components/workspace/use-cli-commands.tsx +++ b/components/workspace/use-cli-commands.tsx @@ -13,6 +13,7 @@ import { fileContentToZip } from "@/lib/file-content-to-zip"; import { saveAs } from "file-saver"; import { useSetSearchParams } from "@/lib/set-search-params"; import { FormatErrors } from "./format-errors"; +import { ShareLink } from "./share-link"; export function useCliCommands() { const terminalContext = useContext(TerminalContext); @@ -35,6 +36,7 @@ export function useCliCommands() {
  • test - tests the current workspace
  • deploy - deploys the built smart contract
  • export - exports the current workspace
  • +
  • share - generates a share link for the current workspace
  • check txID - checks the result of transaction
  • @@ -260,6 +262,46 @@ export function useCliCommands() { ); saveAs(file); }, + share: async () => { + if (typeof id !== "string") throw new Error("id is not string"); + const start = `${pathname}/`; + terminalContext.setBufferedContent( + <> +

    Loading files...

    + + ); + const files = ( + await db.files.filter((file) => file.path.startsWith(start)).toArray() + ).map((file) => ({ + path: decodeURIComponent(file.path.replace(start, "")), + contents: file.contents, + })); + + terminalContext.setBufferedContent( + <> +

    Loaded files: {files.map((i) => i.path).join(", ")}

    +

    + Generating share link... +

    + + ); + + try { + const res = await fetch(`/api/share`, { + method: "POST", + body: JSON.stringify({ files }), + }); + const { id } = await res.json(); + + terminalContext.setBufferedContent( + + ); + } catch (err) { + if (err instanceof Error) + terminalContext.setBufferedContent(<>{err.message}); + return; + } + }, }; return commands; @@ -395,4 +437,5 @@ function AuditReportResult({ codeHash }: { codeHash: string }) { ); -} \ No newline at end of file +} + diff --git a/data/client.ts b/data/client.ts index a4cb2bd..6fb8071 100644 --- a/data/client.ts +++ b/data/client.ts @@ -139,6 +139,18 @@ export function useTutorialList() { const data = await res.json(); + return data; + }); +} + +export function useShare(id: string) { + return useSWR< + Record + >(`get-share-${id}`, async () => { + const res = await fetch(`/api/get-share?id=${id}`); + + const data = await res.json(); + return data; }); } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1d5b606..f08a0d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "parse-github-url": "^1.0.3", "react": "^18", "react-accessible-treeview": "^2.9.1", + "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", "react-dropzone": "^14.2.3", "react-hook-form": "^7.52.1", @@ -89,6 +90,7 @@ "@types/node": "^20", "@types/parse-github-url": "^1.0.3", "@types/react": "^18", + "@types/react-copy-to-clipboard": "^5.0.7", "@types/react-dom": "^18", "@types/uuid": "^10.0.0", "eslint": "^8", @@ -3127,6 +3129,16 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-copy-to-clipboard": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.7.tgz", + "integrity": "sha512-Gft19D+as4M+9Whq1oglhmK49vqPhcLzk8WfvfLvaYMIPYanyfLy0+CwFucMJfdKoSFyySPmkkWn8/E6voQXjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", @@ -4714,6 +4726,15 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -9190,6 +9211,19 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-copy-to-clipboard": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", + "integrity": "sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==", + "license": "MIT", + "dependencies": { + "copy-to-clipboard": "^3.3.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^15.3.0 || 16 || 17 || 18" + } + }, "node_modules/react-device-detect": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/react-device-detect/-/react-device-detect-2.1.2.tgz", @@ -10462,6 +10496,12 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index 57b3cb4..62b4214 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "parse-github-url": "^1.0.3", "react": "^18", "react-accessible-treeview": "^2.9.1", + "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", "react-dropzone": "^14.2.3", "react-hook-form": "^7.52.1", @@ -90,6 +91,7 @@ "@types/node": "^20", "@types/parse-github-url": "^1.0.3", "@types/react": "^18", + "@types/react-copy-to-clipboard": "^5.0.7", "@types/react-dom": "^18", "@types/uuid": "^10.0.0", "eslint": "^8", From 173dee8804546a641b178f84dd7ffd599f85e49e Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Tue, 10 Sep 2024 23:50:59 +0800 Subject: [PATCH 2/2] feat: finalise share feature --- app/api/get-share/route.ts | 24 ++++++++++++++++----- app/share/[id]/_sharepagecomponent.tsx | 29 +++++++++++++++++++++++++- data/client.ts | 8 +++---- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/app/api/get-share/route.ts b/app/api/get-share/route.ts index 8699424..ba10106 100644 --- a/app/api/get-share/route.ts +++ b/app/api/get-share/route.ts @@ -1,5 +1,6 @@ +import { FileContent } from "@/data/db"; import { getBuildServerBaseUrl } from "@/lib/env"; -import { unzipSync } from "fflate"; +import { unzipSync, strFromU8 } from "fflate"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -13,11 +14,24 @@ export async function GET(request: Request) { `${getBuildServerBaseUrl()}/playground/share/get/${id}` ); - const data = await res.text(); + const data = await res.arrayBuffer(); - const zipData = Buffer.from(data, "base64"); + try { + const unzipped = unzipSync(Buffer.from(data)); - const unzipped = unzipSync(zipData); + let files: FileContent[] = []; - return Response.json(unzipped); + Object.entries(unzipped).forEach(([k, v]) => { + files.push({ + path: k, + contents: strFromU8(v) + }) + }) + + return Response.json(files); + } catch (err) { + if (err instanceof Error) { + return Response.json({ message: err.message }, { status: 500 }); + } + } } diff --git a/app/share/[id]/_sharepagecomponent.tsx b/app/share/[id]/_sharepagecomponent.tsx index 08092a1..73f5248 100644 --- a/app/share/[id]/_sharepagecomponent.tsx +++ b/app/share/[id]/_sharepagecomponent.tsx @@ -1,12 +1,39 @@ "use client"; import { useShare } from "@/data/client"; +import { db } from "@/data/db"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; export function SharePageComponent({ id }: { id: string }) { const { data, isLoading, error } = useShare(id); + const router = useRouter(); + + useEffect(() => { + async function importWorkspace() { + if (!data) return; + + const existing = await db.workspaces.get(id); + if (existing) { + router.push(`/workspace/${id}`); + } else { + await db.workspaces.add({name: id, template: id, dll: ''}); + + await db.files.bulkAdd( + data.map(({ path, contents }) => ({ + path: `/workspace/${id}/${encodeURIComponent(path)}`, + contents, + })) + ); + router.push(`/workspace/${id}`); + } + } + + importWorkspace(); + }, [id, data]) if (isLoading) return

    Loading...

    ; else if (error) return

    Error: {String(error)}

    ; - return

    {JSON.stringify(data)}

    ; + return

    Loading...

    ; } diff --git a/data/client.ts b/data/client.ts index 6fb8071..3b1f963 100644 --- a/data/client.ts +++ b/data/client.ts @@ -1,7 +1,7 @@ "use client"; import useSWR from "swr"; -import { db } from "./db"; +import { db, FileContent } from "./db"; import { ProposalInfo } from "./proposal-info-types"; import AElf from "aelf-sdk"; import { Transactions } from "./transactions-types"; @@ -144,13 +144,11 @@ export function useTutorialList() { } export function useShare(id: string) { - return useSWR< - Record - >(`get-share-${id}`, async () => { + return useSWR(`get-share-${id}`, async () => { const res = await fetch(`/api/get-share?id=${id}`); const data = await res.json(); return data; }); -} \ No newline at end of file +}