Skip to content

Commit ca471d7

Browse files
SeanCassiereKevinVandytannerlinsley
authored
feat: add code explorer for the examples (#371)
* Initial attempt at adding static code snippets above sandboxes * convert to file explorer * fix full screen stuff * fetch main file using server fn * use queryOptions * move more to query * move all fetching to Query * no need the assertion * adhere to rules of hooks * starting to wire up fs reads and don't store the entire file in state * initial mocking of the fs fetch * get fs working * make state reactive * make the urls bookmark-able * sort nodes and do not make the route tree depth exponential * more files to ignore * sidebar to retain the active state * more restrictions * fullscreen from current active tab * restrict which folders can be default opened * better icons * more mobile friendly stuff * close sidebar by default on mobile * border cleanup * better fs mocking of the github api * remove unnecessary divs * less layout shift * import icons using `?url` * prepare for determining the starting file path on the server * get the initial filename * eslint rules-of-hooks warning * default open to the current directory * fixup some incorrect fallback behaviour on the starting file * add more preference dirs * break up code explorer ui elements into smaller components * move some state down components tree * improve file explorer tree ui/ux * mobile improvements * add authorization using GITHUB_TOKEN * use `dotenv-cli` instead * update folder icons --------- Co-authored-by: Kevin Van Cott <[email protected]> Co-authored-by: Tanner Linsley <[email protected]>
1 parent e8cd2e5 commit ca471d7

23 files changed

+1312
-46
lines changed

Diff for: app/client.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/// <reference types="vinxi/types/client" />
12
import { hydrateRoot } from 'react-dom/client'
23
import { StartClient } from '@tanstack/start'
34
import { createRouter } from './router'

Diff for: app/components/CodeExplorer.tsx

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import React from 'react'
2+
import { CodeBlock } from '~/components/Markdown'
3+
import { FileExplorer } from './FileExplorer'
4+
import { InteractiveSandbox } from './InteractiveSandbox'
5+
import { CodeExplorerTopBar } from './CodeExplorerTopBar'
6+
import type { GitHubFileNode } from '~/utils/documents.server'
7+
import type { Library } from '~/libraries'
8+
9+
function overrideExtension(ext: string | undefined) {
10+
if (!ext) return 'txt'
11+
12+
// Override some extensions
13+
if (['cts', 'mts'].includes(ext)) return 'ts'
14+
if (['cjs', 'mjs'].includes(ext)) return 'js'
15+
if (['prettierrc', 'babelrc', 'webmanifest'].includes(ext)) return 'json'
16+
if (['env', 'example'].includes(ext)) return 'sh'
17+
if (
18+
[
19+
'gitignore',
20+
'prettierignore',
21+
'log',
22+
'gitattributes',
23+
'editorconfig',
24+
'lock',
25+
'opts',
26+
'Dockerfile',
27+
'dockerignore',
28+
'npmrc',
29+
'nvmrc',
30+
].includes(ext)
31+
)
32+
return 'txt'
33+
34+
return ext
35+
}
36+
37+
interface CodeExplorerProps {
38+
activeTab: 'code' | 'sandbox'
39+
codeSandboxUrl: string
40+
currentCode: string
41+
currentPath: string
42+
examplePath: string
43+
githubContents: GitHubFileNode[] | undefined
44+
library: Library
45+
prefetchFileContent: (path: string) => void
46+
setActiveTab: (tab: 'code' | 'sandbox') => void
47+
setCurrentPath: (path: string) => void
48+
stackBlitzUrl: string
49+
}
50+
51+
export function CodeExplorer({
52+
activeTab,
53+
codeSandboxUrl,
54+
currentCode,
55+
currentPath,
56+
examplePath,
57+
githubContents,
58+
library,
59+
prefetchFileContent,
60+
setActiveTab,
61+
setCurrentPath,
62+
stackBlitzUrl,
63+
}: CodeExplorerProps) {
64+
const [isFullScreen, setIsFullScreen] = React.useState(false)
65+
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true)
66+
67+
// Add escape key handler
68+
React.useEffect(() => {
69+
const handleEsc = (e: KeyboardEvent) => {
70+
if (e.key === 'Escape' && isFullScreen) {
71+
setIsFullScreen(false)
72+
}
73+
}
74+
window.addEventListener('keydown', handleEsc)
75+
return () => window.removeEventListener('keydown', handleEsc)
76+
}, [isFullScreen])
77+
78+
// Add sidebar close handler
79+
React.useEffect(() => {
80+
const handleCloseSidebar = () => {
81+
setIsSidebarOpen(false)
82+
}
83+
window.addEventListener('closeSidebar', handleCloseSidebar)
84+
return () => window.removeEventListener('closeSidebar', handleCloseSidebar)
85+
}, [])
86+
87+
return (
88+
<div
89+
className={`flex flex-col min-h-[60dvh] sm:min-h-[80dvh] border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${
90+
isFullScreen ? 'fixed inset-0 z-50 bg-white dark:bg-gray-900 p-4' : ''
91+
}`}
92+
>
93+
<CodeExplorerTopBar
94+
activeTab={activeTab}
95+
setActiveTab={setActiveTab}
96+
isFullScreen={isFullScreen}
97+
setIsFullScreen={setIsFullScreen}
98+
isSidebarOpen={isSidebarOpen}
99+
setIsSidebarOpen={setIsSidebarOpen}
100+
/>
101+
102+
<div className="relative flex-1">
103+
<div
104+
className={`absolute inset-0 flex ${
105+
activeTab === 'code' ? '' : 'hidden'
106+
}`}
107+
>
108+
<FileExplorer
109+
currentPath={currentPath}
110+
githubContents={githubContents}
111+
isSidebarOpen={isSidebarOpen}
112+
libraryColor={library.bgStyle}
113+
prefetchFileContent={prefetchFileContent}
114+
setCurrentPath={setCurrentPath}
115+
/>
116+
<div className="flex-1 overflow-auto relative">
117+
<CodeBlock
118+
isEmbedded
119+
className={`h-full ${
120+
isFullScreen ? 'max-h-[90dvh]' : 'max-h-[80dvh]'
121+
}`}
122+
>
123+
<code
124+
className={`language-${overrideExtension(
125+
currentPath.split('.').pop()
126+
)}`}
127+
>
128+
{currentCode}
129+
</code>
130+
</CodeBlock>
131+
</div>
132+
</div>
133+
<InteractiveSandbox
134+
isActive={activeTab === 'sandbox'}
135+
codeSandboxUrl={codeSandboxUrl}
136+
stackBlitzUrl={stackBlitzUrl}
137+
examplePath={examplePath}
138+
libraryName={library.name}
139+
embedEditor={library.embedEditor || 'stackblitz'}
140+
/>
141+
</div>
142+
</div>
143+
)
144+
}

