index.tsx163 lines · main
1import { useEffect, useState } from 'react'
2import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
3import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
4
5interface MultipleCodeBlockFile {
6 name: string
7 code: string
8 language?: string
9}
10
11interface MultipleCodeBlockProps {
12 files: MultipleCodeBlockFile[]
13 value?: string
14 onValueChange?: (value: string) => void
15}
16
17const languageAliases: Record<string, CodeBlockLang> = {
18 bash: 'bash',
19 csharp: 'csharp',
20 cs: 'csharp',
21 curl: 'curl',
22 dart: 'dart',
23 go: 'go',
24 http: 'http',
25 javascript: 'js',
26 js: 'js',
27 json: 'json',
28 jsx: 'jsx',
29 kotlin: 'kotlin',
30 pgsql: 'pgsql',
31 php: 'php',
32 py: 'python',
33 python: 'python',
34 sh: 'bash',
35 shell: 'bash',
36 sql: 'sql',
37 swift: 'swift',
38 ts: 'ts',
39 typescript: 'ts',
40 yaml: 'yaml',
41 yml: 'yaml',
42}
43
44const extensionLanguageMap: Record<string, CodeBlockLang> = {
45 astro: 'html',
46 bash: 'bash',
47 cjs: 'js',
48 dart: 'dart',
49 go: 'go',
50 js: 'js',
51 json: 'json',
52 jsx: 'jsx',
53 kt: 'kotlin',
54 mjs: 'js',
55 php: 'php',
56 pgsql: 'pgsql',
57 py: 'python',
58 sh: 'bash',
59 sql: 'sql',
60 swift: 'swift',
61 svelte: 'html',
62 ts: 'ts',
63 vue: 'html',
64 yaml: 'yaml',
65 yml: 'yaml',
66}
67
68const inferLanguageFromName = (name: string): CodeBlockLang | undefined => {
69 const lowerName = name.toLowerCase()
70 if (lowerName.startsWith('.env')) {
71 return 'bash'
72 }
73
74 const extension = lowerName.split('.').pop()
75 if (!extension || extension === lowerName) {
76 return undefined
77 }
78
79 return extensionLanguageMap[extension]
80}
81
82const resolveLanguage = (language: string | undefined, name: string): CodeBlockLang => {
83 if (language) {
84 const normalized = language.toLowerCase()
85 const resolved = languageAliases[normalized]
86 if (resolved) {
87 return resolved
88 }
89 }
90
91 return inferLanguageFromName(name) ?? 'js'
92}
93
94export const MultipleCodeBlock = ({ files, value, onValueChange }: MultipleCodeBlockProps) => {
95 if (!files?.length) {
96 return null
97 }
98
99 const defaultValue = files[0]?.name ?? ''
100 const isControlled = value !== undefined
101 const [internalValue, setInternalValue] = useState(defaultValue)
102
103 const trimmedFiles = files.map((file) => ({
104 ...file,
105 code: typeof file.code === 'string' ? file.code.trim() : file.code,
106 }))
107
108 useEffect(() => {
109 if (isControlled) return
110
111 setInternalValue((currentValue) => {
112 const currentValueExists = files.some((file) => file.name === currentValue)
113 return currentValueExists ? currentValue : defaultValue
114 })
115 }, [defaultValue, files, isControlled])
116
117 const activeValue = isControlled ? value : internalValue
118
119 const handleValueChange = (nextValue: string) => {
120 if (!isControlled) {
121 setInternalValue(nextValue)
122 }
123
124 onValueChange?.(nextValue)
125 }
126
127 return (
128 <Tabs_Shadcn_
129 value={activeValue}
130 onValueChange={handleValueChange}
131 className="border rounded-lg gap-0 space-y-0 overflow-hidden"
132 >
133 <TabsList_Shadcn_ className="bg-surface-75 px-5 gap-5 overflow-x-auto border-0 border-b">
134 {files.map((file) => (
135 <TabsTrigger_Shadcn_
136 key={file.name}
137 value={file.name}
138 className="flex items-center gap-1 text-xs px-0 data-[state=active]:bg-transparent py-2.5"
139 >
140 {file.name}
141 </TabsTrigger_Shadcn_>
142 ))}
143 </TabsList_Shadcn_>
144
145 {trimmedFiles.map((file) => (
146 <TabsContent_Shadcn_
147 key={file.name}
148 value={file.name}
149 forceMount
150 className="p-0 max-h-72 overflow-scroll data-[state=inactive]:hidden"
151 data-connect-tab-content
152 data-tab-label={file.name}
153 >
154 <CodeBlock
155 value={typeof file.code === 'string' ? file.code.trim() : file.code}
156 language={resolveLanguage(file.language, file.name)}
157 className="min-h-72 !bg-surface-75 rounded-none border-0"
158 />
159 </TabsContent_Shadcn_>
160 ))}
161 </Tabs_Shadcn_>
162 )
163}