Skip to content

Commit

Permalink
feat: add button open in stackblitz (#4461)
Browse files Browse the repository at this point in the history
* feat: add button open in stackblitz

* feat: add button open in stackblitz

* fix: multiple react

* feat: add stackblitz button code preview

* feat: add stackblitz button code preview
  • Loading branch information
winchesHe authored Jan 5, 2025
1 parent b2e924f commit da7003e
Show file tree
Hide file tree
Showing 9 changed files with 253 additions and 31 deletions.
52 changes: 23 additions & 29 deletions apps/docs/components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,37 @@
import {FC} from "react";
import {Button, ButtonProps} from "@nextui-org/react";
import {ButtonProps} from "@nextui-org/react";
import {useClipboard} from "@nextui-org/use-clipboard";
import {clsx} from "@nextui-org/shared-utils";
import {memo} from "react";

import {PreviewButton} from "./preview-button";

import {CheckLinearIcon, CopyLinearIcon} from "@/components/icons";

export interface CopyButtonProps extends ButtonProps {
value?: string;
}

export const CopyButton: FC<CopyButtonProps> = ({value, className, ...buttonProps}) => {
export const CopyButton = memo<CopyButtonProps>(({value, className, ...buttonProps}) => {
const {copy, copied} = useClipboard();

const icon = copied ? (
<CheckLinearIcon
className="opacity-0 scale-50 data-[visible=true]:opacity-100 data-[visible=true]:scale-100 transition-transform-opacity"
data-visible={copied}
size={16}
/>
) : (
<CopyLinearIcon
className="opacity-0 scale-50 data-[visible=true]:opacity-100 data-[visible=true]:scale-100 transition-transform-opacity"
data-visible={!copied}
size={16}
/>
);

const handleCopy = () => {
copy(value);
};

return (
<Button
isIconOnly
className={clsx(
"absolute z-50 right-3 text-zinc-300 top-8 border-1 border-transparent bg-transparent before:bg-white/10 before:content-[''] before:block before:z-[-1] before:absolute before:inset-0 before:backdrop-blur-md before:backdrop-saturate-100 before:rounded-lg",
className,
)}
size="sm"
variant="bordered"
onPress={handleCopy}
{...buttonProps}
>
<CheckLinearIcon
className="absolute opacity-0 scale-50 data-[visible=true]:opacity-100 data-[visible=true]:scale-100 transition-transform-opacity"
data-visible={copied}
size={16}
/>
<CopyLinearIcon
className="absolute opacity-0 scale-50 data-[visible=true]:opacity-100 data-[visible=true]:scale-100 transition-transform-opacity"
data-visible={!copied}
size={16}
/>
</Button>
);
};
return <PreviewButton className={className} icon={icon} onPress={handleCopy} {...buttonProps} />;
});

CopyButton.displayName = "CopyButton";
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {SandpackFiles} from "@codesandbox/sandpack-react/types";
import {BgGridContainer} from "@/components/bg-grid-container";
import {GradientBox, GradientBoxProps} from "@/components/gradient-box";
import {CopyButton} from "@/components/copy-button";
import {StackblitzButton} from "@/components/stackblitz-button";
import {PreviewButton} from "@/components/preview-button";

export interface ReactLiveDemoProps {
code: string;
Expand All @@ -21,6 +23,7 @@ export interface ReactLiveDemoProps {
className?: string;
gradientColor?: GradientBoxProps["color"];
overflow?: "auto" | "visible" | "hidden";
typescriptStrict?: boolean;
}

// 🚨 Do not pass react-hook-form to scope, it will break the live preview since
Expand Down Expand Up @@ -49,11 +52,18 @@ export const ReactLiveDemo: React.FC<ReactLiveDemoProps> = ({
height,
className,
noInline,
typescriptStrict = false,
}) => {
const content = (
<>
{files?.[DEFAULT_FILE] && (
<div className="absolute top-[-28px] right-[-8px] z-50">
<div className="absolute top-[-26px] right-[3px] z-50 flex items-center">
<StackblitzButton
button={<PreviewButton icon={undefined} />}
className="before:hidden opacity-0 group-hover/code-demo:opacity-100 transition-opacity text-zinc-400"
files={files}
typescriptStrict={typescriptStrict}
/>
<CopyButton
className="before:hidden opacity-0 group-hover/code-demo:opacity-100 transition-opacity text-zinc-400"
value={files?.[DEFAULT_FILE] as string}
Expand Down
18 changes: 18 additions & 0 deletions apps/docs/components/icons/social.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,23 @@ const CodeSandboxIcon: React.FC<IconSvgProps> = ({width = "1em", height = "1em",
);
};

const StackblitzIcon: React.FC<IconSvgProps> = ({...props}) => {
return (
<svg
height={16}
viewBox="0 0 1024 1024"
width={16}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M848 359.3H627.7L825.8 109c4.1-5.3.4-13-6.3-13H436c-2.8 0-5.5 1.5-6.9 4L170 547.5c-3.1 5.3.7 12 6.9 12h174.4l-89.4 357.6c-1.9 7.8 7.5 13.3 13.3 7.7L853.5 373c5.2-4.9 1.7-13.7-5.5-13.7M378.2 732.5l60.3-241H281.1l189.6-327.4h224.6L487 427.4h211z"
fill="currentColor"
/>
</svg>
);
};

const JavascriptIcon: React.FC<IconSvgProps> = ({width = "1em", height = "1em", ...props}) => {
return (
<svg
Expand Down Expand Up @@ -470,6 +487,7 @@ export {
NewNextJSIcon,
StorybookIcon,
CodeSandboxIcon,
StackblitzIcon,
JavascriptIcon,
TypescriptIcon,
BunIcon,
Expand Down
31 changes: 31 additions & 0 deletions apps/docs/components/preview-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {forwardRef} from "react";
import {Button, ButtonProps} from "@nextui-org/react";
import {clsx} from "@nextui-org/shared-utils";

export interface PreviewButtonProps extends ButtonProps {
icon: React.ReactNode;
}

export const PreviewButton = forwardRef<HTMLButtonElement | null, PreviewButtonProps>(
(props, ref) => {
const {icon, className, ...buttonProps} = props;

return (
<Button
ref={ref}
isIconOnly
className={clsx(
"relative z-50 text-zinc-300 top-8 border-1 border-transparent bg-transparent before:bg-white/10 before:content-[''] before:block before:z-[-1] before:absolute before:inset-0 before:backdrop-blur-md before:backdrop-saturate-100 before:rounded-lg",
className,
)}
size="sm"
variant="light"
{...buttonProps}
>
{icon}
</Button>
);
},
);

PreviewButton.displayName = "PreviewButton";
14 changes: 14 additions & 0 deletions apps/docs/components/sandpack/sandpack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import {FC, useRef} from "react";
import {SandpackProvider, SandpackLayout, SandpackPreview} from "@codesandbox/sandpack-react";

import {StackblitzButton} from "../stackblitz-button";

import {SandpackCodeViewer} from "./code-viewer";
import {nextuiTheme} from "./theme";
import {UseSandpackProps, useSandpack} from "./use-sandpack";
Expand Down Expand Up @@ -72,6 +74,18 @@ export const Sandpack: FC<SandpackProps> = ({
{showReportBug && <BugReportButton />}
{showCopyCode && <CopyButton />}
{!showPreview && showOpenInCodeSandbox && <CodeSandboxButton />}
{!showPreview && showOpenInCodeSandbox && (
<StackblitzButton
isIconOnly
as="span"
className="dark:text-zinc-500 text-white"
files={files}
size="sm"
title="Open in Stackblitz"
typescriptStrict={typescriptStrict}
variant="light"
/>
)}
</div>
{hasTypescript && sandpackTemplate && (
<LanguageSelector template={sandpackTemplate} onChange={setCurrentTemplate} />
Expand Down
58 changes: 58 additions & 0 deletions apps/docs/components/stackblitz-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, {forwardRef} from "react";
import stackblitzSdk from "@stackblitz/sdk";
import {SandpackFiles} from "@codesandbox/sandpack-react/types";

import {StackblitzIcon} from "./icons";

import {useStackblitz} from "@/hooks/use-stackblitz";
import {Tooltip} from "@/../../packages/components/tooltip/src";
import {Button, ButtonProps} from "@/../../packages/components/button/src";

export interface StackblitzButtonProps extends ButtonProps {
files: SandpackFiles;
typescriptStrict?: boolean;
className?: string;
button?: React.ReactElement;
icon?: React.ReactNode;
}

export const StackblitzButton = forwardRef<HTMLButtonElement, StackblitzButtonProps>(
(props, ref) => {
const {
files,
typescriptStrict = false,
className,
button = <Button />,
icon = (
<StackblitzIcon
data-visible
className="opacity-0 data-[visible=true]:opacity-100 transition-transform-opacity"
/>
),
...rest
} = props;
const {stackblitzPrefillConfig, entryFile} = useStackblitz({
files,
typescriptStrict,
});

return (
<Tooltip closeDelay={0} content="Open in Stackblitz">
{React.cloneElement(button, {
ref,
className,
icon,
onPress: () => {
stackblitzSdk.openProject(stackblitzPrefillConfig, {
openFile: [entryFile],
});
},
children: icon,
...rest,
})}
</Tooltip>
);
},
);

StackblitzButton.displayName = "StackblitzButton";
88 changes: 88 additions & 0 deletions apps/docs/hooks/use-stackblitz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {Project} from "@stackblitz/sdk";
import {SandpackFiles} from "@codesandbox/sandpack-react/types";

import {mapKeys, omit} from "@/../../packages/utilities/shared-utils/src";
import {useSandpack} from "@/components/sandpack/use-sandpack";

export interface UseSandpackProps {
files: SandpackFiles;
typescriptStrict?: boolean;
}

const viteConfig = `import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});
`;

function transformSandpackFiles(files: SandpackFiles) {
return Object.fromEntries(
Object.entries(files).map(([key, value]) => [
key,
typeof value === "string" ? value : value.code,
]),
);
}

export function useStackblitz(props: UseSandpackProps) {
const {files, typescriptStrict = false} = props;

const {
customSetup,
files: filesData,
entryFile,
} = useSandpack({
files: transformSandpackFiles(files),
typescriptStrict,
});

const transformFiles = mapKeys(filesData, (_, key) => key.replace(/^\//, ""));

const dependencies = {...customSetup.dependencies, ...customSetup.devDependencies};

const packageJson = `{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1",
${Object.entries(omit(dependencies as any, ["react", "react-dom"]))
.map(([key, value]) => `"${key}": "${value}"`)
.join(",\n ")}
},
"devDependencies": {
"@vitejs/plugin-react": "4.3.4",
"vite": "6.0.6",
"autoprefixer": "10.4.20",
"postcss": "8.4.49",
"tailwindcss": "3.4.17"
},
"main": "/index.jsx"
}`;

const stackblitzPrefillConfig: Project = {
files: {
...transformSandpackFiles(transformFiles),
"vite.config.js": viteConfig,
"package.json": packageJson,
},
dependencies,
title: "NextUI",
template: "node",
};

const findEntryFile = Object.keys(stackblitzPrefillConfig.files).find((key) =>
key.includes("App"),
);

return {
entryFile: findEntryFile ?? entryFile,
stackblitzPrefillConfig,
};
}
3 changes: 2 additions & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
"@nextui-org/use-clipboard": "workspace:*",
"@nextui-org/use-infinite-scroll": "workspace:*",
"@nextui-org/use-is-mobile": "workspace:*",
"clsx": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.0.5",
"@react-aria/focus": "3.19.0",
"@react-aria/i18n": "3.12.4",
Expand All @@ -53,8 +52,10 @@
"@react-stately/layout": "4.1.0",
"@react-stately/tree": "3.8.6",
"@rehooks/local-storage": "^2.4.5",
"@stackblitz/sdk": "^1.11.0",
"@tanstack/react-virtual": "3.11.2",
"canvas-confetti": "^1.9.2",
"clsx": "^1.2.1",
"cmdk": "^0.2.0",
"color2k": "2.0.3",
"contentlayer2": "0.5.3",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit da7003e

Please sign in to comment.