1'use client'
2
3import { motion } from 'framer-motion'
4import Layout from './Layout'
5import { Link } from 'nextjs13-progress'
6import {
7 ChevronRight,
8 FileText,
9 Folder,
10 Download,
11 Copy,
12 Check,
13 Eye,
14 EyeOff,
15 GitBranch,
16 WrapText,
17 ZoomIn,
18 ZoomOut,
19} from 'lucide-react'
20import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
21import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
22import {
23 getFileExtension,
24 getLanguageFromExtension,
25 processMarkdownLinks,
26} from '@/lib/fileUtils'
27import markdownStyles from '@/app/styles/markdown.module.css'
28import { useState, useEffect } from 'react'
29import { Button } from '@/components/ui/button'
30import { toast } from 'sonner'
31import { useRouter } from 'next/navigation'
32import {
33 DropdownMenu,
34 DropdownMenuContent,
35 DropdownMenuItem,
36 DropdownMenuTrigger,
37} from '@/components/ui/dropdown-menu'
38
39const fadeInUp = {
40 initial: { opacity: 0, y: 20 },
41 animate: { opacity: 1, y: 0 },
42 transition: { duration: 0.6 },
43}
44
45interface FileContent {
46 type: string
47 name: string
48 path: string
49 size: number
50 commit?: {
51 message: string
52 date: string
53 }
54}
55
56interface FileViewerPageProps {
57 project: {
58 name: string
59 default_branch: string
60 }
61 filePath: string
62 isDirectory: boolean
63 content?: string
64 contents?: FileContent[]
65 branches: string[]
66 currentBranch: string
67}
68
69export function FileViewerPage({
70 project,
71 filePath,
72 isDirectory,
73 content,
74 contents,
75 branches,
76 currentBranch,
77}: FileViewerPageProps) {
78 const [copied, setCopied] = useState(false)
79 const [showLineNumbers, setShowLineNumbers] = useState(true)
80 const router = useRouter()
81 const [highlightedLines, setHighlightedLines] = useState<number[]>([])
82 const pathParts = filePath.split('/')
83 const currentName = pathParts[pathParts.length - 1]
84 const fileExtension = getFileExtension(currentName)
85 const language = getLanguageFromExtension(fileExtension || 'sh')
86 const isMarkdown = fileExtension.toLowerCase() === 'md'
87 const [wordWrap, setWordWrap] = useState(false)
88 const [fontSize, setFontSize] = useState(14)
89
90 useEffect(() => {
91 const hash = window.location.hash
92 if (hash) {
93 const lineRanges = hash.replace('#L', '').split('-')
94 const lines: number[] = []
95
96 if (lineRanges.length === 2) {
97 // Handle range like #L5-L10
98 const start = parseInt(lineRanges[0])
99 const end = parseInt(lineRanges[1])
100 for (let i = start; i <= end; i++) {
101 lines.push(i)
102 }
103 } else {
104 // Handle single line like #L5
105 lines.push(parseInt(lineRanges[0]))
106 }
107
108 setHighlightedLines(lines)
109
110 // Scroll to the first highlighted line
111 if (lines.length > 0) {
112 const element = document.getElementById(`L${lines[0]}`)
113 element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
114 }
115 }
116 }, [])
117
118 const handleLineNumberClick = (
119 lineNumber: number,
120 event: React.MouseEvent,
121 ) => {
122 const isShiftPressed = event.shiftKey
123 let newHash = ''
124
125 if (isShiftPressed && highlightedLines.length > 0) {
126 // Create a range selection
127 const lastLine = highlightedLines[highlightedLines.length - 1]
128 newHash = `#L${Math.min(lastLine, lineNumber)}-L${Math.max(lastLine, lineNumber)}`
129 } else {
130 // Single line selection
131 newHash = `#L${lineNumber}`
132 }
133
134 window.history.pushState(null, '', newHash)
135 const lines =
136 newHash.includes('-') ?
137 range(
138 parseInt(newHash.split('-')[0].replace('#L', '')),
139 parseInt(newHash.split('-')[1].replace('L', '')),
140 )
141 : [lineNumber]
142 setHighlightedLines(lines)
143 }
144
145 const range = (start: number, end: number) => {
146 return Array.from({ length: end - start + 1 }, (_, i) => start + i)
147 }
148
149 const syntaxHighlighterProps = {
150 language: language,
151 style: vscDarkPlus,
152 showLineNumbers: showLineNumbers,
153 customStyle: {
154 margin: 0,
155 borderRadius: '0.5rem',
156 background: 'transparent',
157 fontSize: `${fontSize}px`,
158 whiteSpace: wordWrap ? 'pre-wrap' : 'pre',
159 },
160 wrapLines: true,
161 lineProps: (lineNumber: number) => ({
162 style: {
163 display: 'block',
164 cursor: 'pointer',
165 backgroundColor:
166 highlightedLines.includes(lineNumber) ?
167 'rgba(255, 255, 255, 0.1)'
168 : 'transparent',
169 },
170 id: `L${lineNumber}`,
171 onClick: (e: React.MouseEvent) => handleLineNumberClick(lineNumber, e),
172 onContextMenu: (e: React.MouseEvent) => {
173 e.preventDefault()
174 handleShareLine(lineNumber)
175 },
176 }),
177 lineNumberStyle: (lineNumber: number) => ({
178 cursor: 'pointer',
179 ':hover': {
180 color: '#fff',
181 },
182 color: highlightedLines.includes(lineNumber) ? '#fff' : undefined,
183 }),
184 }
185
186 const handleCopyContent = async () => {
187 try {
188 await navigator.clipboard.writeText(content || '')
189 setCopied(true)
190 toast.success('Copied to clipboard!')
191 setTimeout(() => setCopied(false), 2000)
192 } catch (err) {
193 toast.error('Failed to copy content')
194 }
195 }
196
197 const sizeToHumanReadable = (size: number) => {
198 // bytes, KB, MB, GB
199 if (size < 1024) return `${size} bytes`
200 if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`
201 if (size < 1024 * 1024 * 1024)
202 return `${(size / (1024 * 1024)).toFixed(2)} MB`
203 return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`
204 }
205
206 const handleDownload = () => {
207 const blob = new Blob([content || ''], { type: 'text/plain' })
208 const url = window.URL.createObjectURL(blob)
209 const a = document.createElement('a')
210 a.href = url
211 a.download = currentName
212 document.body.appendChild(a)
213 a.click()
214 window.URL.revokeObjectURL(url)
215 document.body.removeChild(a)
216 toast.success('File downloaded!')
217 }
218
219 const handleShareLine = (lineNumber: number) => {
220 const url = `${window.location.origin}${window.location.pathname}#L${lineNumber}`
221 navigator.clipboard.writeText(url)
222 toast.success('Link copied to clipboard!')
223 }
224
225 return (
226 <Layout>
227 <div className="container mx-auto py-12">
228 <motion.div
229 className="space-y-8"
230 initial="initial"
231 animate="animate"
232 variants={{
233 initial: { opacity: 0 },
234 animate: { opacity: 1, transition: { staggerChildren: 0.1 } },
235 }}
236 >
237 {/* Breadcrumb */}
238 <motion.div
239 variants={fadeInUp}
240 className="flex items-center space-x-2"
241 >
242 <Link
243 href={`/projects/${project.name}`}
244 className="hover:text-blue-400"
245 >
246 {project.name}
247 </Link>
248 {pathParts.map((part, index) => (
249 <div key={index} className="flex items-center space-x-2">
250 <ChevronRight className="h-4 w-4" />
251 <Link
252 href={`/projects/${project.name}/${currentBranch}/~/${pathParts
253 .slice(0, index + 1)
254 .join('/')}`}
255 className="hover:text-blue-400"
256 >
257 {part}
258 </Link>
259 </div>
260 ))}
261 </motion.div>
262
263 {/* Content */}
264 <motion.div variants={fadeInUp} className="space-y-4">
265 <div className="flex items-center justify-between">
266 <div className="flex items-center space-x-2">
267 {isDirectory ?
268 <Folder className="h-6 w-6" />
269 : <FileText className="h-6 w-6" />}
270 <h1 className="text-2xl font-bold">{currentName}</h1>
271 </div>
272
273 {!isDirectory && (
274 <div className="flex flex-wrap items-center gap-2">
275 {/* Branch selector */}
276 <DropdownMenu>
277 <DropdownMenuTrigger asChild>
278 <Button variant="ghost" size="sm" title="Switch branch">
279 <GitBranch className="mr-2 h-4 w-4" />
280 <span className="hidden sm:inline">
281 {currentBranch}
282 </span>
283 </Button>
284 </DropdownMenuTrigger>
285 <DropdownMenuContent>
286 {
287 // eslint-disable-next-line @typescript-eslint/no-explicit-any
288 branches.map((branch: any) => (
289 <DropdownMenuItem
290 key={branch.name}
291 onClick={() => {
292 router.push(
293 `/projects/${project.name}/${branch.name}/~/${filePath}`,
294 )
295 }}
296 >
297 {branch.name}
298 </DropdownMenuItem>
299 ))
300 }
301 </DropdownMenuContent>
302 </DropdownMenu>
303
304 {/* Controls group */}
305 <div className="flex items-center gap-1">
306 <Button
307 variant="ghost"
308 size="sm"
309 onClick={() => setFontSize(Math.max(10, fontSize - 1))}
310 title="Decrease font size"
311 >
312 <ZoomOut className="h-4 w-4" />
313 </Button>
314 <Button
315 variant="ghost"
316 size="sm"
317 onClick={() => setFontSize(Math.min(24, fontSize + 1))}
318 title="Increase font size"
319 >
320 <ZoomIn className="h-4 w-4" />
321 </Button>
322 </div>
323
324 <div className="flex items-center gap-1">
325 <Button
326 variant="ghost"
327 size="sm"
328 onClick={() => setWordWrap(!wordWrap)}
329 title={
330 wordWrap ? 'Disable word wrap' : 'Enable word wrap'
331 }
332 >
333 <WrapText className="h-4 w-4" />
334 </Button>
335 <Button
336 variant="ghost"
337 size="sm"
338 onClick={() => setShowLineNumbers(!showLineNumbers)}
339 title={
340 showLineNumbers ? 'Hide line numbers' : (
341 'Show line numbers'
342 )
343 }
344 >
345 {showLineNumbers ?
346 <EyeOff className="h-4 w-4" />
347 : <Eye className="h-4 w-4" />}
348 </Button>
349 </div>
350
351 <div className="flex items-center gap-1">
352 <Button
353 variant="ghost"
354 size="sm"
355 onClick={handleCopyContent}
356 title="Copy content"
357 >
358 {copied ?
359 <Check className="h-4 w-4 text-green-500" />
360 : <Copy className="h-4 w-4" />}
361 </Button>
362 <Button
363 variant="ghost"
364 size="sm"
365 onClick={handleDownload}
366 title="Download file"
367 >
368 <Download className="h-4 w-4" />
369 </Button>
370 </div>
371 </div>
372 )}
373 </div>
374
375 {isDirectory ?
376 // Directory listing
377 <div className="overflow-x-auto rounded-lg bg-gray-800/50 p-4 backdrop-blur-sm">
378 <table className="w-full min-w-[640px]">
379 <thead>
380 <tr className="text-left">
381 <th className="pb-4">Name</th>
382 <th className="pb-4">Size</th>
383 <th className="pb-4">Last Commit</th>
384 </tr>
385 </thead>
386 <tbody>
387 {contents
388 ?.sort((a, b) => {
389 // Sort directories first, then files
390 if (a.type === 'dir' && b.type !== 'dir') return -1
391 if (a.type !== 'dir' && b.type === 'dir') return 1
392 // Then sort alphabetically by name
393 return a.name.localeCompare(b.name)
394 })
395 // eslint-disable-next-line @typescript-eslint/no-explicit-any
396 .map((item: any) => (
397 <tr
398 key={item.name}
399 className="border-t border-gray-700"
400 >
401 <td className="py-3">
402 <Link
403 href={`/projects/${project.name}/${currentBranch}/~/${item.path}`}
404 className="flex items-center space-x-2 hover:text-blue-400"
405 >
406 {item.type === 'dir' ?
407 <Folder className="h-4 w-4" />
408 : <FileText className="h-4 w-4" />}
409 <span>{item.name}</span>
410 </Link>
411 </td>
412 <td className="py-3">
413 {sizeToHumanReadable(item.size)}
414 </td>
415 <td className="py-3 text-sm text-gray-400">
416 {item.commit?.message || 'No commits yet'}
417 {item.commit && (
418 <span className="ml-2 text-xs text-gray-500">
419 {new Date(
420 item.commit.date,
421 ).toLocaleDateString()}
422 </span>
423 )}
424 </td>
425 </tr>
426 ))}
427 </tbody>
428 </table>
429 </div>
430 : isMarkdown ?
431 // Markdown content
432 <div
433 className={`${markdownStyles.markdown} prose prose-invert max-w-none rounded-lg bg-gray-800/50 p-6 backdrop-blur-sm`}
434 dangerouslySetInnerHTML={{
435 __html: processMarkdownLinks(
436 content || '',
437 project.name,
438 currentBranch,
439 ),
440 }}
441 />
442 // Code content
443 : <div className="rounded-lg bg-gray-800/50 backdrop-blur-sm">
444 <SyntaxHighlighter {...syntaxHighlighterProps}>
445 {content || ''}
446 </SyntaxHighlighter>
447 </div>
448 }
449 </motion.div>
450 </motion.div>
451 </div>
452 </Layout>
453 )
454}
455