file-viewer-page.tsx

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