diff --git a/app/client.tsx b/app/client.tsx index aa4e1d7e0..41f74b4a3 100644 --- a/app/client.tsx +++ b/app/client.tsx @@ -1,3 +1,4 @@ +/// import { hydrateRoot } from 'react-dom/client' import { StartClient } from '@tanstack/start' import { createRouter } from './router' diff --git a/app/components/CodeExplorer.tsx b/app/components/CodeExplorer.tsx new file mode 100644 index 000000000..c97fbf7c9 --- /dev/null +++ b/app/components/CodeExplorer.tsx @@ -0,0 +1,144 @@ +import React from 'react' +import { CodeBlock } from '~/components/Markdown' +import { FileExplorer } from './FileExplorer' +import { InteractiveSandbox } from './InteractiveSandbox' +import { CodeExplorerTopBar } from './CodeExplorerTopBar' +import type { GitHubFileNode } from '~/utils/documents.server' +import type { Library } from '~/libraries' + +function overrideExtension(ext: string | undefined) { + if (!ext) return 'txt' + + // Override some extensions + if (['cts', 'mts'].includes(ext)) return 'ts' + if (['cjs', 'mjs'].includes(ext)) return 'js' + if (['prettierrc', 'babelrc', 'webmanifest'].includes(ext)) return 'json' + if (['env', 'example'].includes(ext)) return 'sh' + if ( + [ + 'gitignore', + 'prettierignore', + 'log', + 'gitattributes', + 'editorconfig', + 'lock', + 'opts', + 'Dockerfile', + 'dockerignore', + 'npmrc', + 'nvmrc', + ].includes(ext) + ) + return 'txt' + + return ext +} + +interface CodeExplorerProps { + activeTab: 'code' | 'sandbox' + codeSandboxUrl: string + currentCode: string + currentPath: string + examplePath: string + githubContents: GitHubFileNode[] | undefined + library: Library + prefetchFileContent: (path: string) => void + setActiveTab: (tab: 'code' | 'sandbox') => void + setCurrentPath: (path: string) => void + stackBlitzUrl: string +} + +export function CodeExplorer({ + activeTab, + codeSandboxUrl, + currentCode, + currentPath, + examplePath, + githubContents, + library, + prefetchFileContent, + setActiveTab, + setCurrentPath, + stackBlitzUrl, +}: CodeExplorerProps) { + const [isFullScreen, setIsFullScreen] = React.useState(false) + const [isSidebarOpen, setIsSidebarOpen] = React.useState(true) + + // Add escape key handler + React.useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isFullScreen) { + setIsFullScreen(false) + } + } + window.addEventListener('keydown', handleEsc) + return () => window.removeEventListener('keydown', handleEsc) + }, [isFullScreen]) + + // Add sidebar close handler + React.useEffect(() => { + const handleCloseSidebar = () => { + setIsSidebarOpen(false) + } + window.addEventListener('closeSidebar', handleCloseSidebar) + return () => window.removeEventListener('closeSidebar', handleCloseSidebar) + }, []) + + return ( +
+ + +
+
+ +
+ + + {currentCode} + + +
+
+ +
+
+ ) +} diff --git a/app/components/CodeExplorerTopBar.tsx b/app/components/CodeExplorerTopBar.tsx new file mode 100644 index 000000000..b998ca205 --- /dev/null +++ b/app/components/CodeExplorerTopBar.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { FaExpand, FaCompress } from 'react-icons/fa' +import { CgMenuLeft } from 'react-icons/cg' + +interface CodeExplorerTopBarProps { + activeTab: 'code' | 'sandbox' + isFullScreen: boolean + isSidebarOpen: boolean + setActiveTab: (tab: 'code' | 'sandbox') => void + setIsFullScreen: React.Dispatch> + setIsSidebarOpen: (isOpen: boolean) => void +} + +export function CodeExplorerTopBar({ + activeTab, + isFullScreen, + isSidebarOpen, + setActiveTab, + setIsFullScreen, + setIsSidebarOpen, +}: CodeExplorerTopBarProps) { + return ( +
+
+ {activeTab === 'code' ? ( + + ) : ( +
+ +
+ )} + + +
+
+ +
+
+ ) +} diff --git a/app/components/DocsLayout.tsx b/app/components/DocsLayout.tsx index 51ebfa140..3179ffe99 100644 --- a/app/components/DocsLayout.tsx +++ b/app/components/DocsLayout.tsx @@ -400,6 +400,8 @@ export function DocsLayout({ }} activeOptions={{ exact: true, + includeHash: false, + includeSearch: false, }} className="!cursor-pointer relative" > diff --git a/app/components/FileExplorer.tsx b/app/components/FileExplorer.tsx new file mode 100644 index 000000000..dbb85812e --- /dev/null +++ b/app/components/FileExplorer.tsx @@ -0,0 +1,341 @@ +import React from 'react' +import typescriptIconUrl from '~/images/file-icons/typescript.svg?url' +import javascriptIconUrl from '~/images/file-icons/javascript.svg?url' +import cssIconUrl from '~/images/file-icons/css.svg?url' +import htmlIconUrl from '~/images/file-icons/html.svg?url' +import jsonIconUrl from '~/images/file-icons/json.svg?url' +import svelteIconUrl from '~/images/file-icons/svelte.svg?url' +import vueIconUrl from '~/images/file-icons/vue.svg?url' +import textIconUrl from '~/images/file-icons/txt.svg?url' +import type { GitHubFileNode } from '~/utils/documents.server' + +const getFileIconPath = (filename: string) => { + const ext = filename.split('.').pop()?.toLowerCase() || '' + + switch (ext) { + case 'ts': + case 'tsx': + return typescriptIconUrl + case 'js': + case 'jsx': + return javascriptIconUrl + case 'css': + return cssIconUrl + case 'html': + return htmlIconUrl + case 'json': + return jsonIconUrl + case 'svelte': + return svelteIconUrl + case 'vue': + return vueIconUrl + default: + return textIconUrl + } +} + +const FileIcon = ({ filename }: { filename: string }) => { + return ( + {`${filename} + ) +} + +const FolderIcon = ({ isOpen }: { isOpen: boolean }) => ( + + {isOpen ? ( + // Open folder - with visible opening and perspective + <> + + + + ) : ( + // Closed folder - lighter color and simpler shape + + )} + +) + +function getMarginLeft(depth: number) { + return `${depth * 16 + 4}px` +} + +interface FileExplorerProps { + currentPath: string | null + githubContents: GitHubFileNode[] | undefined + isSidebarOpen: boolean + libraryColor: string + prefetchFileContent: (file: string) => void + setCurrentPath: (file: string) => void +} + +export function FileExplorer({ + currentPath, + githubContents, + isSidebarOpen, + libraryColor, + prefetchFileContent, + setCurrentPath, +}: FileExplorerProps) { + const [sidebarWidth, setSidebarWidth] = React.useState(220) + const [isResizing, setIsResizing] = React.useState(false) + const MIN_SIDEBAR_WIDTH = 60 + + // Initialize expandedFolders with root-level folders + const [expandedFolders, setExpandedFolders] = React.useState>( + () => { + const expanded = new Set() + if (githubContents) { + const flattened = recursiveFlattenGithubContents(githubContents) + if (flattened.every((f) => f.depth === 0)) { + return expanded + } + + // if the currentPath matches, then open + for (const file of flattened) { + if (file.path === currentPath) { + // Open all ancestors directories + const dirs = flattedOnlyToDirs(githubContents) + const ancestors = file.path.split('/').slice(0, -1) + + while (ancestors.length > 0) { + const ancestor = ancestors.join('/') + + if (dirs.some((d) => d.path === ancestor)) { + expanded.add(ancestor) + ancestors.pop() + } else { + break + } + } + + break + } + } + } + return expanded + } + ) + + const startResizeRef = React.useRef({ + startX: 0, + startWidth: 0, + }) + + const startResize = (e: React.MouseEvent | React.TouchEvent) => { + setIsResizing(true) + startResizeRef.current = { + startX: 'touches' in e ? e.touches[0].clientX : e.clientX, + startWidth: sidebarWidth, + } + } + + React.useEffect(() => { + const handleMouseMove = (e: MouseEvent | TouchEvent) => { + if (!isResizing) return + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX + const diff = clientX - startResizeRef.current.startX + const newWidth = startResizeRef.current.startWidth + diff + + if (newWidth >= MIN_SIDEBAR_WIDTH && newWidth <= 600) { + setSidebarWidth(newWidth) + } else if (newWidth < MIN_SIDEBAR_WIDTH) { + setSidebarWidth(MIN_SIDEBAR_WIDTH) + } + } + + const handleMouseUp = () => { + setIsResizing(false) + if (sidebarWidth <= MIN_SIDEBAR_WIDTH) { + setSidebarWidth(200) + const event = new CustomEvent('closeSidebar') + window.dispatchEvent(event) + } + } + + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + document.addEventListener('touchmove', handleMouseMove) + document.addEventListener('touchend', handleMouseUp) + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.removeEventListener('touchmove', handleMouseMove) + document.removeEventListener('touchend', handleMouseUp) + } + }, [isResizing, sidebarWidth]) + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev) + if (next.has(path)) { + next.delete(path) + } else { + next.add(path) + } + return next + }) + } + + if (!githubContents) return null + + return ( + <> +
+ {githubContents && isSidebarOpen ? ( +
+ +
+ ) : null} +
+
+ + ) +} + +const RenderFileTree = (props: { + files: GitHubFileNode[] | undefined + libraryColor: string + toggleFolder: (path: string) => void + prefetchFileContent: (file: string) => void + expandedFolders: Set + currentPath: string | null + setCurrentPath: (file: string) => void +}) => { + if (!props.files) return null + + return ( +
    + {props.files.map((file, index) => ( +
  • + {/* Tree lines */} + {file.depth > 0 && ( + <> + {/* Vertical line */} +
    + {/* Horizontal line */} +
    + + )} +
    + +
    + {file.children && props.expandedFolders.has(file.path) && ( + + )} +
  • + ))} +
+ ) +} + +function recursiveFlattenGithubContents( + nodes: Array, + bannedDirs: Set = new Set() +): Array { + return nodes.flatMap((node) => { + if (node.type === 'dir' && node.children && !bannedDirs.has(node.name)) { + return recursiveFlattenGithubContents(node.children, bannedDirs) + } + return node + }) +} + +function flattedOnlyToDirs( + nodes: Array +): Array { + return nodes.flatMap((node) => { + if (node.type === 'dir' && node.children) { + return [node, ...flattedOnlyToDirs(node.children)] + } + return node.type === 'dir' ? [node] : [] + }) +} diff --git a/app/components/InteractiveSandbox.tsx b/app/components/InteractiveSandbox.tsx new file mode 100644 index 000000000..194719c69 --- /dev/null +++ b/app/components/InteractiveSandbox.tsx @@ -0,0 +1,35 @@ +import React from 'react' + +interface InteractiveSandboxProps { + isActive: boolean + codeSandboxUrl: string + stackBlitzUrl: string + examplePath: string + libraryName: string + embedEditor: 'codesandbox' | 'stackblitz' +} + +export function InteractiveSandbox({ + isActive, + codeSandboxUrl, + stackBlitzUrl, + examplePath, + libraryName, + embedEditor, +}: InteractiveSandboxProps) { + return ( +
+