diff --git a/.changeset/pink-vans-brake.md b/.changeset/pink-vans-brake.md new file mode 100644 index 0000000000..9e6868c6ac --- /dev/null +++ b/.changeset/pink-vans-brake.md @@ -0,0 +1,5 @@ +--- +"@justeattakeaway/pie-storybook": patch +--- + +[Added] - aperture previews diff --git a/apps/pie-storybook/.storybook/manager.js b/apps/pie-storybook/.storybook/manager.js index aba51172eb..b94654cad6 100644 --- a/apps/pie-storybook/.storybook/manager.js +++ b/apps/pie-storybook/.storybook/manager.js @@ -1,3 +1,5 @@ import { addons } from 'storybook/manager-api'; import { themes } from 'storybook/theming'; +import '../addons/code-examples/register'; + diff --git a/apps/pie-storybook/addons/code-examples/CodeExamplesPanel.tsx b/apps/pie-storybook/addons/code-examples/CodeExamplesPanel.tsx new file mode 100644 index 0000000000..a1408107ec --- /dev/null +++ b/apps/pie-storybook/addons/code-examples/CodeExamplesPanel.tsx @@ -0,0 +1,325 @@ +import React, { useEffect, useState } from 'react'; +import { useStorybookState } from 'storybook/manager-api'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +const GITHUB_REPO = 'justeattakeaway/pie-aperture'; +const GITHUB_BRANCH = 'main'; + +interface ApertureApp { + label: string; + baseUrl: string; + language: string; + getSourcePath: (name: string) => string; +} + +const APERTURE_APPS: ApertureApp[] = [ + { + label: 'Next.js 14 (React)', + baseUrl: 'https://aperture-nextjs-v14.pie.design/components', + language: 'tsx', + getSourcePath: (name) => `nextjs-app-v14/src/app/components/${name}/${name}.tsx`, + }, + { + label: 'Next.js 15 (React)', + baseUrl: 'https://aperture-nextjs-v15.pie.design/components', + language: 'tsx', + getSourcePath: (name) => `nextjs-app-v15/src/app/components/${name}/${name}.tsx`, + }, + { + label: 'Nuxt 3 (Vue)', + baseUrl: 'https://aperture-nuxt.pie.design/components', + language: 'html', + getSourcePath: (name) => `nuxt-app/pages/components/${name}.vue`, + }, + { + label: 'Vanilla JS', + baseUrl: 'https://aperture-vanilla.pie.design/components', + language: 'javascript', + getSourcePath: (name) => `vanilla-app/js/${name}.js`, + }, +]; + +/** + * Extracts the component name from a Storybook story title. + * e.g. "Components/Button" -> "button" + */ +function getComponentName (storyTitle: string): string | null { + const match = storyTitle.match(/^Components\/(.+)/i); + if (!match) return null; + + return match[1].toLowerCase().replace(/\s+/g, '-'); +} + +function getRawGitHubUrl (sourcePath: string): string { + return `https://raw.githubusercontent.com/${GITHUB_REPO}/${GITHUB_BRANCH}/${sourcePath}`; +} + +function getGitHubUrl (sourcePath: string): string { + return `https://github.com/${GITHUB_REPO}/blob/${GITHUB_BRANCH}/${sourcePath}`; +} + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + fontFamily: 'inherit', + }, + tabBar: { + display: 'flex', + gap: '0', + borderBottom: '1px solid #e0e0e0', + padding: '0 16px', + flexShrink: 0, + backgroundColor: '#fafafa', + }, + tab: { + padding: '10px 16px', + fontSize: '13px', + fontWeight: 500, + cursor: 'pointer', + border: 'none', + background: 'none', + color: '#666', + borderBottom: '2px solid transparent', + marginBottom: '-1px', + transition: 'color 0.15s, border-color 0.15s', + }, + tabActive: { + color: '#f36d00', + borderBottomColor: '#f36d00', + }, + splitView: { + display: 'flex', + flex: 1, + overflow: 'hidden', + }, + previewPane: { + flex: 1, + borderRight: '1px solid #e0e0e0', + display: 'flex', + flexDirection: 'column', + }, + previewHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 12px', + backgroundColor: '#f5f5f5', + borderBottom: '1px solid #e0e0e0', + fontSize: '12px', + color: '#666', + flexShrink: 0, + }, + iframeContainer: { + flex: 1, + position: 'relative', + }, + iframe: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + border: 'none', + }, + codePane: { + flex: 1, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }, + codeHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 12px', + backgroundColor: '#1e1e1e', + borderBottom: '1px solid #333', + fontSize: '12px', + color: '#999', + flexShrink: 0, + }, + codeContent: { + flex: 1, + overflow: 'auto', + margin: 0, + }, + githubLink: { + color: '#58a6ff', + textDecoration: 'none', + fontSize: '12px', + }, + openLink: { + color: '#58a6ff', + textDecoration: 'none', + fontSize: '12px', + }, + loading: { + padding: '24px', + color: '#999', + fontSize: '13px', + }, + error: { + padding: '16px', + color: '#999', + fontSize: '13px', + }, + empty: { + padding: '24px', + fontSize: '14px', + color: '#999', + }, +}; + +export function CodeExamplesPanel () { + const state = useStorybookState(); + const { storyId } = state; + const story = state.index?.[storyId]; + const storyTitle = story && 'title' in story ? story.title : ''; + + const componentName = getComponentName(storyTitle); + const [selectedIndex, setSelectedIndex] = useState(0); + const [code, setCode] = useState(null); + const [loading, setLoading] = useState(false); + const [fetchError, setFetchError] = useState(false); + + const selectedApp = APERTURE_APPS[selectedIndex]; + const sourcePath = componentName ? selectedApp.getSourcePath(componentName) : ''; + + // Fetch source code from GitHub + useEffect(() => { + if (!componentName) return; + + setLoading(true); + setFetchError(false); + setCode(null); + + fetch(getRawGitHubUrl(sourcePath)) + .then((res) => { + if (!res.ok) throw new Error(`${res.status}`); + return res.text(); + }) + .then((text) => { + setCode(text); + setLoading(false); + }) + .catch(() => { + setFetchError(true); + setLoading(false); + }); + }, [componentName, selectedIndex, sourcePath]); + + if (!componentName) { + return ( +
+ Code examples are only available for component stories. +
+ ); + } + + return ( +
+ {/* Framework tabs */} +
+ {APERTURE_APPS.map((app, index) => ( + + ))} +
+ + {/* Split view: live preview + code */} +
+ {/* Left: Live preview */} +
+
+ Live Preview + + Open in new tab ↗ + +
+ {/* + * All 4 iframes are rendered simultaneously but only the + * active one is visible. This keeps each iframe's fonts + * and styles loaded across tab switches. + */} +
+ {APERTURE_APPS.map((app, index) => ( +