From 46cab693db39cccc25a52151eb96adcce0cc9bbd Mon Sep 17 00:00:00 2001 From: Ahmet Kilinc Date: Sat, 20 Dec 2025 02:43:11 +0000 Subject: [PATCH] mvp --- README.md | 125 ++- actions/repositories.ts | 302 +++++++ app/(auth)/layout.tsx | 27 + app/(auth)/login/page.tsx | 97 ++ app/(auth)/register/page.tsx | 153 ++++ .../[username]/[repo]/blob/[...path]/page.tsx | 114 +++ app/(main)/[username]/[repo]/page.tsx | 131 +++ .../[username]/[repo]/tree/[...path]/page.tsx | 83 ++ app/(main)/[username]/page.tsx | 71 ++ app/(main)/layout.tsx | 18 + app/(main)/new/page.tsx | 202 +++++ app/(main)/page.tsx | 166 ++++ app/api/auth/[...all]/route.ts | 5 + app/api/git/[...path]/route.ts | 258 ++++++ app/globals.css | 173 +++- app/layout.tsx | 23 +- app/not-found.tsx | 27 + app/page.tsx | 65 -- bun.lock | 833 ++++++++++++++++++ components.json | 22 + components/clone-url.tsx | 76 ++ components/code-viewer.tsx | 88 ++ components/file-tree.tsx | 78 ++ components/header.tsx | 103 +++ components/repo-list.tsx | 70 ++ components/ui/avatar.tsx | 53 ++ components/ui/badge.tsx | 46 + components/ui/button.tsx | 62 ++ components/ui/card.tsx | 92 ++ components/ui/dialog.tsx | 143 +++ components/ui/dropdown-menu.tsx | 257 ++++++ components/ui/form.tsx | 167 ++++ components/ui/input.tsx | 21 + components/ui/label.tsx | 24 + components/ui/separator.tsx | 28 + components/ui/sonner.tsx | 40 + components/ui/textarea.tsx | 18 + db/index.ts | 8 + db/schema.ts | 68 ++ drizzle.config.ts | 11 + lib/auth-client.ts | 16 + lib/auth.ts | 33 + lib/query-client.tsx | 23 + lib/r2-fs.ts | 155 ++++ lib/r2-git-sync.ts | 116 +++ lib/r2.ts | 98 +++ lib/session.ts | 9 + lib/utils.ts | 6 + package.json | 39 +- 49 files changed, 4725 insertions(+), 118 deletions(-) create mode 100644 actions/repositories.ts create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/register/page.tsx create mode 100644 app/(main)/[username]/[repo]/blob/[...path]/page.tsx create mode 100644 app/(main)/[username]/[repo]/page.tsx create mode 100644 app/(main)/[username]/[repo]/tree/[...path]/page.tsx create mode 100644 app/(main)/[username]/page.tsx create mode 100644 app/(main)/layout.tsx create mode 100644 app/(main)/new/page.tsx create mode 100644 app/(main)/page.tsx create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/git/[...path]/route.ts create mode 100644 app/not-found.tsx delete mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/clone-url.tsx create mode 100644 components/code-viewer.tsx create mode 100644 components/file-tree.tsx create mode 100644 components/header.tsx create mode 100644 components/repo-list.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 db/index.ts create mode 100644 db/schema.ts create mode 100644 drizzle.config.ts create mode 100644 lib/auth-client.ts create mode 100644 lib/auth.ts create mode 100644 lib/query-client.tsx create mode 100644 lib/r2-fs.ts create mode 100644 lib/r2-git-sync.ts create mode 100644 lib/r2.ts create mode 100644 lib/session.ts create mode 100644 lib/utils.ts diff --git a/README.md b/README.md index e215bc4..854f42c 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,121 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# gitbruv + +A GitHub clone built with Next.js, featuring real Git repository support with Cloudflare R2 storage. + +## Tech Stack + +- **Framework**: Next.js 16 (App Router) +- **Auth**: better-auth (email/password) +- **Database**: PostgreSQL + Drizzle ORM +- **Storage**: Cloudflare R2 (S3-compatible) +- **UI**: shadcn/ui + Tailwind CSS +- **Data Fetching**: TanStack React Query +- **Git**: isomorphic-git + Git HTTP Smart Protocol ## Getting Started -First, run the development server: +### Prerequisites + +- Node.js 18+ or Bun +- PostgreSQL database +- Git installed on your system +- Cloudflare account with R2 enabled + +### Cloudflare R2 Setup + +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → R2 +2. Create a bucket named `gitbruv-repos` +3. Go to R2 → Manage R2 API Tokens → Create API Token +4. Select "Object Read & Write" permissions +5. Copy the Account ID, Access Key ID, and Secret Access Key + +### Setup + +1. Clone and install dependencies: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +2. Create a `.env` file with the following variables: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +``` +DATABASE_URL=postgresql://postgres:password@localhost:5432/gitbruv +BETTER_AUTH_SECRET=your-secret-key-here-at-least-32-characters +BETTER_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +# Cloudflare R2 Configuration +R2_ACCOUNT_ID=your-cloudflare-account-id +R2_ACCESS_KEY_ID=your-r2-access-key-id +R2_SECRET_ACCESS_KEY=your-r2-secret-access-key +R2_BUCKET_NAME=gitbruv-repos +``` -## Learn More +3. Push the database schema: -To learn more about Next.js, take a look at the following resources: +```bash +bun run db:push +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +4. Start the development server: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +bun run dev +``` -## Deploy on Vercel +5. Open [http://localhost:3000](http://localhost:3000) -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Features -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- User authentication (sign up, sign in, sign out) +- Create public/private repositories +- Browse repository files +- View file contents with syntax highlighting +- Clone repositories via HTTP +- Push to repositories via HTTP (with authentication) +- Markdown README rendering +- Cloud storage with Cloudflare R2 (zero egress fees) + +## Project Structure + +``` +app/ +├── (auth)/ # Auth pages (login, register) +├── (main)/ # Main app pages +│ ├── [username]/ # User profile & repos +│ └── new/ # Create repository +├── api/ +│ ├── auth/ # better-auth API +│ └── git/ # Git HTTP Smart Protocol +actions/ # Server actions +components/ # React components +db/ # Database schema & connection +lib/ # Utilities, auth config, R2 integration +``` + +## Git Operations + +Clone a repository: + +```bash +git clone http://localhost:3000/api/git/username/repo.git +``` + +Push to a repository (requires auth): + +```bash +git push origin main +# Enter your email and password when prompted +``` + +## Architecture + +Git repositories are stored in Cloudflare R2 as bare repos. When git operations occur: + +1. Repository files are synced from R2 to a temp directory +2. Git commands execute against the temp directory +3. For push operations, changes are synced back to R2 +4. Temp directory is cleaned up + +This allows serverless deployment while maintaining full Git compatibility. diff --git a/actions/repositories.ts b/actions/repositories.ts new file mode 100644 index 0000000..1393e1b --- /dev/null +++ b/actions/repositories.ts @@ -0,0 +1,302 @@ +"use server"; + +import { db } from "@/db"; +import { repositories, users } from "@/db/schema"; +import { getSession } from "@/lib/session"; +import { eq, and, desc } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import git from "isomorphic-git"; +import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs"; + +export async function createRepository(data: { + name: string; + description?: string; + visibility: "public" | "private"; +}) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const normalizedName = data.name.toLowerCase().replace(/\s+/g, "-"); + + if (!/^[a-zA-Z0-9_.-]+$/.test(normalizedName)) { + throw new Error("Invalid repository name"); + } + + const existing = await db.query.repositories.findFirst({ + where: and( + eq(repositories.ownerId, session.user.id), + eq(repositories.name, normalizedName) + ), + }); + + if (existing) { + throw new Error("Repository already exists"); + } + + const [repo] = await db + .insert(repositories) + .values({ + name: normalizedName, + description: data.description || null, + visibility: data.visibility, + ownerId: session.user.id, + }) + .returning(); + + const repoPrefix = getRepoPrefix(session.user.id, `${normalizedName}.git`); + const fs = createR2Fs(repoPrefix); + + await fs.writeFile("/HEAD", "ref: refs/heads/main\n"); + await fs.writeFile("/config", `[core] +\trepositoryformatversion = 0 +\tfilemode = true +\tbare = true +`); + await fs.writeFile("/description", "Unnamed repository; edit this file to name the repository.\n"); + + const username = (session.user as { username?: string }).username; + revalidatePath(`/${username}`); + revalidatePath("/"); + + return repo; +} + +export async function getRepository(owner: string, name: string) { + const user = await db.query.users.findFirst({ + where: eq(users.username, owner), + }); + + if (!user) { + return null; + } + + const repo = await db.query.repositories.findFirst({ + where: and( + eq(repositories.ownerId, user.id), + eq(repositories.name, name) + ), + }); + + if (!repo) { + return null; + } + + const session = await getSession(); + if (repo.visibility === "private") { + if (!session?.user || session.user.id !== repo.ownerId) { + return null; + } + } + + return { + ...repo, + owner: { + id: user.id, + username: user.username, + name: user.name, + image: user.image, + }, + }; +} + +export async function getUserRepositories(username: string) { + const user = await db.query.users.findFirst({ + where: eq(users.username, username), + }); + + if (!user) { + return []; + } + + const session = await getSession(); + const isOwner = session?.user?.id === user.id; + + const repos = await db.query.repositories.findMany({ + where: isOwner + ? eq(repositories.ownerId, user.id) + : and( + eq(repositories.ownerId, user.id), + eq(repositories.visibility, "public") + ), + orderBy: [desc(repositories.updatedAt)], + }); + + return repos; +} + +export async function deleteRepository(repoId: string) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const repo = await db.query.repositories.findFirst({ + where: eq(repositories.id, repoId), + }); + + if (!repo) { + throw new Error("Repository not found"); + } + + if (repo.ownerId !== session.user.id) { + throw new Error("Unauthorized"); + } + + const { r2DeletePrefix } = await import("@/lib/r2"); + const repoPrefix = getRepoPrefix(session.user.id, `${repo.name}.git`); + + try { + await r2DeletePrefix(repoPrefix); + } catch { + } + + await db.delete(repositories).where(eq(repositories.id, repoId)); + + const username = (session.user as { username?: string }).username; + revalidatePath(`/${username}`); + revalidatePath("/"); +} + +export async function getRepoFileTree( + owner: string, + repoName: string, + branch: string, + dirPath: string = "" +) { + const user = await db.query.users.findFirst({ + where: eq(users.username, owner), + }); + + if (!user) { + return null; + } + + const repoPrefix = getRepoPrefix(user.id, `${repoName}.git`); + const fs = createR2Fs(repoPrefix); + + try { + const commits = await git.log({ + fs, + gitdir: "/", + ref: branch, + depth: 1, + }); + + if (commits.length === 0) { + return { files: [], isEmpty: true }; + } + + const commitOid = commits[0].oid; + + const { tree } = await git.readTree({ + fs, + gitdir: "/", + oid: commitOid, + }); + + let targetTree = tree; + + if (dirPath) { + const parts = dirPath.split("/").filter(Boolean); + for (const part of parts) { + const entry = targetTree.find((e) => e.path === part && e.type === "tree"); + if (!entry) { + return { files: [], isEmpty: false }; + } + const subTree = await git.readTree({ + fs, + gitdir: "/", + oid: entry.oid, + }); + targetTree = subTree.tree; + } + } + + const entries = targetTree.map((entry) => ({ + name: entry.path, + type: entry.type as "blob" | "tree", + oid: entry.oid, + path: dirPath ? `${dirPath}/${entry.path}` : entry.path, + })); + + entries.sort((a, b) => { + if (a.type === "tree" && b.type !== "tree") return -1; + if (a.type !== "tree" && b.type === "tree") return 1; + return a.name.localeCompare(b.name); + }); + + return { files: entries, isEmpty: false }; + } catch (err: unknown) { + const error = err as { code?: string }; + if (error.code !== "NotFoundError") { + console.error("getRepoFileTree error:", err); + } + return { files: [], isEmpty: true }; + } +} + +export async function getRepoFile( + owner: string, + repoName: string, + branch: string, + filePath: string +) { + const user = await db.query.users.findFirst({ + where: eq(users.username, owner), + }); + + if (!user) { + return null; + } + + const repoPrefix = getRepoPrefix(user.id, `${repoName}.git`); + const fs = createR2Fs(repoPrefix); + + try { + const commits = await git.log({ + fs, + gitdir: "/", + ref: branch, + depth: 1, + }); + + if (commits.length === 0) { + return null; + } + + const commitOid = commits[0].oid; + const parts = filePath.split("/").filter(Boolean); + const fileName = parts.pop()!; + + let currentTree = (await git.readTree({ fs, gitdir: "/", oid: commitOid })).tree; + + for (const part of parts) { + const entry = currentTree.find((e) => e.path === part && e.type === "tree"); + if (!entry) return null; + currentTree = (await git.readTree({ fs, gitdir: "/", oid: entry.oid })).tree; + } + + const fileEntry = currentTree.find((e) => e.path === fileName && e.type === "blob"); + if (!fileEntry) return null; + + const { blob } = await git.readBlob({ + fs, + gitdir: "/", + oid: fileEntry.oid, + }); + + const decoder = new TextDecoder("utf-8"); + const content = decoder.decode(blob); + + return { + content, + oid: fileEntry.oid, + path: filePath, + }; + } catch (err) { + console.error("getRepoFile error:", err); + return null; + } +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..f839b3d --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,27 @@ +import { GitBranch } from "lucide-react"; +import Link from "next/link"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+
+
+
+
+ +
+ +
+ gitbruv + + {children} +
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..7dfb639 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { signIn } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +export default function LoginPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + try { + const { error } = await signIn.email({ + email, + password, + }); + + if (error) { + toast.error(error.message || "Failed to sign in"); + return; + } + + toast.success("Welcome back!"); + router.push("/"); + router.refresh(); + } catch { + toast.error("Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Sign in to gitbruv

+
+
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + className="bg-input/50 h-11" + /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + className="bg-input/50 h-11" + /> +
+ +
+
+
+

+ New to gitbruv?{" "} + + Create an account + +

+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..4272298 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { signUpWithUsername } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +export default function RegisterPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + name: "", + username: "", + email: "", + password: "", + }); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + if (formData.username.length < 3) { + toast.error("Username must be at least 3 characters"); + setLoading(false); + return; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) { + toast.error("Username can only contain letters, numbers, hyphens, and underscores"); + setLoading(false); + return; + } + + try { + const { error } = await signUpWithUsername({ + email: formData.email, + password: formData.password, + name: formData.name, + username: formData.username.toLowerCase(), + }); + + if (error) { + toast.error(error.message || "Failed to create account"); + return; + } + + toast.success("Account created successfully!"); + router.push("/"); + router.refresh(); + } catch { + toast.error("Something went wrong"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Create your account

+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="John Doe" + required + className="bg-input/50 h-11" + /> +
+
+ + setFormData({ ...formData, username: e.target.value })} + placeholder="johndoe" + required + className="bg-input/50 h-11" + /> +

+ This will be your unique identifier on gitbruv +

+
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="you@example.com" + required + className="bg-input/50 h-11" + /> +
+
+ + setFormData({ ...formData, password: e.target.value })} + placeholder="••••••••" + required + minLength={8} + className="bg-input/50 h-11" + /> +

