frontend changes
This commit is contained in:
parent
69def8560b
commit
2176100b0e
11
src/app/github/repo/[id]/page.tsx
Normal file
11
src/app/github/repo/[id]/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import RepoByIdClient from "./repo-client"
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
export const dynamicParams = true
|
||||||
|
export function generateStaticParams() { return [] }
|
||||||
|
|
||||||
|
export default function Page({ params, searchParams }: { params: { id: string }, searchParams?: { path?: string } }) {
|
||||||
|
const id = params.id
|
||||||
|
const path = (searchParams?.path as string) || ""
|
||||||
|
return <RepoByIdClient repositoryId={id} initialPath={path} />
|
||||||
|
}
|
||||||
125
src/app/github/repo/[id]/repo-client.tsx
Normal file
125
src/app/github/repo/[id]/repo-client.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { getRepositoryStructure, getRepositoryFileContent, type RepoStructureEntry } from "@/lib/api/github"
|
||||||
|
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search } from "lucide-react"
|
||||||
|
|
||||||
|
export default function RepoByIdClient({ repositoryId, initialPath = "" }: { repositoryId: string, initialPath?: string }) {
|
||||||
|
const [path, setPath] = useState(initialPath)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [entries, setEntries] = useState<RepoStructureEntry[]>([])
|
||||||
|
const [fileQuery, setFileQuery] = useState("")
|
||||||
|
const [readme, setReadme] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const struct = await getRepositoryStructure(repositoryId, path)
|
||||||
|
if (!mounted) return
|
||||||
|
setEntries(struct?.structure || [])
|
||||||
|
// try README at this path
|
||||||
|
const candidates = ["README.md", "readme.md", "README.MD"]
|
||||||
|
for (const name of candidates) {
|
||||||
|
try {
|
||||||
|
const fp = path ? `${path}/${name}` : name
|
||||||
|
const content = await getRepositoryFileContent(repositoryId, fp)
|
||||||
|
if (content?.content) { setReadme(content.content); break }
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { mounted = false }
|
||||||
|
}, [repositoryId, path])
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const q = fileQuery.toLowerCase()
|
||||||
|
if (!q) return entries
|
||||||
|
return entries.filter(e => e.name.toLowerCase().includes(q))
|
||||||
|
}, [entries, fileQuery])
|
||||||
|
|
||||||
|
const navigateFolder = (name: string) => {
|
||||||
|
const next = path ? `${path}/${name}` : name
|
||||||
|
setPath(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goUp = () => {
|
||||||
|
if (!path) return
|
||||||
|
const parts = path.split("/")
|
||||||
|
parts.pop()
|
||||||
|
setPath(parts.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/github/repos">
|
||||||
|
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Repos
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-semibold truncate">Repository #{repositoryId}</h1>
|
||||||
|
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-emerald-900/40 text-emerald-300 border border-emerald-800">Attached</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2">
|
||||||
|
<Button variant="outline" className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">
|
||||||
|
<GitBranch className="h-4 w-4 mr-2"/> main
|
||||||
|
</Button>
|
||||||
|
<div className="relative ml-auto w-full sm:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-white/40"/>
|
||||||
|
<Input value={fileQuery} onChange={e=>setFileQuery(e.target.value)} placeholder="Go to file" className="pl-9 h-8 text-sm bg-white/5 border-white/10 text-white placeholder:text-white/40"/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={goUp} className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">Up one level</Button>
|
||||||
|
<Button className="h-8 text-sm bg-emerald-600 hover:bg-emerald-500 text-black">
|
||||||
|
<Code className="h-4 w-4 mr-2"/> Code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading && (
|
||||||
|
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||||
|
)}
|
||||||
|
{!loading && visible.length === 0 && (
|
||||||
|
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||||
|
)}
|
||||||
|
{visible.map((e, i) => (
|
||||||
|
<div key={i} className="flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer"
|
||||||
|
onClick={() => e.type === 'directory' ? navigateFolder(e.name) : void 0}>
|
||||||
|
<div className="w-7 flex justify-center">
|
||||||
|
{e.type === 'directory' ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 font-medium truncate">{e.name}</div>
|
||||||
|
<div className="w-40 text-right text-sm text-white/60 flex items-center justify-end gap-1">
|
||||||
|
<Clock className="h-4 w-4"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-white/5 border-white/10">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold">README</div>
|
||||||
|
{!readme ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<BookText className="h-10 w-10 mx-auto text-white/60"/>
|
||||||
|
<h3 className="mt-3 text-xl font-semibold">No README found</h3>
|
||||||
|
<p className="mt-1 text-white/60 text-sm">Add a README.md to the repository to show it here.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="p-4 whitespace-pre-wrap text-sm text-white/90">{readme}</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/app/github/repo/page.tsx
Normal file
17
src/app/github/repo/page.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import RepoByIdClient from "./repo-client"
|
||||||
|
|
||||||
|
export default function Page({ searchParams }: { searchParams?: { id?: string; path?: string } }) {
|
||||||
|
const id = (searchParams?.id as string) || ""
|
||||||
|
const path = (searchParams?.path as string) || ""
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-10 text-white/80">
|
||||||
|
<h1 className="text-2xl font-semibold">Repository</h1>
|
||||||
|
<p className="mt-2">Missing repository id. Go back to <a href="/github/repos" className="text-orange-400 underline">My GitHub Repositories</a>.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RepoByIdClient repositoryId={id} initialPath={path} />
|
||||||
|
}
|
||||||
175
src/app/github/repo/repo-client.tsx
Normal file
175
src/app/github/repo/repo-client.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { getRepositoryStructure, getRepositoryFileContent, getRepositoryCommitSummary, type RepoStructureEntry, type CommitSummaryResponse } from "@/lib/api/github"
|
||||||
|
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search, GitCommit } from "lucide-react"
|
||||||
|
|
||||||
|
export default function RepoByIdClient({ repositoryId, initialPath = "" }: { repositoryId: string, initialPath?: string }) {
|
||||||
|
const [path, setPath] = useState(initialPath)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [entries, setEntries] = useState<RepoStructureEntry[]>([])
|
||||||
|
const [fileQuery, setFileQuery] = useState("")
|
||||||
|
const [readme, setReadme] = useState<string | null>(null)
|
||||||
|
const [commitSummary, setCommitSummary] = useState<CommitSummaryResponse | null>(null)
|
||||||
|
|
||||||
|
const timeAgo = (iso?: string) => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso).getTime()
|
||||||
|
const diff = Math.max(0, Date.now() - d)
|
||||||
|
const s = Math.floor(diff/1000)
|
||||||
|
if (s < 60) return `${s}s ago`
|
||||||
|
const m = Math.floor(s/60)
|
||||||
|
if (m < 60) return `${m}m ago`
|
||||||
|
const h = Math.floor(m/60)
|
||||||
|
if (h < 24) return `${h}h ago`
|
||||||
|
const days = Math.floor(h/24)
|
||||||
|
if (days < 30) return `${days}d ago`
|
||||||
|
const mo = Math.floor(days/30)
|
||||||
|
if (mo < 12) return `${mo}mo ago`
|
||||||
|
const y = Math.floor(mo/12)
|
||||||
|
return `${y}y ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const struct = await getRepositoryStructure(repositoryId, path)
|
||||||
|
if (!mounted) return
|
||||||
|
setEntries(struct?.structure || [])
|
||||||
|
// Only fetch README if it exists in the current directory listing
|
||||||
|
const readmeEntry = (struct?.structure || []).find(e => e.type === 'file' && /^readme\.(md|markdown)$/i.test(e.name))
|
||||||
|
if (readmeEntry) {
|
||||||
|
try {
|
||||||
|
const fp = path ? `${path}/${readmeEntry.name}` : readmeEntry.name
|
||||||
|
const content = await getRepositoryFileContent(repositoryId, fp)
|
||||||
|
if (content?.content) { setReadme(content.content) } else { setReadme(null) }
|
||||||
|
} catch { setReadme(null) }
|
||||||
|
} else {
|
||||||
|
setReadme(null)
|
||||||
|
}
|
||||||
|
// fetch commit summary (only on root path)
|
||||||
|
if (!path) {
|
||||||
|
try {
|
||||||
|
const summary = await getRepositoryCommitSummary(repositoryId)
|
||||||
|
if (mounted) setCommitSummary(summary)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { mounted = false }
|
||||||
|
}, [repositoryId, path])
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const q = fileQuery.toLowerCase()
|
||||||
|
if (!q) return entries
|
||||||
|
return entries.filter(e => e.name.toLowerCase().includes(q))
|
||||||
|
}, [entries, fileQuery])
|
||||||
|
|
||||||
|
const navigateFolder = (name: string) => {
|
||||||
|
const next = path ? `${path}/${name}` : name
|
||||||
|
setPath(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goUp = () => {
|
||||||
|
if (!path) return
|
||||||
|
const parts = path.split("/")
|
||||||
|
parts.pop()
|
||||||
|
setPath(parts.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/github/repos">
|
||||||
|
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Repos
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-semibold truncate">Repository #{repositoryId}</h1>
|
||||||
|
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-emerald-900/40 text-emerald-300 border border-emerald-800">Attached</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2">
|
||||||
|
<Button variant="outline" className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">
|
||||||
|
<GitBranch className="h-4 w-4 mr-2"/> main
|
||||||
|
</Button>
|
||||||
|
<div className="relative ml-auto w-full sm:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-white/40"/>
|
||||||
|
<Input value={fileQuery} onChange={e=>setFileQuery(e.target.value)} placeholder="Go to file" className="pl-9 h-8 text-sm bg-white/5 border-white/10 text-white placeholder:text-white/40"/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={goUp} className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">Up one level</Button>
|
||||||
|
<Button className="h-8 text-sm bg-emerald-600 hover:bg-emerald-500 text-black">
|
||||||
|
<Code className="h-4 w-4 mr-2"/> Code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commit summary row */}
|
||||||
|
{commitSummary?.last_commit && (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||||
|
<div className="rounded-full bg-white/10 p-2"><GitCommit className="h-4 w-4 text-white"/></div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-white truncate">
|
||||||
|
<span className="font-semibold">{commitSummary.last_commit.author_name}</span>
|
||||||
|
<span className="mx-2 text-white/60">{commitSummary.last_commit.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/60">
|
||||||
|
{commitSummary.last_commit.short_hash} • {timeAgo(commitSummary.last_commit.committed_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-white/80 shrink-0">
|
||||||
|
<span className="px-2 py-1 rounded-full bg-white/10 border border-white/10">{commitSummary.total_commits} commits</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading && (
|
||||||
|
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||||
|
)}
|
||||||
|
{!loading && visible.length === 0 && (
|
||||||
|
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||||
|
)}
|
||||||
|
{visible.map((e, i) => {
|
||||||
|
const displayName = (e as any).name || (e as any).filename || (e as any).relative_path || (e as any).path || '(unknown)'
|
||||||
|
const isDir = e.type === 'directory' || (e as any).type === 'dir'
|
||||||
|
return (
|
||||||
|
<div key={(e as any).path || (e as any).relative_path || displayName + i} className="flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer"
|
||||||
|
onClick={() => isDir ? navigateFolder(displayName) : void 0}>
|
||||||
|
<div className="w-7 flex justify-center">
|
||||||
|
{isDir ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 font-medium truncate text-white">{displayName}</div>
|
||||||
|
<div className="w-40 text-right text-sm text-white/60 flex items-center justify-end gap-1">
|
||||||
|
<Clock className="h-4 w-4"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-white/5 border-white/10">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold">README</div>
|
||||||
|
{!readme ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<BookText className="h-10 w-10 mx-auto text-white/60"/>
|
||||||
|
<h3 className="mt-3 text-xl font-semibold">No README found</h3>
|
||||||
|
<p className="mt-1 text-white/60 text-sm">Add a README.md to the repository to show it here.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre className="p-4 whitespace-pre-wrap text-sm text-white/90">{readme}</pre>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
src/app/github/repos/page.tsx
Normal file
168
src/app/github/repos/page.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ArrowLeft, ExternalLink, FolderGit2, GitFork, Star, Shield, Search } from "lucide-react"
|
||||||
|
import { getGitHubAuthStatus, getUserRepositories, type GitHubRepoSummary } from "@/lib/api/github"
|
||||||
|
|
||||||
|
export default function GitHubUserReposPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [authLoading, setAuthLoading] = useState(true)
|
||||||
|
const [connected, setConnected] = useState<boolean>(false)
|
||||||
|
const [authUrl, setAuthUrl] = useState<string | null>(null)
|
||||||
|
const [repos, setRepos] = useState<GitHubRepoSummary[]>([])
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
setAuthLoading(true)
|
||||||
|
const status = await getGitHubAuthStatus()
|
||||||
|
if (!mounted) return
|
||||||
|
setConnected(Boolean(status?.data?.connected))
|
||||||
|
setAuthUrl(status?.data?.auth_url || null)
|
||||||
|
} catch (_) {
|
||||||
|
setConnected(false)
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const list = await getUserRepositories()
|
||||||
|
if (!mounted) return
|
||||||
|
setRepos(Array.isArray(list) ? list : [])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { mounted = false }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return repos
|
||||||
|
return repos.filter(r =>
|
||||||
|
(r.full_name || "").toLowerCase().includes(q) ||
|
||||||
|
(r.description || "").toLowerCase().includes(q) ||
|
||||||
|
(r.language || "").toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}, [repos, query])
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
|
||||||
|
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
|
||||||
|
const base = authUrl || "/api/github/auth/github"
|
||||||
|
const url = `${base}?redirect=1${userId ? `&user_id=${encodeURIComponent(userId)}` : ''}`
|
||||||
|
window.location.href = url
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-8 space-y-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/project-builder">
|
||||||
|
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Builder
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold">My GitHub Repositories</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 h-5 w-5" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name, description, or language..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="pl-10 h-11 text-base border border-white/10 bg-white/5 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!connected && (
|
||||||
|
<Button onClick={handleConnect} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||||
|
<Shield className="mr-2 h-4 w-4" /> Connect GitHub
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(authLoading || loading) && (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!authLoading && !connected && repos.length === 0 && (
|
||||||
|
<Card className="bg-white/5 border-white/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FolderGit2 className="h-5 w-5 text-orange-400" />
|
||||||
|
Connect your GitHub account
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-white/70 space-y-4">
|
||||||
|
<p>Connect GitHub to view your private repositories and enable one-click attach to projects.</p>
|
||||||
|
<Button onClick={handleConnect} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||||
|
<Shield className="mr-2 h-4 w-4" /> Connect GitHub
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div className="text-center text-white/60 py-16">
|
||||||
|
<p>No repositories found{query ? ' for your search.' : '.'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divide-y divide-white/10 rounded-lg border border-white/10 overflow-hidden">
|
||||||
|
{filtered.map((repo) => (
|
||||||
|
<div key={repo.id || repo.full_name} className="p-5 hover:bg-white/5 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{repo.html_url ? (
|
||||||
|
<a href={repo.html_url} target="_blank" rel="noopener noreferrer" className="font-semibold text-orange-400 hover:text-orange-300 truncate">
|
||||||
|
{repo.full_name || repo.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold truncate">{repo.full_name || repo.name}</span>
|
||||||
|
)}
|
||||||
|
<Badge className={repo.visibility === 'private' ? 'bg-rose-900/40 text-rose-300 border border-rose-800' : 'bg-emerald-900/40 text-emerald-300 border border-emerald-800'}>
|
||||||
|
{repo.visibility === 'private' ? 'Private' : 'Public'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/70 mt-1 line-clamp-2">
|
||||||
|
{repo.description || 'No description provided.'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-white/60">
|
||||||
|
{repo.language && (
|
||||||
|
<span className="inline-flex items-center gap-1">{repo.language}</span>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1"><Star className="h-4 w-4 text-yellow-400" /> {repo.stargazers_count ?? 0}</span>
|
||||||
|
<span className="inline-flex items-center gap-1"><GitFork className="h-4 w-4 text-blue-400" /> {repo.forks_count ?? 0}</span>
|
||||||
|
{repo.updated_at && (
|
||||||
|
<span className="inline-flex items-center gap-1">Updated {new Date(repo.updated_at).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/github/repo?id=${encodeURIComponent(String((repo as any).id ?? ''))}`}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Button variant="outline" className="border-white/20 text-white hover:bg-white/10">
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" /> Open
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/components/github/ViewUserReposButton.tsx
Normal file
22
src/components/github/ViewUserReposButton.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { FolderGit2 } from "lucide-react"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string
|
||||||
|
size?: "default" | "sm" | "lg" | "icon"
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewUserReposButton({ className, size = "default", label = "View My Repos" }: Props) {
|
||||||
|
return (
|
||||||
|
<Link href="/github/repos" prefetch>
|
||||||
|
<Button className={className} size={size} variant="default">
|
||||||
|
<FolderGit2 className="mr-2 h-5 w-5" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ import { DualCanvasEditor } from "@/components/dual-canvas-editor"
|
|||||||
import { getAccessToken } from "@/components/apis/authApiClients"
|
import { getAccessToken } from "@/components/apis/authApiClients"
|
||||||
import TechStackSummary from "@/components/tech-stack-summary"
|
import TechStackSummary from "@/components/tech-stack-summary"
|
||||||
import { attachRepository, getGitHubAuthStatus } from "@/lib/api/github"
|
import { attachRepository, getGitHubAuthStatus } from "@/lib/api/github"
|
||||||
|
import ViewUserReposButton from "@/components/github/ViewUserReposButton"
|
||||||
|
|
||||||
interface Template {
|
interface Template {
|
||||||
id: string
|
id: string
|
||||||
@ -730,6 +731,11 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right-aligned quick navigation to user repos */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ViewUserReposButton className="bg-orange-500 hover:bg-orange-400 text-black" label="My GitHub Repos" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="max-w-2xl mx-auto relative">
|
<div className="max-w-2xl mx-auto relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 h-5 w-5" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-white/40 h-5 w-5" />
|
||||||
|
|||||||
@ -6,6 +6,108 @@ export interface AttachRepositoryPayload {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -------- Repository contents --------
|
||||||
|
export interface RepoStructureEntry {
|
||||||
|
name: string
|
||||||
|
type: 'file' | 'directory'
|
||||||
|
size?: number
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoStructureResponse {
|
||||||
|
repository_id: string
|
||||||
|
directory_path: string
|
||||||
|
structure: RepoStructureEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryStructure(repositoryId: string, path: string = ''): Promise<RepoStructureResponse> {
|
||||||
|
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/structure${path ? `?path=${encodeURIComponent(path)}` : ''}`
|
||||||
|
const res = await authApiClient.get(url)
|
||||||
|
return res.data?.data as RepoStructureResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileContentResponse {
|
||||||
|
file_info: {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
file_extension?: string
|
||||||
|
relative_path: string
|
||||||
|
file_size_bytes?: number
|
||||||
|
mime_type?: string
|
||||||
|
is_binary?: boolean
|
||||||
|
language_detected?: string
|
||||||
|
line_count?: number
|
||||||
|
char_count?: number
|
||||||
|
}
|
||||||
|
content: string | null
|
||||||
|
preview?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryFileContent(repositoryId: string, filePath: string): Promise<FileContentResponse> {
|
||||||
|
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/file-content?file_path=${encodeURIComponent(filePath)}`
|
||||||
|
const res = await authApiClient.get(url)
|
||||||
|
return res.data?.data as FileContentResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitSummaryResponse {
|
||||||
|
last_commit: {
|
||||||
|
hash: string
|
||||||
|
short_hash: string
|
||||||
|
author_name: string
|
||||||
|
author_email: string
|
||||||
|
committed_at: string
|
||||||
|
message: string
|
||||||
|
} | null
|
||||||
|
total_commits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryCommitSummary(repositoryId: string): Promise<CommitSummaryResponse> {
|
||||||
|
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/commit-summary`
|
||||||
|
const res = await authApiClient.get(url)
|
||||||
|
return res.data?.data as CommitSummaryResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PathCommitResponse {
|
||||||
|
hash: string
|
||||||
|
short_hash: string
|
||||||
|
author_name: string
|
||||||
|
author_email: string
|
||||||
|
committed_at: string
|
||||||
|
message: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPathCommit(repositoryId: string, relPath: string): Promise<PathCommitResponse | null> {
|
||||||
|
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/path-commit?path=${encodeURIComponent(relPath)}`
|
||||||
|
const res = await authApiClient.get(url)
|
||||||
|
return (res.data?.data as PathCommitResponse) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitsListResponse {
|
||||||
|
items: Array<{
|
||||||
|
hash: string
|
||||||
|
short_hash: string
|
||||||
|
author_name: string
|
||||||
|
author_email: string
|
||||||
|
committed_at: string
|
||||||
|
message: string
|
||||||
|
}>
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
has_next: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRepositoryCommits(repositoryId: string, opts?: { page?: number; limit?: number; path?: string }): Promise<CommitsListResponse> {
|
||||||
|
const page = opts?.page ?? 1
|
||||||
|
const limit = opts?.limit ?? 20
|
||||||
|
const path = opts?.path ? `&path=${encodeURIComponent(opts.path)}` : ''
|
||||||
|
const url = `/api/github/repository/${encodeURIComponent(repositoryId)}/commits?page=${page}&limit=${limit}${path}`
|
||||||
|
const res = await authApiClient.get(url)
|
||||||
|
return res.data?.data as CommitsListResponse
|
||||||
|
}
|
||||||
|
|
||||||
export interface AttachRepositoryResponse<T = unknown> {
|
export interface AttachRepositoryResponse<T = unknown> {
|
||||||
success: boolean
|
success: boolean
|
||||||
message?: string
|
message?: string
|
||||||
@ -47,3 +149,70 @@ export async function getGitHubAuthStatus(): Promise<AttachRepositoryResponse<Gi
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -------- User repositories (UI-only helper) --------
|
||||||
|
export interface GitHubRepoSummary {
|
||||||
|
id?: string | number
|
||||||
|
full_name: string
|
||||||
|
name: string
|
||||||
|
owner?: { login?: string } | null
|
||||||
|
description?: string | null
|
||||||
|
visibility?: 'public' | 'private'
|
||||||
|
stargazers_count?: number
|
||||||
|
forks_count?: number
|
||||||
|
language?: string | null
|
||||||
|
updated_at?: string
|
||||||
|
html_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tries backend gateway route first. If backend does not yet provide it, returns an empty list gracefully.
|
||||||
|
export async function getUserRepositories(): Promise<GitHubRepoSummary[]> {
|
||||||
|
try {
|
||||||
|
const rawUser = (typeof window !== 'undefined') ? localStorage.getItem('codenuk_user') : null
|
||||||
|
const userId = rawUser ? (JSON.parse(rawUser)?.id || null) : null
|
||||||
|
// Prefer path param route; fallback to legacy query-based if gateway not updated
|
||||||
|
const primaryUrl = userId ? `/api/github/user/${encodeURIComponent(userId)}/repositories` : '/api/github/user/repositories'
|
||||||
|
let res
|
||||||
|
try {
|
||||||
|
res = await authApiClient.get(primaryUrl)
|
||||||
|
} catch (e: any) {
|
||||||
|
const fallbackUrl = userId ? `/api/github/user/repos?user_id=${encodeURIComponent(userId)}` : '/api/github/user/repos'
|
||||||
|
res = await authApiClient.get(fallbackUrl)
|
||||||
|
}
|
||||||
|
const data = res?.data?.data || res?.data
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
// If data already looks like GitHubRepoSummary, return as-is
|
||||||
|
if (data.length === 0) return []
|
||||||
|
const looksLike = (item: any) => item && (item.full_name || (item.name && item.owner))
|
||||||
|
if (looksLike(data[0])) return data as GitHubRepoSummary[]
|
||||||
|
|
||||||
|
// Normalize rows coming from github_repositories table with parsed metadata/codebase_analysis
|
||||||
|
const normalized = data.map((r: any) => {
|
||||||
|
const md = r?.metadata || {}
|
||||||
|
const owner = r?.owner_name || md?.owner?.login || (typeof md?.full_name === 'string' ? md.full_name.split('/')[0] : undefined)
|
||||||
|
const name = r?.repository_name || md?.name || (typeof md?.full_name === 'string' ? md.full_name.split('/')[1] : undefined) || r?.repo
|
||||||
|
const full = md?.full_name || (owner && name ? `${owner}/${name}` : r?.repository_url)
|
||||||
|
return {
|
||||||
|
id: r?.id,
|
||||||
|
full_name: full,
|
||||||
|
name: name,
|
||||||
|
owner: owner ? { login: owner } : undefined,
|
||||||
|
description: md?.description || null,
|
||||||
|
visibility: md?.visibility || (r?.is_public ? 'public' : 'private'),
|
||||||
|
stargazers_count: md?.stargazers_count || 0,
|
||||||
|
forks_count: md?.forks_count || 0,
|
||||||
|
language: md?.language || null,
|
||||||
|
updated_at: md?.updated_at || r?.updated_at,
|
||||||
|
html_url: md?.html_url || (full ? `https://github.com/${full}` : undefined),
|
||||||
|
} as GitHubRepoSummary
|
||||||
|
})
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
} catch (e: any) {
|
||||||
|
// If endpoint not found or unauthorized, surface as empty for now (UI design requirement)
|
||||||
|
const status = e?.response?.status
|
||||||
|
if (status === 404 || status === 401) return []
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user