Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added site/public/icons/bun.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/public/icons/npm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/public/icons/pnpm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/public/icons/yarn.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions site/src/components/mdx/Clipboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
145 changes: 145 additions & 0 deletions site/src/components/mdx/PackageManagerTabs.tsx
Original file line number Diff line number Diff line change
@@ -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<PackageManagerTabsProps> = ({
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 (
<>
<span className='package-manager'>{pkgManager}</span>
<span className='command-rest'>{' ' + rest.join(' ')}</span>
</>
);
};

return (
<div className='package-manager-tabs'>
<div className='tabs'>
{packageManagers.map((pm) => (
<button
key={pm.id}
className={`tab ${activeTab === pm.id ? 'active' : ''}`}
onClick={() => setActiveTab(pm.id)}
>
<span className='icon'>
<img src={`/icons/${pm.id}.png`} alt={`${pm.label} icon`} width={16} height={16} />
</span>
{pm.label}
</button>
))}
</div>
<div className='code-block-wrapper'>
<pre {...props} className='code-block'>
<code>{renderCommand(activeCommand)}</code>
</pre>
<button className='copy-button' onClick={() => void handleCopy()} title='Copy to clipboard'>
{copied ? (
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<polyline points='20 6 9 17 4 12'></polyline>
</svg>
) : (
<svg
xmlns='http://www.w3.org/2000/svg'
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<rect x='9' y='9' width='13' height='13' rx='2' ry='2'></rect>
<path d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'></path>
</svg>
)}
</button>
</div>
</div>
);
};

export default PackageManagerTabs;
4 changes: 3 additions & 1 deletion site/src/components/mdx/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -11,7 +12,8 @@ const components: MDXComponents = {
ul: (props) => <UnorderedList {...props} />,
a: (props) => <DocLink {...props} />,
p: (props) => <p {...props} className='my-4 leading-7' />,
Callout: (props) => <Callout {...props} />
Callout: (props) => <Callout {...props} />,
pre: (props) => <PackageManagerTabs {...props} />
};

export default components;
110 changes: 110 additions & 0 deletions site/src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}