diff --git a/site/public/icons/bun.png b/site/public/icons/bun.png new file mode 100644 index 0000000..9651e77 Binary files /dev/null and b/site/public/icons/bun.png differ diff --git a/site/public/icons/npm.png b/site/public/icons/npm.png new file mode 100644 index 0000000..83d6344 Binary files /dev/null and b/site/public/icons/npm.png differ diff --git a/site/public/icons/pnpm.png b/site/public/icons/pnpm.png new file mode 100644 index 0000000..4294b52 Binary files /dev/null and b/site/public/icons/pnpm.png differ diff --git a/site/public/icons/yarn.png b/site/public/icons/yarn.png new file mode 100644 index 0000000..2991fd6 Binary files /dev/null and b/site/public/icons/yarn.png differ diff --git a/site/src/components/mdx/Clipboard.tsx b/site/src/components/mdx/Clipboard.tsx index d8e91c9..74c4446 100644 --- a/site/src/components/mdx/Clipboard.tsx +++ b/site/src/components/mdx/Clipboard.tsx @@ -6,6 +6,11 @@ function Clipboard({ children }: { children: ReactNode }) { const buttons: { button: HTMLButtonElement; onClick: () => void }[] = []; for (const figure of figures) { + // Skip if it's a package manager tabs code block + if (figure.closest('.package-manager-tabs')) { + continue; + } + if (figure.querySelector('.copy-button')) { continue; } diff --git a/site/src/components/mdx/PackageManagerTabs.tsx b/site/src/components/mdx/PackageManagerTabs.tsx new file mode 100644 index 0000000..1cbdc35 --- /dev/null +++ b/site/src/components/mdx/PackageManagerTabs.tsx @@ -0,0 +1,145 @@ +import React, { useEffect, useState } from 'react'; + +interface PackageManager { + id: string; + label: string; + icon: string; + commandTemplate: string; +} + +interface PackageManagerTabsProps extends React.ComponentPropsWithoutRef<'pre'> { + packageName?: string; + customPackageManagers?: PackageManager[]; +} + +const defaultPackageManagers: PackageManager[] = [ + { + id: 'npm', + label: 'npm', + icon: '/icons/npm.png', + commandTemplate: 'npm install {package}' + }, + { + id: 'yarn', + label: 'yarn', + icon: '/icons/yarn.png', + commandTemplate: 'yarn add {package}' + }, + { + id: 'pnpm', + label: 'pnpm', + icon: '/icons/pnpm.png', + commandTemplate: 'pnpm add {package}' + }, + { + id: 'bun', + label: 'bun', + icon: '/icons/bun.png', + commandTemplate: 'bun add {package}' + } +]; + +const STORAGE_KEY = 'preferred-package-manager'; + +const PackageManagerTabs: React.FC = ({ + packageName = 'lovit', + customPackageManagers, + ...props +}) => { + const packageManagers = customPackageManagers || defaultPackageManagers; + + const [activeTab, setActiveTab] = useState(() => { + if (globalThis.window !== undefined) { + return globalThis.window.localStorage.getItem(STORAGE_KEY) || packageManagers[0].id; + } + return packageManagers[0].id; + }); + const [copied, setCopied] = useState(false); + + const activePackageManager = packageManagers.find((pm) => pm.id === activeTab); + const activeCommand = activePackageManager + ? activePackageManager.commandTemplate.replace('{package}', packageName) + : ''; + + useEffect(() => { + globalThis.window.localStorage.setItem(STORAGE_KEY, activeTab); + }, [activeTab]); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(activeCommand); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + const renderCommand = (command: string) => { + const [pkgManager, ...rest] = command.split(' '); + return ( + <> + {pkgManager} + {' ' + rest.join(' ')} + + ); + }; + + return ( +
+
+ {packageManagers.map((pm) => ( + + ))} +
+
+
+          {renderCommand(activeCommand)}
+        
+ +
+
+ ); +}; + +export default PackageManagerTabs; diff --git a/site/src/components/mdx/index.tsx b/site/src/components/mdx/index.tsx index 7de60f1..43356a0 100644 --- a/site/src/components/mdx/index.tsx +++ b/site/src/components/mdx/index.tsx @@ -2,6 +2,7 @@ import { MDXComponents } from 'mdx/types'; import Callout from './Callout'; import DocLink from './DocLink'; import Heading from './Heading'; +import PackageManagerTabs from './PackageManagerTabs'; import UnorderedList from './UnorderedList'; const components: MDXComponents = { @@ -11,7 +12,8 @@ const components: MDXComponents = { ul: (props) => , a: (props) => , p: (props) =>

, - Callout: (props) => + Callout: (props) => , + pre: (props) => }; export default components; diff --git a/site/src/styles/index.css b/site/src/styles/index.css index f4c1e62..2f49569 100644 --- a/site/src/styles/index.css +++ b/site/src/styles/index.css @@ -84,3 +84,113 @@ ul:has(a[href='/guide/usage'].active) a[href='/guide'] { :focus-visible { @apply outline-grey-light; } + +.package-manager-tabs { + margin: 1.5rem 0; + border: 1px solid var(--color-black-light-2); + border-radius: 8px; + overflow: hidden; +} + +.package-manager-tabs .tabs { + display: flex; + gap: 0; + background: var(--color-black-light); + border-bottom: 1px solid var(--color-black-light-2); + padding: 0.5rem; +} + +.package-manager-tabs .tab { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + background: none; + color: var(--color-grey); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + transition: all 0.2s ease; + position: relative; +} + +.package-manager-tabs .tab .icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.package-manager-tabs .tab .icon svg { + width: 100%; + height: 100%; +} + +.package-manager-tabs .tab:hover { + color: var(--color-grey-light); +} + +.package-manager-tabs .tab.active { + color: var(--color-grey-light); +} + +.package-manager-tabs .tab.active::after { + content: ''; + position: absolute; + bottom: -0.5rem; + left: 0; + width: 100%; + height: 2px; + background: var(--color-primary); + border-radius: 2px; +} + +.package-manager-tabs .code-block-wrapper { + position: relative; + background: var(--color-black); +} + +.package-manager-tabs .code-block { + margin: 0; + padding: 1rem; + background: var(--color-black); + border-radius: 0; + overflow-x: auto; + color: var(--color-grey-light); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.875rem; + line-height: 1.5; +} + +.package-manager-tabs .code-block .package-manager { + color: var(--color-primary); +} + +.package-manager-tabs .code-block .command-rest { + color: var(--color-grey-light); +} + +.package-manager-tabs .copy-button { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0.5rem; + border: none; + border-radius: 4px; + background: var(--color-black-light); + color: var(--color-grey); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease; +} + +.package-manager-tabs .code-block-wrapper:hover .copy-button { + opacity: 1; +} + +.package-manager-tabs .copy-button:hover { + background: var(--color-black-light-2); + color: var(--color-grey-light); +}