diff --git a/src/app/github/repo/[id]/page.tsx b/src/app/github/repo/[id]/page.tsx new file mode 100644 index 0000000..4529eef --- /dev/null +++ b/src/app/github/repo/[id]/page.tsx @@ -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 +} diff --git a/src/app/github/repo/[id]/repo-client.tsx b/src/app/github/repo/[id]/repo-client.tsx new file mode 100644 index 0000000..366b885 --- /dev/null +++ b/src/app/github/repo/[id]/repo-client.tsx @@ -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([]) + const [fileQuery, setFileQuery] = useState("") + const [readme, setReadme] = useState(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 ( +
+
+ + + +

Repository #{repositoryId}

+ Attached +
+ +
+ +
+ + 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"/> +
+ + +
+ + + + {loading && ( +
Loading...
+ )} + {!loading && visible.length === 0 && ( +
No entries found.
+ )} + {visible.map((e, i) => ( +
e.type === 'directory' ? navigateFolder(e.name) : void 0}> +
+ {e.type === 'directory' ? : } +
+
{e.name}
+
+ +
+
+ ))} +
+
+ + + +
README
+ {!readme ? ( +
+ +

No README found

+

Add a README.md to the repository to show it here.

+
+ ) : ( +
{readme}
+ )} +
+
+
+ ) +} diff --git a/src/app/github/repo/page.tsx b/src/app/github/repo/page.tsx new file mode 100644 index 0000000..d589961 --- /dev/null +++ b/src/app/github/repo/page.tsx @@ -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 ( +
+

Repository

+

Missing repository id. Go back to My GitHub Repositories.

+
+ ) + } + + return +} diff --git a/src/app/github/repo/repo-client.tsx b/src/app/github/repo/repo-client.tsx new file mode 100644 index 0000000..6006264 --- /dev/null +++ b/src/app/github/repo/repo-client.tsx @@ -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([]) + const [fileQuery, setFileQuery] = useState("") + const [readme, setReadme] = useState(null) + const [commitSummary, setCommitSummary] = useState(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 ( +
+
+ + + +

Repository #{repositoryId}

+ Attached +
+ +
+ +
+ + 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"/> +
+ + +
+ + {/* Commit summary row */} + {commitSummary?.last_commit && ( +
+
+
+
+ {commitSummary.last_commit.author_name} + {commitSummary.last_commit.message} +
+
+ {commitSummary.last_commit.short_hash} • {timeAgo(commitSummary.last_commit.committed_at)} +
+
+
+ {commitSummary.total_commits} commits +
+
+ )} + + + + {loading && ( +
Loading...
+ )} + {!loading && visible.length === 0 && ( +
No entries found.
+ )} + {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 ( +
isDir ? navigateFolder(displayName) : void 0}> +
+ {isDir ? : } +
+
{displayName}
+
+ +
+
+ )})} +
+
+ + + +
README
+ {!readme ? ( +
+ +

No README found

+

Add a README.md to the repository to show it here.

+
+ ) : ( +
{readme}
+ )} +
+
+
+ ) +} diff --git a/src/app/github/repos/page.tsx b/src/app/github/repos/page.tsx new file mode 100644 index 0000000..834b48c --- /dev/null +++ b/src/app/github/repos/page.tsx @@ -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(false) + const [authUrl, setAuthUrl] = useState(null) + const [repos, setRepos] = useState([]) + 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 ( +
+
+ + + +

My GitHub Repositories

+
+ +
+
+ + 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" + /> +
+ {!connected && ( + + )} +
+ + {(authLoading || loading) && ( +
+
+
+ )} + + {!authLoading && !connected && repos.length === 0 && ( + + + + + Connect your GitHub account + + + +

Connect GitHub to view your private repositories and enable one-click attach to projects.

+ +
+
+ )} + + {!loading && filtered.length === 0 && ( +
+

No repositories found{query ? ' for your search.' : '.'}

+
+ )} + +
+ {filtered.map((repo) => ( +
+
+
+
+ {repo.html_url ? ( + + {repo.full_name || repo.name} + + ) : ( + {repo.full_name || repo.name} + )} + + {repo.visibility === 'private' ? 'Private' : 'Public'} + +
+

+ {repo.description || 'No description provided.'} +

+
+ {repo.language && ( + {repo.language} + )} + {repo.stargazers_count ?? 0} + {repo.forks_count ?? 0} + {repo.updated_at && ( + Updated {new Date(repo.updated_at).toLocaleDateString()} + )} +
+
+ + + +
+
+ ))} +
+
+ ) +} diff --git a/src/components/github/ViewUserReposButton.tsx b/src/components/github/ViewUserReposButton.tsx new file mode 100644 index 0000000..b3f31de --- /dev/null +++ b/src/components/github/ViewUserReposButton.tsx @@ -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 ( + + + + ) +} diff --git a/src/components/main-dashboard.tsx b/src/components/main-dashboard.tsx index f6d06ca..502791c 100644 --- a/src/components/main-dashboard.tsx +++ b/src/components/main-dashboard.tsx @@ -23,6 +23,7 @@ import { DualCanvasEditor } from "@/components/dual-canvas-editor" import { getAccessToken } from "@/components/apis/authApiClients" import TechStackSummary from "@/components/tech-stack-summary" import { attachRepository, getGitHubAuthStatus } from "@/lib/api/github" +import ViewUserReposButton from "@/components/github/ViewUserReposButton" interface Template { id: string @@ -730,6 +731,11 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi )} + {/* Right-aligned quick navigation to user repos */} +
+ +
+
diff --git a/src/lib/api/github.ts b/src/lib/api/github.ts index 9eced34..1155752 100644 --- a/src/lib/api/github.ts +++ b/src/lib/api/github.ts @@ -6,6 +6,108 @@ export interface AttachRepositoryPayload { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { success: boolean message?: string @@ -47,3 +149,70 @@ export async function getGitHubAuthStatus(): Promise { + 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 [] + } +} +