Diff for: app/components/CodeExplorerTopBar.tsx

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react'
2+
import { FaExpand, FaCompress } from 'react-icons/fa'
3+
import { CgMenuLeft } from 'react-icons/cg'
4+
5+
interface CodeExplorerTopBarProps {
6+
activeTab: 'code' | 'sandbox'
7+
isFullScreen: boolean
8+
isSidebarOpen: boolean
9+
setActiveTab: (tab: 'code' | 'sandbox') => void
10+
setIsFullScreen: React.Dispatch<React.SetStateAction<boolean>>
11+
setIsSidebarOpen: (isOpen: boolean) => void
12+
}
13+
14+
export function CodeExplorerTopBar({
15+
activeTab,
16+
isFullScreen,
17+
isSidebarOpen,
18+
setActiveTab,
19+
setIsFullScreen,
20+
setIsSidebarOpen,
21+
}: CodeExplorerTopBarProps) {
22+
return (
23+
<div className="flex items-center justify-between gap-2 border-b border-gray-200 dark:border-gray-700">
24+
<div className="flex items-center gap-2 px-1">
25+
{activeTab === 'code' ? (
26+
<button
27+
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
28+
className="p-2 text-sm rounded transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
29+
title={isSidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
30+
>
31+
<CgMenuLeft className="w-4 h-4" />
32+
</button>
33+
) : (
34+
<div className="p-2 text-sm rounded" aria-hidden>
35+
<CgMenuLeft className="w-4 h-4 text-transparent" aria-hidden />
36+
</div>
37+
)}
38+
<button
39+
onClick={() => setActiveTab('code')}
40+
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
41+
activeTab === 'code'
42+
? 'text-gray-900 dark:text-white'
43+
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
44+
}`}
45+
>
46+
<span className="hidden sm:inline">Code Explorer</span>
47+
<span className="sm:hidden">Code</span>
48+
{activeTab === 'code' ? (
49+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500" />
50+
) : (
51+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-transparent" />
52+
)}
53+
</button>
54+
<button
55+
onClick={() => setActiveTab('sandbox')}
56+
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
57+
activeTab === 'sandbox'
58+
? 'text-gray-900 dark:text-white'
59+
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
60+
}`}
61+
>
62+
<span className="hidden sm:inline">Interactive Sandbox</span>
63+
<span className="sm:hidden">Sandbox</span>
64+
{activeTab === 'sandbox' ? (
65+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500" />
66+
) : (
67+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-transparent" />
68+
)}
69+
</button>
70+
</div>
71+
<div className="flex items-center gap-2">
72+
<button
73+
onClick={() => {
74+
setIsFullScreen((prev) => !prev)
75+
}}
76+
className={`p-2 text-sm rounded transition-colors mr-2 hover:bg-gray-200 dark:hover:bg-gray-700 ${
77+
activeTab === 'code'
78+
? 'text-gray-700 dark:text-gray-300'
79+
: 'text-gray-400 dark:text-gray-600'
80+
}`}
81+
title={isFullScreen ? 'Exit full screen' : 'Enter full screen'}
82+
>
83+
{isFullScreen ? (
84+
<FaCompress className="w-4 h-4" />
85+
) : (
86+
<FaExpand className="w-4 h-4" />
87+
)}
88+
</button>
89+
</div>
90+
</div>
91+
)
92+
}

Diff for: app/components/DocsLayout.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,8 @@ export function DocsLayout({
400400
}}
401401
activeOptions={{
402402
exact: true,
403+
includeHash: false,
404+
includeSearch: false,
403405
}}
404406
className="!cursor-pointer relative"
405407
>

0 commit comments

Comments
 (0)