readme file is now rendering in repo. can now view files and edit them. can switch between branches with dropdown menu
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useRepo, useRepoBlob, useUpdateBlob } from '../api/queries/repos'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
|
||||
export default function BlobPage() {
|
||||
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [commitMsg, setCommitMsg] = useState('')
|
||||
const [preview, setPreview] = useState(false)
|
||||
|
||||
const ref = searchParams.get('ref') ?? ''
|
||||
const filePath = searchParams.get('path') ?? ''
|
||||
const fileName = filePath.split('/').pop() ?? filePath
|
||||
|
||||
const { data: repo } = useRepo(owner, repoName)
|
||||
const { data: blob, isLoading, isError } = useRepoBlob(owner, repoName, ref, filePath)
|
||||
const updateBlob = useUpdateBlob(owner, repoName)
|
||||
|
||||
const branch = ref || repo?.defaultBranch || 'main'
|
||||
const isMarkdown = fileName.toLowerCase().endsWith('.md')
|
||||
|
||||
function startEdit() {
|
||||
setEditContent(blob?.content ?? '')
|
||||
setCommitMsg(`Update ${fileName}`)
|
||||
setEditing(true)
|
||||
setPreview(false)
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditing(false)
|
||||
setPreview(false)
|
||||
}
|
||||
|
||||
async function handleCommit() {
|
||||
if (!commitMsg.trim() || !filePath) return
|
||||
await updateBlob.mutateAsync({
|
||||
path: filePath,
|
||||
content: editContent,
|
||||
message: commitMsg.trim(),
|
||||
branch,
|
||||
})
|
||||
setEditing(false)
|
||||
navigate(`/repos/${owner}/${repoName}/blob?ref=${encodeURIComponent(branch)}&path=${encodeURIComponent(filePath)}`, { replace: true })
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-6"><RepoListSkeleton /></div>
|
||||
if (isError || !blob) return <div className="p-6 text-sm text-[#DE350B]">File not found.</div>
|
||||
|
||||
const lines = blob.content.split('\n')
|
||||
const pathParts = filePath.split('/')
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-6 py-6 space-y-4">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1 text-sm flex-wrap">
|
||||
<Link to="/repos" className="text-[#0052CC] hover:underline">Repositories</Link>
|
||||
<span className="text-[#5E6C84]">/</span>
|
||||
<Link to={`/repos/${owner}/${repoName}`} className="text-[#0052CC] hover:underline">{repoName}</Link>
|
||||
{pathParts.map((seg, i) => {
|
||||
const partial = pathParts.slice(0, i + 1).join('/')
|
||||
const isLast = i === pathParts.length - 1
|
||||
return (
|
||||
<span key={partial} className="flex items-center gap-1">
|
||||
<span className="text-[#5E6C84]">/</span>
|
||||
{isLast
|
||||
? <span className="font-semibold text-[#172B4D]">{seg}</span>
|
||||
: <Link to={`/repos/${owner}/${repoName}?path=${encodeURIComponent(partial)}&ref=${encodeURIComponent(branch)}`} className="text-[#0052CC] hover:underline">{seg}</Link>
|
||||
}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* File card */}
|
||||
<div className="border border-[#DFE1E6] rounded bg-white overflow-hidden">
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[#DFE1E6] bg-[#FAFBFC] gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{/* Branch pill */}
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 border border-[#DFE1E6] rounded text-xs text-[#5E6C84] bg-white">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||
</svg>
|
||||
{branch}
|
||||
</span>
|
||||
<span className="text-[#5E6C84]">{repoName}</span>
|
||||
<span className="text-[#5E6C84]">/</span>
|
||||
<span className="font-medium text-[#172B4D]">{fileName}</span>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(filePath)}
|
||||
className="text-[#5E6C84] hover:text-[#172B4D]"
|
||||
title="Copy path"
|
||||
>
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!editing && (
|
||||
<div className="flex items-center gap-1">
|
||||
{isMarkdown && (
|
||||
<button
|
||||
onClick={() => setPreview(p => !p)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded border ${preview ? 'border-[#0052CC] text-[#0052CC] bg-[#DEEBFF]' : 'border-[#DFE1E6] text-[#5E6C84] hover:bg-[#F4F5F7]'}`}
|
||||
>
|
||||
{preview ? 'Source' : 'Preview'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={startEdit}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-[#DFE1E6] rounded text-[#172B4D] hover:bg-[#F4F5F7] flex items-center gap-1.5"
|
||||
>
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(blob.content)}
|
||||
className="px-3 py-1.5 text-xs font-medium border border-[#DFE1E6] rounded text-[#5E6C84] hover:bg-[#F4F5F7]"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{editing ? (
|
||||
<div className="flex flex-col">
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
className="w-full font-mono text-xs text-[#172B4D] bg-white p-4 resize-none focus:outline-none border-b border-[#DFE1E6]"
|
||||
style={{ minHeight: Math.max(300, lines.length * 20) }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="p-4 bg-[#FAFBFC] border-t border-[#DFE1E6] space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Commit message</label>
|
||||
<input
|
||||
value={commitMsg}
|
||||
onChange={e => setCommitMsg(e.target.value)}
|
||||
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
|
||||
placeholder="Describe your changes…"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={updateBlob.isPending || !commitMsg.trim()}
|
||||
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50"
|
||||
>
|
||||
{updateBlob.isPending ? 'Committing…' : 'Commit changes'}
|
||||
</button>
|
||||
<button onClick={cancelEdit} className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7]">
|
||||
Cancel
|
||||
</button>
|
||||
{updateBlob.isError && (
|
||||
<span className="text-xs text-[#DE350B]">{(updateBlob.error as Error)?.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isMarkdown && preview ? (
|
||||
<div className="px-6 py-5 prose prose-sm max-w-none text-[#172B4D]
|
||||
prose-headings:text-[#172B4D] prose-headings:font-semibold prose-headings:border-b prose-headings:border-[#DFE1E6] prose-headings:pb-1
|
||||
prose-a:text-[#0052CC] prose-code:bg-[#F4F5F7] prose-code:px-1 prose-code:rounded
|
||||
prose-pre:bg-[#F4F5F7] prose-pre:border prose-pre:border-[#DFE1E6] prose-pre:rounded">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{blob.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse font-mono text-xs">
|
||||
<tbody>
|
||||
{lines.map((line, i) => (
|
||||
<tr key={i} className="hover:bg-[#FFFBDD]">
|
||||
<td className="select-none text-right text-[#5E6C84] px-4 py-0.5 w-12 border-r border-[#DFE1E6] bg-[#FAFBFC] sticky left-0">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="px-4 py-0.5 text-[#172B4D] whitespace-pre">{line || ' '}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user