+ Must be at least 8 characters +

+
+ +
+
+
+

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/app/(main)/[username]/[repo]/blob/[...path]/page.tsx b/app/(main)/[username]/[repo]/blob/[...path]/page.tsx new file mode 100644 index 0000000..ebf6911 --- /dev/null +++ b/app/(main)/[username]/[repo]/blob/[...path]/page.tsx @@ -0,0 +1,114 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { getRepository, getRepoFile } from "@/actions/repositories"; +import { CodeViewer } from "@/components/code-viewer"; +import { Badge } from "@/components/ui/badge"; +import { Lock, Globe, ChevronRight, Home, FileCode } from "lucide-react"; + +const LANGUAGE_MAP: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + java: "java", + md: "markdown", + json: "json", + yaml: "yaml", + yml: "yaml", + css: "css", + html: "html", + sh: "bash", + bash: "bash", + zsh: "bash", +}; + +function getLanguage(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() || ""; + return LANGUAGE_MAP[ext] || "text"; +} + +export default async function BlobPage({ params }: { params: Promise<{ username: string; repo: string; path: string[] }> }) { + const { username, repo: repoName, path: pathSegments } = await params; + const branch = pathSegments[0]; + const filePath = pathSegments.slice(1).join("/"); + + const repo = await getRepository(username, repoName); + + if (!repo) { + notFound(); + } + + const file = await getRepoFile(username, repoName, branch, filePath); + + if (!file) { + notFound(); + } + + const pathParts = filePath.split("/").filter(Boolean); + const fileName = pathParts[pathParts.length - 1]; + const language = getLanguage(fileName); + const lineCount = file.content.split("\n").length; + + return ( +
+
+
+ + {username} + + / + + {repoName} + + + {repo.visibility === "private" ? ( + <> + + Private + + ) : ( + <> + + Public + + )} + +
+
+ +
+ + +
+
+ + {lineCount} lines +
+
+ + +
+
+ ); +} diff --git a/app/(main)/[username]/[repo]/page.tsx b/app/(main)/[username]/[repo]/page.tsx new file mode 100644 index 0000000..a8fd128 --- /dev/null +++ b/app/(main)/[username]/[repo]/page.tsx @@ -0,0 +1,131 @@ +import { notFound } from "next/navigation"; +import { getRepository, getRepoFileTree, getRepoFile } from "@/actions/repositories"; +import { FileTree } from "@/components/file-tree"; +import { CodeViewer } from "@/components/code-viewer"; +import { CloneUrl } from "@/components/clone-url"; +import { Badge } from "@/components/ui/badge"; +import { GitBranch, Lock, Globe, FileCode } from "lucide-react"; +import Link from "next/link"; + +export default async function RepoPage({ params }: { params: Promise<{ username: string; repo: string }> }) { + const { username, repo: repoName } = await params; + + const repo = await getRepository(username, repoName); + + if (!repo) { + notFound(); + } + + const fileTree = await getRepoFileTree(username, repoName, repo.defaultBranch); + const readmeFile = fileTree?.files.find((f) => f.name.toLowerCase() === "readme.md" && f.type === "blob"); + + let readmeContent = null; + if (readmeFile) { + const file = await getRepoFile(username, repoName, repo.defaultBranch, readmeFile.name); + readmeContent = file?.content; + } + + return ( +
+
+
+ + {username} + + / +

{repo.name}

+ + {repo.visibility === "private" ? ( + <> + + Private + + ) : ( + <> + + Public + + )} + +
+ +
+ + {repo.description &&

{repo.description}

} + +
+
+
+
+ + {repo.defaultBranch} +
+ + {fileTree?.isEmpty ? ( + + ) : ( + + )} +
+ + {readmeContent && ( +
+
+ + README.md +
+
+ +
+
+ )} +
+ + +
+
+ ); +} + +function EmptyRepoGuide({ username, repoName }: { username: string; repoName: string }) { + const cloneUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/git/${username}/${repoName}.git`; + + return ( +
+
+ +

This repository is empty

+

Get started by cloning or pushing to this repository.

+
+ +
+
+

Create a new repository on the command line

+
+            {`echo "# ${repoName}" >> README.md
+git init
+git add README.md
+git commit -m "first commit"
+git branch -M main
+git remote add origin ${cloneUrl}
+git push -u origin main`}
+          
+
+ +
+

Push an existing repository from the command line

+
+            {`git remote add origin ${cloneUrl}
+git branch -M main
+git push -u origin main`}
+          
+
+
+
+ ); +} diff --git a/app/(main)/[username]/[repo]/tree/[...path]/page.tsx b/app/(main)/[username]/[repo]/tree/[...path]/page.tsx new file mode 100644 index 0000000..232b6a3 --- /dev/null +++ b/app/(main)/[username]/[repo]/tree/[...path]/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { getRepository, getRepoFileTree } from "@/actions/repositories"; +import { FileTree } from "@/components/file-tree"; +import { Badge } from "@/components/ui/badge"; +import { GitBranch, Lock, Globe, ChevronRight, Home } from "lucide-react"; + +export default async function TreePage({ params }: { params: Promise<{ username: string; repo: string; path: string[] }> }) { + const { username, repo: repoName, path: pathSegments } = await params; + const branch = pathSegments[0]; + const dirPath = pathSegments.slice(1).join("/"); + + const repo = await getRepository(username, repoName); + + if (!repo) { + notFound(); + } + + const fileTree = await getRepoFileTree(username, repoName, branch, dirPath); + + if (!fileTree) { + notFound(); + } + + const pathParts = dirPath.split("/").filter(Boolean); + + return ( +
+
+
+ + {username} + + / + + {repoName} + + + {repo.visibility === "private" ? ( + <> + + Private + + ) : ( + <> + + Public + + )} + +
+
+ +
+
+ + {branch} +
+ + + + +
+
+ ); +} diff --git a/app/(main)/[username]/page.tsx b/app/(main)/[username]/page.tsx new file mode 100644 index 0000000..e89f022 --- /dev/null +++ b/app/(main)/[username]/page.tsx @@ -0,0 +1,71 @@ +import { notFound } from "next/navigation"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { getUserRepositories } from "@/actions/repositories"; +import { RepoList } from "@/components/repo-list"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { CalendarDays, GitBranch } from "lucide-react"; +import { format } from "date-fns"; + +export default async function ProfilePage({ + params, +}: { + params: Promise<{ username: string }>; +}) { + const { username } = await params; + + const user = await db.query.users.findFirst({ + where: eq(users.username, username), + }); + + if (!user) { + notFound(); + } + + const repos = await getUserRepositories(username); + + return ( +
+
+ + +
+
+ +

Repositories

+ ({repos.length}) +
+ + {repos.length === 0 ? ( +
+ +

No repositories yet

+

+ {user.name} hasn't created any public repositories. +

+
+ ) : ( + + )} +
+
+
+ ); +} + diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx new file mode 100644 index 0000000..7edc72b --- /dev/null +++ b/app/(main)/layout.tsx @@ -0,0 +1,18 @@ +import { Header } from "@/components/header"; +import { QueryProvider } from "@/lib/query-client"; + +export default function MainLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+
+
{children}
+
+
+ ); +} + diff --git a/app/(main)/new/page.tsx b/app/(main)/new/page.tsx new file mode 100644 index 0000000..208541c --- /dev/null +++ b/app/(main)/new/page.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { createRepository } from "@/actions/repositories"; +import { useSession } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "sonner"; +import { Loader2, Lock, Globe, BookMarked } from "lucide-react"; +import Link from "next/link"; + +export default function NewRepoPage() { + const router = useRouter(); + const { data: session, isPending } = useSession(); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + name: "", + description: "", + visibility: "public" as "public" | "private", + }); + + const username = (session?.user as { username?: string } | undefined)?.username || ""; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!session?.user) { + toast.error("You must be logged in"); + return; + } + + setLoading(true); + + try { + await createRepository({ + name: formData.name, + description: formData.description || undefined, + visibility: formData.visibility, + }); + + toast.success("Repository created!"); + router.push(`/${username}/${formData.name.toLowerCase().replace(/\s+/g, "-")}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to create repository"); + } finally { + setLoading(false); + } + } + + if (isPending) { + return ( +
+
+ +
+
+ ); + } + + if (!session?.user) { + return ( +
+
+ +

Sign in required

+

Please sign in to create a repository

+ +
+
+ ); + } + + return ( +
+
+

Create a new repository

+

A repository contains all project files, including the revision history.

+
+ +
+
+
+ +
+ {username} + / + setFormData({ ...formData, name: e.target.value })} + placeholder="my-awesome-project" + required + pattern="^[a-zA-Z0-9_.-]+$" + className="flex-1 bg-input/50" + /> +
+

Great repository names are short and memorable.

+
+ +
+ +