1
0
Fork 0
mirror of https://gitbruv.vercel.app/api/git/bruv/gitbruv.git synced 2025-12-20 23:24:09 +01:00

Merge pull request #1 from ahmetskilinc/mvp

This commit is contained in:
Ahmet Kilinc 2025-12-20 03:27:34 +00:00 committed by GitHub
commit 2483a758c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 5923 additions and 118 deletions

125
README.md
View file

@ -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 ## 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 ```bash
npm run dev bun install
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
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. 4. Start the development server:
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
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.

302
actions/repositories.ts Normal file
View file

@ -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;
}
}

239
actions/settings.ts Normal file
View file

@ -0,0 +1,239 @@
"use server";
import { db } from "@/db";
import { users, accounts, repositories } from "@/db/schema";
import { getSession } from "@/lib/session";
import { eq, and } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { r2Put, r2Delete } from "@/lib/r2";
export async function updateProfile(data: {
name: string;
username: string;
bio?: string;
location?: string;
website?: string;
pronouns?: string;
}) {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const normalizedUsername = data.username.toLowerCase().replace(/\s+/g, "-");
if (!/^[a-zA-Z0-9_-]+$/.test(normalizedUsername)) {
throw new Error("Username can only contain letters, numbers, underscores, and hyphens");
}
if (normalizedUsername.length < 3) {
throw new Error("Username must be at least 3 characters");
}
const existingUser = await db.query.users.findFirst({
where: and(
eq(users.username, normalizedUsername),
),
});
if (existingUser && existingUser.id !== session.user.id) {
throw new Error("Username is already taken");
}
await db
.update(users)
.set({
name: data.name,
username: normalizedUsername,
bio: data.bio || null,
location: data.location || null,
website: data.website || null,
pronouns: data.pronouns || null,
updatedAt: new Date(),
})
.where(eq(users.id, session.user.id));
revalidatePath("/settings");
revalidatePath(`/${normalizedUsername}`);
return { success: true, username: normalizedUsername };
}
export async function updateSocialLinks(data: {
github?: string;
twitter?: string;
linkedin?: string;
custom?: string[];
}) {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const socialLinks = {
github: data.github || undefined,
twitter: data.twitter || undefined,
linkedin: data.linkedin || undefined,
custom: data.custom?.filter(Boolean) || undefined,
};
await db
.update(users)
.set({
socialLinks,
updatedAt: new Date(),
})
.where(eq(users.id, session.user.id));
revalidatePath("/settings");
return { success: true };
}
export async function updateAvatar(formData: FormData) {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const file = formData.get("avatar") as File;
if (!file || file.size === 0) {
throw new Error("No file provided");
}
if (!file.type.startsWith("image/")) {
throw new Error("File must be an image");
}
if (file.size > 5 * 1024 * 1024) {
throw new Error("File size must be less than 5MB");
}
const ext = file.name.split(".").pop() || "png";
const key = `avatars/${session.user.id}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
await r2Put(key, buffer);
const avatarUrl = `/api/avatar/${session.user.id}.${ext}`;
await db
.update(users)
.set({
image: avatarUrl,
avatarUrl,
updatedAt: new Date(),
})
.where(eq(users.id, session.user.id));
revalidatePath("/settings");
revalidatePath("/");
return { success: true, avatarUrl };
}
export async function updateEmail(data: { email: string }) {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const existingUser = await db.query.users.findFirst({
where: eq(users.email, data.email),
});
if (existingUser && existingUser.id !== session.user.id) {
throw new Error("Email is already in use");
}
await db
.update(users)
.set({
email: data.email,
updatedAt: new Date(),
})
.where(eq(users.id, session.user.id));
revalidatePath("/settings/account");
return { success: true };
}
export async function updatePassword(data: {
currentPassword: string;
newPassword: string;
}) {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
if (!user) {
throw new Error("User not found");
}
try {
await auth.api.signInEmail({
body: { email: user.email, password: data.currentPassword },
});
} catch {
throw new Error("Current password is incorrect");
}
await auth.api.changePassword({
body: {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
},
headers: {
cookie: `better-auth.session_token=${session.session.token}`,
},
});
return { success: true };
}
export async function deleteAccount() {
const session = await getSession();
if (!session?.user) {
throw new Error("Unauthorized");
}
const userRepos = await db.query.repositories.findMany({
where: eq(repositories.ownerId, session.user.id),
});
const { r2DeletePrefix } = await import("@/lib/r2");
for (const repo of userRepos) {
try {
await r2DeletePrefix(`repos/${session.user.id}/${repo.name}.git`);
} catch {}
}
try {
await r2Delete(`avatars/${session.user.id}`);
} catch {}
await db.delete(users).where(eq(users.id, session.user.id));
return { success: true };
}
export async function getCurrentUser() {
const session = await getSession();
if (!session?.user) {
return null;
}
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
return user;
}

27
app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,27 @@
import { GitBranch } from "lucide-react";
import Link from "next/link";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col items-center justify-center relative overflow-hidden px-4">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent/15 via-background to-background" />
<div className="absolute inset-0">
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-accent/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] bg-primary/5 rounded-full blur-[100px]" />
</div>
<div className="relative z-10 flex flex-col items-center w-full max-w-[400px]">
<Link href="/" className="flex items-center gap-3 mb-10 group">
<div className="relative">
<GitBranch className="w-10 h-10 text-foreground transition-transform group-hover:scale-110" />
</div>
<span className="text-2xl font-bold tracking-tight">gitbruv</span>
</Link>
{children}
</div>
</div>
);
}

97
app/(auth)/login/page.tsx Normal file
View file

@ -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 (
<div className="w-full">
<div className="rounded-xl border border-border bg-card/80 backdrop-blur-sm p-8">
<div className="text-center mb-8">
<h1 className="text-xl font-semibold">Sign in to gitbruv</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
className="bg-input/50 h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="bg-input/50 h-11"
/>
</div>
<Button type="submit" disabled={loading} className="w-full h-11">
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign in"
)}
</Button>
</form>
</div>
<div className="mt-6 p-4 rounded-xl border border-border text-center">
<p className="text-sm text-muted-foreground">
New to gitbruv?{" "}
<Link href="/register" className="text-accent hover:underline font-medium">
Create an account
</Link>
</p>
</div>
</div>
);
}

View file

@ -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 (
<div className="w-full">
<div className="rounded-xl border border-border bg-card/80 backdrop-blur-sm p-8">
<div className="text-center mb-8">
<h1 className="text-xl font-semibold">Create your account</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="John Doe"
required
className="bg-input/50 h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="johndoe"
required
className="bg-input/50 h-11"
/>
<p className="text-xs text-muted-foreground">
This will be your unique identifier on gitbruv
</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="you@example.com"
required
className="bg-input/50 h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
required
minLength={8}
className="bg-input/50 h-11"
/>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters
</p>
</div>
<Button
type="submit"
disabled={loading}
className="w-full h-11"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
"Create account"
)}
</Button>
</form>
</div>
<div className="mt-6 p-4 rounded-xl border border-border text-center">
<p className="text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/login"
className="text-accent hover:underline font-medium"
>
Sign in
</Link>
</p>
</div>
</div>
);
}

View file

@ -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<string, string> = {
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 (
<div className="container px-4 py-6">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<Link href={`/${username}`} className="text-accent hover:underline">
<span className="text-xl font-bold">{username}</span>
</Link>
<span className="text-muted-foreground">/</span>
<Link href={`/${username}/${repoName}`} className="text-accent hover:underline">
<span className="text-xl font-bold">{repoName}</span>
</Link>
<Badge variant="secondary" className="text-xs font-normal">
{repo.visibility === "private" ? (
<>
<Lock className="h-3 w-3 mr-1" />
Private
</>
) : (
<>
<Globe className="h-3 w-3 mr-1" />
Public
</>
)}
</Badge>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<nav className="flex items-center gap-1 px-4 py-2 bg-card border-b border-border text-sm">
<Link href={`/${username}/${repoName}`} className="text-accent hover:underline flex items-center gap-1">
<Home className="h-4 w-4" />
{repoName}
</Link>
{pathParts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
{i === pathParts.length - 1 ? (
<span className="font-medium">{part}</span>
) : (
<Link href={`/${username}/${repoName}/tree/${branch}/${pathParts.slice(0, i + 1).join("/")}`} className="text-accent hover:underline">
{part}
</Link>
)}
</span>
))}
</nav>
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileCode className="h-4 w-4" />
<span>{lineCount} lines</span>
</div>
</div>
<CodeViewer content={file.content} language={language} showLineNumbers />
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
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 (
<div className="container px-4 py-6">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<Link href={`/${username}`} className="text-accent hover:underline">
<span className="text-xl font-bold">{username}</span>
</Link>
<span className="text-muted-foreground">/</span>
<div className="text-foreground">
<span className="text-xl font-bold">{repo.name}</span>
</div>
<Badge variant="secondary" className="text-xs font-normal">
{repo.visibility === "private" ? (
<>
<Lock className="h-3 w-3 mr-1" />
Private
</>
) : (
<>
<Globe className="h-3 w-3 mr-1" />
Public
</>
)}
</Badge>
</div>
<CloneUrl username={username} repoName={repo.name} />
</div>
{repo.description && <p className="text-muted-foreground mb-6">{repo.description}</p>}
<div className="grid lg:grid-cols-4 gap-6">
<div className="lg:col-span-3 space-y-6">
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-card border-b border-border">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{repo.defaultBranch}</span>
</div>
{fileTree?.isEmpty ? (
<EmptyRepoGuide username={username} repoName={repo.name} />
) : (
<FileTree files={fileTree?.files || []} username={username} repoName={repo.name} branch={repo.defaultBranch} />
)}
</div>
{readmeContent && (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-card border-b border-border">
<FileCode className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">README.md</span>
</div>
<div className="p-6">
<CodeViewer content={readmeContent} language="markdown" />
</div>
</div>
)}
</div>
<aside className="space-y-6">
<div className="border border-border rounded-lg p-4">
<h3 className="font-semibold mb-3">About</h3>
<p className="text-sm text-muted-foreground">{repo.description || "No description provided."}</p>
</div>
</aside>
</div>
</div>
);
}
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 (
<div className="p-6 space-y-6">
<div className="text-center py-8">
<GitBranch className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-medium mb-2">This repository is empty</h3>
<p className="text-muted-foreground">Get started by cloning or pushing to this repository.</p>
</div>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-2">Create a new repository on the command line</h4>
<pre className="bg-muted p-4 rounded-lg text-sm overflow-x-auto">
<code>{`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`}</code>
</pre>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Push an existing repository from the command line</h4>
<pre className="bg-muted p-4 rounded-lg text-sm overflow-x-auto">
<code>{`git remote add origin ${cloneUrl}
git branch -M main
git push -u origin main`}</code>
</pre>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="container px-4 py-6">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<Link href={`/${username}`} className="text-accent hover:underline">
<span className="text-xl font-bold">{username}</span>
</Link>
<span className="text-muted-foreground">/</span>
<Link href={`/${username}/${repoName}`} className="text-accent hover:underline">
<span className="text-xl font-bold">{repoName}</span>
</Link>
<Badge variant="secondary" className="text-xs font-normal">
{repo.visibility === "private" ? (
<>
<Lock className="h-3 w-3 mr-1" />
Private
</>
) : (
<>
<Globe className="h-3 w-3 mr-1" />
Public
</>
)}
</Badge>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 bg-card border-b border-border">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{branch}</span>
</div>
<nav className="flex items-center gap-1 px-4 py-2 bg-muted/30 border-b border-border text-sm">
<Link href={`/${username}/${repoName}`} className="text-accent hover:underline flex items-center gap-1">
<Home className="h-4 w-4" />
{repoName}
</Link>
{pathParts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
{i === pathParts.length - 1 ? (
<span className="font-medium">{part}</span>
) : (
<Link href={`/${username}/${repoName}/tree/${branch}/${pathParts.slice(0, i + 1).join("/")}`} className="text-accent hover:underline">
{part}
</Link>
)}
</span>
))}
</nav>
<FileTree files={fileTree.files} username={username} repoName={repoName} branch={branch} basePath={dirPath} />
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
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, MapPin, Link as LinkIcon } from "lucide-react";
import { format } from "date-fns";
import Link from "next/link";
import { GithubIcon, XIcon, LinkedInIcon } from "@/components/icons";
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 (
<div className="container px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
<aside className="lg:w-72 shrink-0">
<div className="sticky top-20 space-y-4">
<Avatar className="w-64 h-64 mx-auto lg:mx-0 border border-border">
<AvatarImage src={user.avatarUrl || user.image || undefined} />
<AvatarFallback className="text-6xl bg-accent/20">{user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-bold">{user.name}</h1>
<p className="text-lg text-muted-foreground">@{user.username}</p>
{user.pronouns && <p className="text-sm text-muted-foreground">{user.pronouns}</p>}
</div>
{user.bio && <p className="text-sm">{user.bio}</p>}
<div className="space-y-2 text-sm">
{user.location && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4" />
<span>{user.location}</span>
</div>
)}
{user.website && (
<div className="flex items-center gap-2 text-muted-foreground">
<LinkIcon className="h-4 w-4" />
<Link href={user.website} target="_blank" className="text-primary hover:underline truncate">
{user.website.replace(/^https?:\/\//, "")}
</Link>
</div>
)}
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="h-4 w-4" />
<span>Joined {format(new Date(user.createdAt), "MMMM yyyy")}</span>
</div>
</div>
{user.socialLinks && (
<div className="flex items-center gap-3">
{user.socialLinks.github && (
<Link href={user.socialLinks.github} target="_blank" className="text-muted-foreground hover:text-foreground transition-colors">
<GithubIcon className="h-5 w-5" />
</Link>
)}
{user.socialLinks.twitter && (
<Link href={user.socialLinks.twitter} target="_blank" className="text-muted-foreground hover:text-foreground transition-colors">
<XIcon className="h-5 w-5" />
</Link>
)}
{user.socialLinks.linkedin && (
<Link href={user.socialLinks.linkedin} target="_blank" className="text-muted-foreground hover:text-foreground transition-colors">
<LinkedInIcon className="h-5 w-5" />
</Link>
)}
{user.socialLinks.custom?.map((link, i) => (
<Link key={i} href={link} target="_blank" className="text-muted-foreground hover:text-foreground transition-colors">
<LinkIcon className="h-5 w-5" />
</Link>
))}
</div>
)}
</div>
</aside>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-6">
<GitBranch className="h-5 w-5" />
<h2 className="text-xl font-semibold">Repositories</h2>
<span className="text-sm text-muted-foreground">({repos.length})</span>
</div>
{repos.length === 0 ? (
<div className="border border-dashed border-border rounded-lg p-12 text-center">
<GitBranch className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-medium mb-2">No repositories yet</h3>
<p className="text-muted-foreground">{user.name} hasn&apos;t created any public repositories.</p>
</div>
) : (
<RepoList repos={repos} username={username} />
)}
</div>
</div>
</div>
);
}

18
app/(main)/layout.tsx Normal file
View file

@ -0,0 +1,18 @@
import { Header } from "@/components/header";
import { QueryProvider } from "@/lib/query-client";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<QueryProvider>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
</QueryProvider>
);
}

202
app/(main)/new/page.tsx Normal file
View file

@ -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 (
<div className="container max-w-2xl py-16">
<div className="flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</div>
);
}
if (!session?.user) {
return (
<div className="container max-w-2xl py-16">
<div className="rounded-xl border border-border bg-card p-12 text-center">
<BookMarked className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h2 className="text-xl font-semibold mb-2">Sign in required</h2>
<p className="text-muted-foreground mb-6">Please sign in to create a repository</p>
<Button asChild>
<Link href="/login">Sign in</Link>
</Button>
</div>
</div>
);
}
return (
<div className="container max-w-2xl! py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Create a new repository</h1>
<p className="text-muted-foreground">A repository contains all project files, including the revision history.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="rounded-xl border border-border bg-card p-6 space-y-6">
<div className="space-y-3">
<Label htmlFor="name" className="text-sm font-medium">
Repository name <span className="text-destructive">*</span>
</Label>
<div className="flex items-center gap-3">
<span className="text-muted-foreground font-medium">{username}</span>
<span className="text-muted-foreground">/</span>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="my-awesome-project"
required
pattern="^[a-zA-Z0-9_.-]+$"
className="flex-1 bg-input/50"
/>
</div>
<p className="text-xs text-muted-foreground">Great repository names are short and memorable.</p>
</div>
<div className="space-y-3">
<Label htmlFor="description" className="text-sm font-medium">
Description <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="A short description of your project"
rows={3}
className="bg-input/50 resize-none"
/>
</div>
</div>
<div className="rounded-xl border border-border bg-card p-6 space-y-4">
<Label className="text-sm font-medium">Visibility</Label>
<div className="space-y-3">
<label
className={`flex items-start gap-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${
formData.visibility === "public" ? "border-primary bg-primary/5" : "border-transparent bg-input/30 hover:bg-input/50"
}`}
>
<div
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
formData.visibility === "public" ? "border-primary" : "border-muted-foreground"
}`}
>
{formData.visibility === "public" && <div className="w-2.5 h-2.5 rounded-full bg-primary" />}
</div>
<input
type="radio"
name="visibility"
value="public"
checked={formData.visibility === "public"}
onChange={() => setFormData({ ...formData, visibility: "public" })}
className="sr-only"
/>
<div className="flex-1">
<div className="flex items-center gap-2 font-medium">
<Globe className="h-4 w-4 text-muted-foreground" />
Public
</div>
<p className="text-sm text-muted-foreground mt-1">Anyone on the internet can see this repository.</p>
</div>
</label>
<label
className={`flex items-start gap-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${
formData.visibility === "private" ? "border-primary bg-primary/5" : "border-transparent bg-input/30 hover:bg-input/50"
}`}
>
<div
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
formData.visibility === "private" ? "border-primary" : "border-muted-foreground"
}`}
>
{formData.visibility === "private" && <div className="w-2.5 h-2.5 rounded-full bg-primary" />}
</div>
<input
type="radio"
name="visibility"
value="private"
checked={formData.visibility === "private"}
onChange={() => setFormData({ ...formData, visibility: "private" })}
className="sr-only"
/>
<div className="flex-1">
<div className="flex items-center gap-2 font-medium">
<Lock className="h-4 w-4 text-muted-foreground" />
Private
</div>
<p className="text-sm text-muted-foreground mt-1">You choose who can see and commit to this repository.</p>
</div>
</label>
</div>
</div>
<div className="flex items-center justify-end gap-4 pt-4">
<Button type="button" variant="ghost" asChild>
<Link href="/">Cancel</Link>
</Button>
<Button type="submit" disabled={loading || !formData.name} className="min-w-[160px]">
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create repository"
)}
</Button>
</div>
</form>
</div>
);
}

166
app/(main)/page.tsx Normal file
View file

@ -0,0 +1,166 @@
import { getSession } from "@/lib/session";
import { getUserRepositories } from "@/actions/repositories";
import { RepoList } from "@/components/repo-list";
import { Button } from "@/components/ui/button";
import { GitBranch, Plus, Rocket, Code, Users, BookOpen } from "lucide-react";
import Link from "next/link";
export default async function HomePage() {
const session = await getSession();
if (!session?.user) {
return <LandingPage />;
}
const username = (session.user as { username?: string }).username || "";
const repos = await getUserRepositories(username);
return (
<div className="container py-8">
<div className="flex flex-col lg:flex-row gap-8">
<aside className="lg:w-64 shrink-0">
<div className="flex items-center gap-3 p-4 rounded-lg bg-card border border-border">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-accent/30 to-primary/30 flex items-center justify-center text-lg font-bold">
{session.user.name?.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-semibold truncate">{session.user.name}</p>
<p className="text-sm text-muted-foreground truncate">@{username}</p>
</div>
</div>
<nav className="mt-4 space-y-1">
<Link
href={`/${username}`}
className="flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm hover:bg-card transition-colors"
>
<BookOpen className="h-4 w-4 text-muted-foreground" />
Your repositories
</Link>
</nav>
</aside>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold">Repositories</h2>
<Button asChild size="sm" className="gap-2">
<Link href="/new">
<Plus className="h-4 w-4" />
New
</Link>
</Button>
</div>
{repos.length === 0 ? (
<div className="border border-dashed border-border rounded-xl p-12 text-center bg-card/30">
<div className="w-16 h-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-4">
<GitBranch className="h-8 w-8 text-accent" />
</div>
<h3 className="text-lg font-semibold mb-2">No repositories yet</h3>
<p className="text-muted-foreground mb-6 max-w-sm mx-auto">
Create your first repository to start building something awesome
</p>
<Button asChild size="lg">
<Link href="/new">
<Plus className="h-4 w-4 mr-2" />
Create repository
</Link>
</Button>
</div>
) : (
<RepoList repos={repos} username={username} />
)}
</div>
</div>
</div>
);
}
function LandingPage() {
return (
<div className="flex flex-col">
<section className="relative py-24 lg:py-36 overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent/20 via-background to-background" />
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0zNiAxOGMtOS45NDEgMC0xOCA4LjA1OS0xOCAxOHM4LjA1OSAxOCAxOCAxOCAxOC04LjA1OSAxOC0xOC04LjA1OS0xOC0xOC0xOHptMCAzMmMtNy43MzIgMC0xNC02LjI2OC0xNC0xNHM2LjI2OC0xNCAxNC0xNCAxNCA2LjI2OCAxNCAxNC02LjI2OCAxNC0xNCAxNHoiIGZpbGw9IiMzMDM2M2QiIGZpbGwtb3BhY2l0eT0iMC4xIi8+PC9nPjwvc3ZnPg==')] opacity-30" />
<div className="container relative text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 text-sm text-accent mb-8">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-accent opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent"></span>
</span>
Built for developers, by developers
</div>
<h1 className="text-4xl sm:text-5xl lg:text-7xl font-bold tracking-tight mb-6">
Where the world
<br />
<span className="bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent">
builds software
</span>
</h1>
<p className="text-lg lg:text-xl text-muted-foreground max-w-2xl mx-auto mb-10">
Host and review code, manage projects, and build software alongside
millions of developers. Your code, your way.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" asChild className="text-base h-12 px-8">
<Link href="/register">
Get started for free
</Link>
</Button>
<Button size="lg" variant="outline" asChild className="text-base h-12 px-8">
<Link href="/login">Sign in</Link>
</Button>
</div>
</div>
</section>
<section className="py-24 border-t border-border">
<div className="container">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold mb-4">Everything you need to ship</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Powerful features to help you build, test, and deploy your projects faster
</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
<FeatureCard
icon={Code}
title="Collaborative coding"
description="Build better software together with powerful code review and collaboration tools."
/>
<FeatureCard
icon={Rocket}
title="Ship faster"
description="Automate your workflow with CI/CD pipelines and deploy with confidence."
/>
<FeatureCard
icon={Users}
title="Open source"
description="Join the world's largest developer community and contribute to projects."
/>
</div>
</div>
</section>
</div>
);
}
function FeatureCard({
icon: Icon,
title,
description,
}: {
icon: React.ElementType;
title: string;
description: string;
}) {
return (
<div className="group p-6 rounded-xl border border-border bg-card hover:border-accent/50 transition-all duration-300">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
<Icon className="h-6 w-6 text-accent" />
</div>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-muted-foreground text-sm leading-relaxed">{description}</p>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/actions/settings";
import { EmailForm } from "@/components/settings/email-form";
import { PasswordForm } from "@/components/settings/password-form";
import { DeleteAccount } from "@/components/settings/delete-account";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default async function AccountSettingsPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return (
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Email Address</CardTitle>
<CardDescription>
Change the email associated with your account
</CardDescription>
</CardHeader>
<CardContent>
<EmailForm currentEmail={user.email} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<PasswordForm />
</CardContent>
</Card>
<Card className="border-red-500/20">
<CardHeader>
<CardTitle className="text-red-500">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that affect your account
</CardDescription>
</CardHeader>
<CardContent>
<DeleteAccount username={user.username} />
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { SettingsNav } from "@/components/settings/settings-nav";
export default async function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session?.user) {
redirect("/login");
}
return (
<div className="container max-w-5xl py-8">
<h1 className="text-2xl font-semibold mb-8">Settings</h1>
<div className="flex gap-8">
<aside className="w-48 shrink-0">
<SettingsNav />
</aside>
<main className="flex-1 min-w-0">{children}</main>
</div>
</div>
);
}

View file

@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/actions/settings";
import { ProfileForm } from "@/components/settings/profile-form";
import { AvatarUpload } from "@/components/settings/avatar-upload";
import { SocialLinksForm } from "@/components/settings/social-links-form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export default async function SettingsPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return (
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle>Profile Picture</CardTitle>
<CardDescription>
Upload a picture to personalize your profile
</CardDescription>
</CardHeader>
<CardContent>
<AvatarUpload currentAvatar={user.avatarUrl} name={user.name} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your profile details visible to other users
</CardDescription>
</CardHeader>
<CardContent>
<ProfileForm
user={{
name: user.name,
username: user.username,
bio: user.bio,
location: user.location,
website: user.website,
pronouns: user.pronouns,
}}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Social Links</CardTitle>
<CardDescription>
Add links to your social profiles
</CardDescription>
</CardHeader>
<CardContent>
<SocialLinksForm socialLinks={user.socialLinks} />
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,5 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);

View file

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { r2Get } from "@/lib/r2";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
const { filename } = await params;
const key = `avatars/${filename}`;
const data = await r2Get(key);
if (!data) {
return new NextResponse(null, { status: 404 });
}
const ext = filename.split(".").pop()?.toLowerCase();
let contentType = "image/png";
if (ext === "jpg" || ext === "jpeg") {
contentType = "image/jpeg";
} else if (ext === "gif") {
contentType = "image/gif";
} else if (ext === "webp") {
contentType = "image/webp";
}
return new NextResponse(new Uint8Array(data), {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}

View file

@ -0,0 +1,258 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { users, repositories } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { spawn } from "child_process";
import path from "path";
import fs from "fs/promises";
import { withTempRepo } from "@/lib/r2-git-sync";
import { auth } from "@/lib/auth";
async function authenticateUser(authHeader: string | null): Promise<{ id: string; username: string } | null> {
if (!authHeader || !authHeader.startsWith("Basic ")) {
return null;
}
const base64Credentials = authHeader.split(" ")[1];
const credentials = Buffer.from(base64Credentials, "base64").toString("utf-8");
const [email, password] = credentials.split(":");
if (!email || !password) {
return null;
}
try {
const result = await auth.api.signInEmail({
body: { email, password },
});
if (!result?.user) {
return null;
}
const user = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) {
return null;
}
return { id: user.id, username: user.username };
} catch {
return null;
}
}
function runGitCommand(command: string, args: string[], cwd: string, input?: Buffer): Promise<{ stdout: Buffer; stderr: Buffer; code: number }> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
cwd,
env: { ...process.env, GIT_DIR: cwd },
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
proc.stdout.on("data", (data) => stdout.push(data));
proc.stderr.on("data", (data) => stderr.push(data));
if (input) {
proc.stdin.write(input);
proc.stdin.end();
}
proc.on("close", (code) => {
resolve({
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
code: code || 0,
});
});
});
}
function parseGitPath(pathSegments: string[]): { username: string; repoName: string; action: string | null } | null {
if (pathSegments.length < 2) return null;
const username = pathSegments[0];
let repoName = pathSegments[1];
if (repoName.endsWith(".git")) {
repoName = repoName.slice(0, -4);
}
const remainingPath = pathSegments.slice(2).join("/");
let action: string | null = null;
if (remainingPath === "info/refs") {
action = "info/refs";
} else if (remainingPath === "git-upload-pack") {
action = "git-upload-pack";
} else if (remainingPath === "git-receive-pack") {
action = "git-receive-pack";
}
return { username, repoName, action };
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathSegments } = await params;
const parsed = parseGitPath(pathSegments);
if (!parsed) {
return new NextResponse("Not found", { status: 404 });
}
const { username, repoName, action } = parsed;
const owner = await db.query.users.findFirst({
where: eq(users.username, username),
});
if (!owner) {
return new NextResponse("Repository not found", { status: 404 });
}
const repo = await db.query.repositories.findFirst({
where: and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)),
});
if (!repo) {
return new NextResponse("Repository not found", { status: 404 });
}
if (repo.visibility === "private") {
const user = await authenticateUser(request.headers.get("authorization"));
if (!user || user.id !== repo.ownerId) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
});
}
}
if (action === "info/refs") {
const serviceQuery = request.nextUrl.searchParams.get("service");
if (serviceQuery === "git-upload-pack" || serviceQuery === "git-receive-pack") {
const serviceName = serviceQuery;
if (serviceName === "git-receive-pack") {
const user = await authenticateUser(request.headers.get("authorization"));
if (!user || user.id !== repo.ownerId) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
});
}
}
const response = await withTempRepo(owner.id, repoName, async (tempDir) => {
const { stdout } = await runGitCommand("git", [serviceName.replace("git-", ""), "--advertise-refs", "."], tempDir);
const packet = `# service=${serviceName}\n`;
const packetLen = (packet.length + 4).toString(16).padStart(4, "0");
return Buffer.concat([Buffer.from(packetLen + packet + "0000"), stdout]);
});
return new NextResponse(new Uint8Array(response), {
headers: {
"Content-Type": `application/x-${serviceName}-advertisement`,
"Cache-Control": "no-cache",
},
});
}
const infoRefs = await withTempRepo(owner.id, repoName, async (tempDir) => {
await runGitCommand("git", ["update-server-info"], tempDir);
try {
return await fs.readFile(path.join(tempDir, "info", "refs"), "utf-8");
} catch {
return "";
}
});
return new NextResponse(infoRefs, {
headers: { "Content-Type": "text/plain" },
});
}
return new NextResponse("Not found", { status: 404 });
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathSegments } = await params;
const parsed = parseGitPath(pathSegments);
if (!parsed) {
return new NextResponse("Not found", { status: 404 });
}
const { username, repoName, action } = parsed;
if (action !== "git-upload-pack" && action !== "git-receive-pack") {
return new NextResponse("Not found", { status: 404 });
}
const owner = await db.query.users.findFirst({
where: eq(users.username, username),
});
if (!owner) {
return new NextResponse("Repository not found", { status: 404 });
}
const repo = await db.query.repositories.findFirst({
where: and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)),
});
if (!repo) {
return new NextResponse("Repository not found", { status: 404 });
}
const user = await authenticateUser(request.headers.get("authorization"));
if (action === "git-receive-pack") {
if (!user || user.id !== repo.ownerId) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
});
}
} else if (repo.visibility === "private") {
if (!user || user.id !== repo.ownerId) {
return new NextResponse("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
});
}
}
const body = await request.arrayBuffer();
const input = Buffer.from(body);
const serviceName = action.replace("git-", "");
const shouldSyncBack = action === "git-receive-pack";
const { stdout, stderr, code } = await withTempRepo(
owner.id,
repoName,
async (tempDir) => {
return await runGitCommand("git", [serviceName, "--stateless-rpc", "."], tempDir, input);
},
shouldSyncBack
);
if (code !== 0) {
console.error("Git error:", stderr.toString());
}
return new NextResponse(new Uint8Array(stdout), {
headers: {
"Content-Type": `application/x-${action}-result`,
"Cache-Control": "no-cache",
},
});
}

View file

@ -1,26 +1,173 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
:root { @custom-variant dark (&:is(.dark *));
--background: #ffffff;
--foreground: #171717; @font-face {
font-family: "Mona Sans";
src: url("https://github.githubassets.com/static/fonts/mona-sans/MonaSans.woff2") format("woff2");
font-weight: 200 900;
font-style: normal;
font-display: swap;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: "Mona Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
} }
@media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --radius: 0.5rem;
--foreground: #ededed; --background: #0d1117;
--foreground: #e6edf3;
--card: #161b22;
--card-foreground: #e6edf3;
--popover: #1c2128;
--popover-foreground: #e6edf3;
--primary: #238636;
--primary-foreground: #ffffff;
--secondary: #21262d;
--secondary-foreground: #c9d1d9;
--muted: #161b22;
--muted-foreground: #8b949e;
--accent: #58a6ff;
--accent-foreground: #ffffff;
--destructive: #f85149;
--border: #30363d;
--input: #21262d;
--ring: #58a6ff;
--chart-1: #238636;
--chart-2: #58a6ff;
--chart-3: #a371f7;
--chart-4: #f78166;
--chart-5: #d29922;
--sidebar: #010409;
--sidebar-foreground: #e6edf3;
--sidebar-primary: #238636;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #21262d;
--sidebar-accent-foreground: #c9d1d9;
--sidebar-border: #21262d;
--sidebar-ring: #58a6ff;
--success: #238636;
--success-foreground: #ffffff;
}
.dark {
--background: #0d1117;
--foreground: #e6edf3;
--card: #161b22;
--card-foreground: #e6edf3;
--popover: #1c2128;
--popover-foreground: #e6edf3;
--primary: #238636;
--primary-foreground: #ffffff;
--secondary: #21262d;
--secondary-foreground: #c9d1d9;
--muted: #161b22;
--muted-foreground: #8b949e;
--accent: #58a6ff;
--accent-foreground: #ffffff;
--destructive: #f85149;
--border: #30363d;
--input: #21262d;
--ring: #58a6ff;
--sidebar: #010409;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
font-family: "Mona Sans", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
} }
} }
body { .container {
background: var(--background); width: 100%;
color: var(--foreground); max-width: 1280px;
font-family: Arial, Helvetica, sans-serif; margin-left: auto;
margin-right: auto;
}
@media (min-width: 640px) {
.container {
padding-left: 1rem;
padding-right: 1rem;
}
}
@media (min-width: 1024px) {
.container {
padding-left: 2rem;
padding-right: 2rem;
}
}
::selection {
background: rgba(88, 166, 255, 0.4);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
input[type="radio"] {
accent-color: var(--primary);
} }

View file

@ -1,20 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "@/components/ui/sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "gitbruv",
description: "Generated by create next app", description: "Where code lives",
}; };
export default function RootLayout({ export default function RootLayout({
@ -23,11 +13,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className="dark">
<body <body className="min-h-screen">
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children} {children}
<Toaster richColors position="top-right" />
</body> </body>
</html> </html>
); );

27
app/not-found.tsx Normal file
View file

@ -0,0 +1,27 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { GitBranch, Home } from "lucide-react";
export default function NotFound() {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-4">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-destructive/10 via-transparent to-transparent" />
<div className="relative text-center">
<GitBranch className="h-16 w-16 mx-auto mb-6 text-muted-foreground" />
<h1 className="text-7xl font-bold text-foreground mb-2">404</h1>
<h2 className="text-2xl font-semibold mb-4">Page not found</h2>
<p className="text-muted-foreground mb-8 max-w-md">
The page you&apos;re looking for doesn&apos;t exist or you don&apos;t have
permission to view it.
</p>
<Button asChild>
<Link href="/" className="gap-2">
<Home className="h-4 w-4" />
Go home
</Link>
</Button>
</div>
</div>
);
}

View file

@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

833
bun.lock

File diff suppressed because it is too large Load diff

22
components.json Normal file
View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

76
components/clone-url.tsx Normal file
View file

@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Check, Copy, ChevronDown } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function CloneUrl({
username,
repoName,
}: {
username: string;
repoName: string;
}) {
const [copied, setCopied] = useState(false);
const [protocol, setProtocol] = useState<"https" | "ssh">("https");
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const httpsUrl = `${baseUrl}/api/git/${username}/${repoName}.git`;
const sshUrl = `git@gitbruv.local:${username}/${repoName}.git`;
const url = protocol === "https" ? httpsUrl : sshUrl;
async function copyToClipboard() {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
{protocol.toUpperCase()}
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setProtocol("https")}>
HTTPS
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setProtocol("ssh")}>
SSH
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="relative flex-1 min-w-[280px]">
<Input
value={url}
readOnly
className="pr-10 font-mono text-xs bg-muted"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={copyToClipboard}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,88 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useEffect, useState } from "react";
import { codeToHtml } from "shiki";
export function CodeViewer({
content,
language,
showLineNumbers = false,
}: {
content: string;
language: string;
showLineNumbers?: boolean;
}) {
const [highlightedCode, setHighlightedCode] = useState<string | null>(null);
useEffect(() => {
if (language === "markdown" || language === "md") return;
async function highlight() {
try {
const html = await codeToHtml(content, {
lang: language === "text" ? "plaintext" : language,
theme: "github-dark-default",
});
setHighlightedCode(html);
} catch {
setHighlightedCode(null);
}
}
highlight();
}, [content, language]);
if (language === "markdown" || language === "md") {
return (
<div className="prose prose-invert prose-sm max-w-none prose-headings:border-b prose-headings:border-border prose-headings:pb-2 prose-a:text-accent prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-pre:bg-muted">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
if (highlightedCode) {
const lines = content.split("\n");
return (
<div className="overflow-x-auto">
<div className="flex font-mono text-sm">
{showLineNumbers && (
<div className="text-right text-muted-foreground select-none pr-4 pl-4 py-2 border-r border-border bg-muted/30 shrink-0">
{lines.map((_, i) => (
<div key={i} className="leading-6">
{i + 1}
</div>
))}
</div>
)}
<div
className="flex-1 pl-4 py-2 [&>pre]:!bg-transparent [&>pre]:!m-0 [&>pre]:!p-0 [&_code]:leading-6"
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</div>
</div>
);
}
const lines = content.split("\n");
return (
<div className="font-mono text-sm overflow-x-auto">
<table className="w-full border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="hover:bg-muted/30">
{showLineNumbers && (
<td className="text-right text-muted-foreground select-none pr-4 pl-4 py-0.5 w-12 align-top border-r border-border">
{i + 1}
</td>
)}
<td className="pl-4 py-0.5 whitespace-pre">{line || " "}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

78
components/file-tree.tsx Normal file
View file

@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { Folder, FileCode, FileText, FileJson, File } from "lucide-react";
type FileEntry = {
name: string;
type: "blob" | "tree";
oid: string;
path: string;
};
const FILE_ICONS: Record<string, React.ElementType> = {
ts: FileCode,
tsx: FileCode,
js: FileCode,
jsx: FileCode,
py: FileCode,
rb: FileCode,
go: FileCode,
rs: FileCode,
java: FileCode,
md: FileText,
txt: FileText,
json: FileJson,
yaml: FileJson,
yml: FileJson,
};
function getFileIcon(name: string, type: "blob" | "tree") {
if (type === "tree") return Folder;
const ext = name.split(".").pop()?.toLowerCase() || "";
return FILE_ICONS[ext] || File;
}
export function FileTree({
files,
username,
repoName,
branch,
basePath = "",
}: {
files: FileEntry[];
username: string;
repoName: string;
branch: string;
basePath?: string;
}) {
return (
<div className="divide-y divide-border">
{files.map((file) => {
const Icon = getFileIcon(file.name, file.type);
const href =
file.type === "tree"
? `/${username}/${repoName}/tree/${branch}/${file.path}`
: `/${username}/${repoName}/blob/${branch}/${file.path}`;
return (
<Link
key={file.oid + file.name}
href={href}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50 transition-colors group"
>
<Icon
className={`h-4 w-4 shrink-0 ${
file.type === "tree" ? "text-accent" : "text-muted-foreground"
}`}
/>
<span className="text-sm group-hover:text-accent truncate">
{file.name}
</span>
</Link>
);
})}
</div>
);
}

109
components/header.tsx Normal file
View file

@ -0,0 +1,109 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { GitBranch, Plus, LogOut, User, ChevronDown, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { signOut, useSession } from "@/lib/auth-client";
export function Header() {
const router = useRouter();
const { data: session } = useSession();
async function handleSignOut() {
await signOut();
router.push("/");
router.refresh();
}
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-[#010409]">
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/" className="flex items-center gap-2.5 group">
<span className="font-bold text-xl tracking-tight hidden sm:inline">gitbruv</span>
</Link>
</div>
<div className="flex items-center gap-2">
{session?.user ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-muted-foreground hover:text-foreground">
<Plus className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem asChild>
<Link href="/new" className="cursor-pointer gap-2">
<BookIcon className="h-4 w-4" />
New repository
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 rounded-full p-0 overflow-hidden ring-2 ring-transparent hover:ring-accent/50 transition-all">
<Avatar className="h-8 w-8">
<AvatarImage src={session.user.image || undefined} />
<AvatarFallback className="bg-gradient-to-br from-accent/40 to-primary/40 text-foreground text-xs font-semibold">
{session.user.name?.charAt(0).toUpperCase() || "U"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-3 py-2">
<p className="text-sm font-medium">{session.user.name}</p>
<p className="text-xs text-muted-foreground">@{(session.user as { username?: string }).username}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/${(session.user as { username?: string }).username}`} className="cursor-pointer gap-2">
<User className="h-4 w-4" />
Your profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings" className="cursor-pointer gap-2">
<Settings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer gap-2 text-destructive focus:text-destructive focus:bg-destructive/10">
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild className="text-muted-foreground hover:text-foreground">
<Link href="/login">Sign in</Link>
</Button>
<Button size="sm" asChild>
<Link href="/register">Sign up</Link>
</Button>
</div>
)}
</div>
</div>
</header>
);
}
function BookIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
<path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z" />
</svg>
);
}

34
components/icons.tsx Normal file
View file

@ -0,0 +1,34 @@
import React from "react";
export function XIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" className={className}>
<path
className="fill-current"
d="M357.2 48L427.8 48 273.6 224.2 455 464 313 464 201.7 318.6 74.5 464 3.8 464 168.7 275.5-5.2 48 140.4 48 240.9 180.9 357.2 48zM332.4 421.8l39.1 0-252.4-333.8-42 0 255.3 333.8z"
/>
</svg>
);
}
export function GithubIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" className={className}>
<path
className="fill-current"
d="M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM252.8 8c-138.7 0-244.8 105.3-244.8 244 0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1 100-33.2 167.8-128.1 167.8-239 0-138.7-112.5-244-251.2-244zM105.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
/>
</svg>
);
}
export function LinkedInIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" className={className}>
<path
className="fill-current"
d="M416 32L31.9 32C14.3 32 0 46.5 0 64.3L0 447.7C0 465.5 14.3 480 31.9 480L416 480c17.6 0 32-14.5 32-32.3l0-383.4C448 46.5 433.6 32 416 32zM135.4 416l-66.4 0 0-213.8 66.5 0 0 213.8-.1 0zM102.2 96a38.5 38.5 0 1 1 0 77 38.5 38.5 0 1 1 0-77zM384.3 416l-66.4 0 0-104c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9l0 105.8-66.4 0 0-213.8 63.7 0 0 29.2 .9 0c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9l0 117.2z"
/>
</svg>
);
}

70
components/repo-list.tsx Normal file
View file

@ -0,0 +1,70 @@
"use client";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { Lock, Globe } from "lucide-react";
type Repository = {
id: string;
name: string;
description: string | null;
visibility: "public" | "private";
updatedAt: Date;
};
export function RepoList({
repos,
username,
}: {
repos: Repository[];
username: string;
}) {
return (
<div className="space-y-3">
{repos.map((repo) => (
<Link
key={repo.id}
href={`/${username}/${repo.name}`}
className="block p-5 rounded-xl border border-border bg-card hover:border-accent/50 transition-all duration-200 group"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 mb-1.5">
<span className="font-semibold text-accent group-hover:underline text-lg">
{repo.name}
</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
repo.visibility === "private"
? "border-yellow-500/30 text-yellow-500 bg-yellow-500/10"
: "border-border text-muted-foreground bg-secondary"
}`}
>
{repo.visibility === "private" ? (
<>
<Lock className="h-3 w-3" />
Private
</>
) : (
<>
<Globe className="h-3 w-3" />
Public
</>
)}
</span>
</div>
{repo.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-2">
{repo.description}
</p>
)}
</div>
<p className="text-xs text-muted-foreground shrink-0 pt-1">
{formatDistanceToNow(new Date(repo.updatedAt), { addSuffix: true })}
</p>
</div>
</Link>
))}
</div>
);
}

View file

@ -0,0 +1,81 @@
"use client";
import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { updateAvatar } from "@/actions/settings";
import { Camera, Loader2 } from "lucide-react";
interface AvatarUploadProps {
currentAvatar?: string | null;
name: string;
}
export function AvatarUpload({ currentAvatar, name }: AvatarUploadProps) {
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<string | null>(currentAvatar || null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("Please select an image file");
return;
}
if (file.size > 5 * 1024 * 1024) {
setError("Image must be less than 5MB");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append("avatar", file);
const result = await updateAvatar(formData);
setPreview(result.avatarUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to upload avatar");
setPreview(currentAvatar || null);
} finally {
setLoading(false);
}
}
return (
<div className="flex items-start gap-6">
<div className="relative">
<Avatar className="w-24 h-24">
<AvatarImage src={preview || undefined} alt={name} />
<AvatarFallback className="text-2xl bg-accent">{name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
{loading && (
<div className="absolute inset-0 bg-background/80 rounded-full flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
)}
</div>
<div className="space-y-2">
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={loading}>
<Camera className="w-4 h-4 mr-2" />
Change Avatar
</Button>
<p className="text-xs text-muted-foreground">JPG, PNG or GIF. Max 5MB.</p>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { deleteAccount } from "@/actions/settings";
import { Loader2, AlertTriangle } from "lucide-react";
import { useRouter } from "next/navigation";
interface DeleteAccountProps {
username: string;
}
export function DeleteAccount({ username }: DeleteAccountProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmation, setConfirmation] = useState("");
const [showConfirm, setShowConfirm] = useState(false);
const router = useRouter();
async function handleDelete() {
if (confirmation !== username) {
setError("Please type your username to confirm");
return;
}
setLoading(true);
setError(null);
try {
await deleteAccount();
router.push("/");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete account");
setLoading(false);
}
}
if (!showConfirm) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Once you delete your account, there is no going back. All your repositories and data will be permanently deleted.
</p>
<Button
variant="destructive"
onClick={() => setShowConfirm(true)}
>
Delete Account
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-red-500/10 border border-red-500/20 rounded-md">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="text-sm font-medium text-red-500">
This action cannot be undone
</p>
<p className="text-sm text-muted-foreground">
This will permanently delete your account, all repositories, and remove all your data from our servers.
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">
Type <span className="font-mono font-semibold">{username}</span> to confirm
</Label>
<Input
id="confirm"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
placeholder="Enter your username"
/>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setShowConfirm(false);
setConfirmation("");
setError(null);
}}
disabled={loading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={loading || confirmation !== username}
>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Delete My Account
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateEmail } from "@/actions/settings";
import { Loader2 } from "lucide-react";
interface EmailFormProps {
currentEmail: string;
}
export function EmailForm({ currentEmail }: EmailFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
if (email === currentEmail) {
setError("New email is the same as current email");
setLoading(false);
return;
}
try {
await updateEmail({ email });
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update email");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={currentEmail}
required
/>
<p className="text-xs text-muted-foreground">
Your email is used for account notifications and git authentication
</p>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
{success && (
<div className="text-sm text-green-500 bg-green-500/10 border border-green-500/20 rounded-md px-3 py-2">
Email updated successfully!
</div>
)}
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Update Email
</Button>
</form>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updatePassword } from "@/actions/settings";
import { Loader2 } from "lucide-react";
export function PasswordForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
const formData = new FormData(e.currentTarget);
const currentPassword = formData.get("currentPassword") as string;
const newPassword = formData.get("newPassword") as string;
const confirmPassword = formData.get("confirmPassword") as string;
if (newPassword !== confirmPassword) {
setError("New passwords do not match");
setLoading(false);
return;
}
if (newPassword.length < 8) {
setError("Password must be at least 8 characters");
setLoading(false);
return;
}
try {
await updatePassword({ currentPassword, newPassword });
setSuccess(true);
(e.target as HTMLFormElement).reset();
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update password");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Current Password</Label>
<Input
id="currentPassword"
name="currentPassword"
type="password"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New Password</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm New Password</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength={8}
/>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
{success && (
<div className="text-sm text-green-500 bg-green-500/10 border border-green-500/20 rounded-md px-3 py-2">
Password updated successfully!
</div>
)}
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Update Password
</Button>
</form>
);
}

View file

@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
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 { updateProfile } from "@/actions/settings";
import { Loader2 } from "lucide-react";
interface ProfileFormProps {
user: {
name: string;
username: string;
bio?: string | null;
location?: string | null;
website?: string | null;
pronouns?: string | null;
};
}
export function ProfileForm({ user }: ProfileFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
const formData = new FormData(e.currentTarget);
try {
await updateProfile({
name: formData.get("name") as string,
username: formData.get("username") as string,
bio: formData.get("bio") as string,
location: formData.get("location") as string,
website: formData.get("website") as string,
pronouns: formData.get("pronouns") as string,
});
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update profile");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Display Name</Label>
<Input
id="name"
name="name"
defaultValue={user.name}
placeholder="Your display name"
required
/>
<p className="text-xs text-muted-foreground">
Your name as it appears on your profile
</p>
</div>
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
defaultValue={user.username}
placeholder="username"
required
pattern="[a-zA-Z0-9_-]+"
minLength={3}
/>
<p className="text-xs text-muted-foreground">
Your unique handle. Letters, numbers, underscores, and hyphens only.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
name="bio"
defaultValue={user.bio || ""}
placeholder="Tell us about yourself"
maxLength={160}
rows={3}
/>
<p className="text-xs text-muted-foreground">
Brief description for your profile. Max 160 characters.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="pronouns">Pronouns</Label>
<Input
id="pronouns"
name="pronouns"
defaultValue={user.pronouns || ""}
placeholder="e.g., they/them, she/her, he/him"
/>
</div>
<div className="space-y-2">
<Label htmlFor="location">Location</Label>
<Input
id="location"
name="location"
defaultValue={user.location || ""}
placeholder="City, Country"
/>
</div>
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input
id="website"
name="website"
type="url"
defaultValue={user.website || ""}
placeholder="https://yourwebsite.com"
/>
</div>
{error && (
<div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
{success && (
<div className="text-sm text-green-500 bg-green-500/10 border border-green-500/20 rounded-md px-3 py-2">
Profile updated successfully!
</div>
)}
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</Button>
</form>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { User, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/settings", label: "Profile", icon: User },
{ href: "/settings/account", label: "Account", icon: Shield },
];
export function SettingsNav() {
const pathname = usePathname();
return (
<nav className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive = item.href === "/settings"
? pathname === "/settings"
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
isActive
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
)}
>
<item.icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
);
}

View file

@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { updateSocialLinks } from "@/actions/settings";
import { Loader2, Link } from "lucide-react";
import { GithubIcon, LinkedInIcon, XIcon } from "../icons";
interface SocialLinksFormProps {
socialLinks?: {
github?: string;
twitter?: string;
linkedin?: string;
custom?: string[];
} | null;
}
export function SocialLinksForm({ socialLinks }: SocialLinksFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [customLinks, setCustomLinks] = useState<string[]>([socialLinks?.custom?.[0] || "", socialLinks?.custom?.[1] || "", socialLinks?.custom?.[2] || ""]);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
const formData = new FormData(e.currentTarget);
try {
await updateSocialLinks({
github: formData.get("github") as string,
twitter: formData.get("twitter") as string,
linkedin: formData.get("linkedin") as string,
custom: customLinks.filter(Boolean),
});
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update social links");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="github" className="flex items-center gap-2">
<GithubIcon className="w-4 h-4" />
GitHub
</Label>
<Input id="github" name="github" defaultValue={socialLinks?.github || ""} placeholder="https://github.com/username" type="url" />
</div>
<div className="space-y-2">
<Label htmlFor="twitter" className="flex items-center gap-2">
<XIcon className="w-4 h-4" />
Twitter / X
</Label>
<Input id="twitter" name="twitter" defaultValue={socialLinks?.twitter || ""} placeholder="https://twitter.com/username" type="url" />
</div>
<div className="space-y-2">
<Label htmlFor="linkedin" className="flex items-center gap-2">
<LinkedInIcon className="w-4 h-4" />
LinkedIn
</Label>
<Input id="linkedin" name="linkedin" defaultValue={socialLinks?.linkedin || ""} placeholder="https://linkedin.com/in/username" type="url" />
</div>
<div className="space-y-3">
<Label className="flex items-center gap-2">
<Link className="w-4 h-4" />
Custom Links
</Label>
{[0, 1, 2].map((i) => (
<Input
key={i}
value={customLinks[i]}
onChange={(e) => {
const newLinks = [...customLinks];
newLinks[i] = e.target.value;
setCustomLinks(newLinks);
}}
placeholder={`Custom link ${i + 1}`}
type="url"
/>
))}
<p className="text-xs text-muted-foreground">Add up to 3 custom links to your profile</p>
</div>
{error && <div className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">{error}</div>}
{success && <div className="text-sm text-green-500 bg-green-500/10 border border-green-500/20 rounded-md px-3 py-2">Social links updated!</div>}
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Social Links
</Button>
</form>
);
}

53
components/ui/avatar.tsx Normal file
View file

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

46
components/ui/badge.tsx Normal file
View file

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

62
components/ui/button.tsx Normal file
View file

@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

143
components/ui/dialog.tsx Normal file
View file

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

167
components/ui/form.tsx Normal file
View file

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

21
components/ui/input.tsx Normal file
View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View file

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

40
components/ui/sonner.tsx Normal file
View file

@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

8
db/index.ts Normal file
View file

@ -0,0 +1,8 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

78
db/schema.ts Normal file
View file

@ -0,0 +1,78 @@
import { pgTable, text, timestamp, boolean, uuid, jsonb } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
username: text("username").notNull().unique(),
bio: text("bio"),
location: text("location"),
website: text("website"),
pronouns: text("pronouns"),
avatarUrl: text("avatar_url"),
socialLinks: jsonb("social_links").$type<{
github?: string;
twitter?: string;
linkedin?: string;
custom?: string[];
}>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const repositories = pgTable("repositories", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
description: text("description"),
ownerId: text("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
visibility: text("visibility", { enum: ["public", "private"] })
.notNull()
.default("public"),
defaultBranch: text("default_branch").notNull().default("main"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

11
drizzle.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

16
lib/auth-client.ts Normal file
View file

@ -0,0 +1,16 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});
export const { signIn, signOut, useSession } = authClient;
export async function signUpWithUsername(data: {
email: string;
password: string;
name: string;
username: string;
}) {
return authClient.signUp.email(data as Parameters<typeof authClient.signUp.email>[0]);
}

33
lib/auth.ts Normal file
View file

@ -0,0 +1,33 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db";
import * as schema from "@/db/schema";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verifications,
},
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
user: {
additionalFields: {
username: {
type: "string",
required: true,
input: true,
},
},
},
plugins: [nextCookies()],
});
export type Session = typeof auth.$Infer.Session;

23
lib/query-client.tsx Normal file
View file

@ -0,0 +1,23 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

155
lib/r2-fs.ts Normal file
View file

@ -0,0 +1,155 @@
import { r2Get, r2Put, r2Delete, r2Exists, r2List, r2DeletePrefix } from "./r2";
function normalizePath(path: string): string {
return path.replace(/\/+/g, "/").replace(/^\//, "").replace(/\/$/, "");
}
function createStatResult(type: "file" | "dir", size: number) {
const now = Date.now();
return {
type,
mode: type === "dir" ? 0o40755 : 0o100644,
size,
ino: 0,
mtimeMs: now,
ctimeMs: now,
uid: 1000,
gid: 1000,
dev: 0,
isFile: () => type === "file",
isDirectory: () => type === "dir",
isSymbolicLink: () => false,
};
}
export function createR2Fs(repoPrefix: string) {
const dirMarkerCache = new Set<string>();
const getKey = (filepath: string) => {
const normalized = normalizePath(filepath);
if (normalized.startsWith(repoPrefix)) {
return normalized;
}
if (!normalized) {
return repoPrefix;
}
return `${repoPrefix}/${normalized}`.replace(/\/+/g, "/");
};
const readFile = async (filepath: string, options?: { encoding?: string }): Promise<Buffer | string> => {
const key = getKey(filepath);
const data = await r2Get(key);
if (!data) {
const err = new Error(`ENOENT: no such file or directory, open '${filepath}'`) as NodeJS.ErrnoException;
err.code = "ENOENT";
throw err;
}
if (options?.encoding === "utf8" || options?.encoding === "utf-8") {
return data.toString("utf-8");
}
return data;
};
const writeFile = async (filepath: string, data: Buffer | string): Promise<void> => {
const key = getKey(filepath);
await r2Put(key, typeof data === "string" ? Buffer.from(data) : data);
};
const unlink = async (filepath: string): Promise<void> => {
const key = getKey(filepath);
await r2Delete(key);
};
const readdir = async (filepath: string): Promise<string[]> => {
const prefix = getKey(filepath);
const fullPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
const keys = await r2List(fullPrefix);
const entries = new Set<string>();
for (const key of keys) {
const relative = key.slice(fullPrefix.length);
if (!relative) continue;
const firstPart = relative.split("/")[0];
if (firstPart) entries.add(firstPart);
}
return Array.from(entries);
};
const mkdir = async (filepath: string): Promise<void> => {
const key = getKey(filepath);
dirMarkerCache.add(key);
};
const rmdir = async (filepath: string, options?: { recursive?: boolean }): Promise<void> => {
if (options?.recursive) {
const prefix = getKey(filepath);
await r2DeletePrefix(prefix.endsWith("/") ? prefix : `${prefix}/`);
}
const key = getKey(filepath);
dirMarkerCache.delete(key);
};
const stat = async (filepath: string) => {
const key = getKey(filepath);
if (dirMarkerCache.has(key)) {
return createStatResult("dir", 0);
}
const exists = await r2Exists(key);
if (exists) {
const data = await r2Get(key);
return createStatResult("file", data?.length || 0);
}
const prefix = key.endsWith("/") ? key : `${key}/`;
const children = await r2List(prefix);
if (children.length > 0) {
return createStatResult("dir", 0);
}
const err = new Error(`ENOENT: no such file or directory, stat '${filepath}'`) as NodeJS.ErrnoException;
err.code = "ENOENT";
throw err;
};
const lstat = stat;
const readlink = async (filepath: string): Promise<string> => {
const err = new Error(`ENOENT: no such file or directory, readlink '${filepath}'`) as NodeJS.ErrnoException;
err.code = "ENOENT";
throw err;
};
const symlink = async (): Promise<void> => {};
return {
promises: {
readFile,
writeFile,
unlink,
readdir,
mkdir,
rmdir,
stat,
lstat,
readlink,
symlink,
},
readFile,
writeFile,
unlink,
readdir,
mkdir,
rmdir,
stat,
lstat,
readlink,
symlink,
};
}
export function getRepoPrefix(userId: string, repoName: string): string {
return `repos/${userId}/${repoName}`;
}

116
lib/r2-git-sync.ts Normal file
View file

@ -0,0 +1,116 @@
import { r2Get, r2Put, r2List, r2Delete } from "./r2";
import { getRepoPrefix } from "./r2-fs";
import fs from "fs/promises";
import path from "path";
import os from "os";
export async function syncR2ToLocal(userId: string, repoName: string): Promise<string> {
const repoPrefix = getRepoPrefix(userId, `${repoName}.git`);
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gitbruv-"));
const keys = await r2List(repoPrefix);
for (const key of keys) {
const relativePath = key.slice(repoPrefix.length + 1);
if (!relativePath) continue;
const localPath = path.join(tempDir, relativePath);
const localDir = path.dirname(localPath);
await fs.mkdir(localDir, { recursive: true });
const data = await r2Get(key);
if (data) {
await fs.writeFile(localPath, data);
}
}
await fs.mkdir(path.join(tempDir, "objects"), { recursive: true });
await fs.mkdir(path.join(tempDir, "objects/info"), { recursive: true });
await fs.mkdir(path.join(tempDir, "objects/pack"), { recursive: true });
await fs.mkdir(path.join(tempDir, "refs"), { recursive: true });
await fs.mkdir(path.join(tempDir, "refs/heads"), { recursive: true });
await fs.mkdir(path.join(tempDir, "refs/tags"), { recursive: true });
const headPath = path.join(tempDir, "HEAD");
try {
await fs.access(headPath);
} catch {
await fs.writeFile(headPath, "ref: refs/heads/main\n");
}
const configPath = path.join(tempDir, "config");
try {
await fs.access(configPath);
} catch {
await fs.writeFile(configPath, `[core]
\trepositoryformatversion = 0
\tfilemode = true
\tbare = true
`);
}
return tempDir;
}
export async function syncLocalToR2(localDir: string, userId: string, repoName: string): Promise<void> {
const repoPrefix = getRepoPrefix(userId, `${repoName}.git`);
const existingKeys = await r2List(repoPrefix);
const existingSet = new Set(existingKeys);
const newKeys = new Set<string>();
async function uploadDir(dirPath: string, r2Prefix: string) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const localPath = path.join(dirPath, entry.name);
const r2Key = `${r2Prefix}/${entry.name}`;
if (entry.isDirectory()) {
await uploadDir(localPath, r2Key);
} else if (entry.isFile()) {
const data = await fs.readFile(localPath);
await r2Put(r2Key, data);
newKeys.add(r2Key);
}
}
}
await uploadDir(localDir, repoPrefix);
for (const key of existingSet) {
if (!newKeys.has(key)) {
await r2Delete(key);
}
}
}
export async function cleanupTempDir(tempDir: string): Promise<void> {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
}
}
export async function withTempRepo<T>(
userId: string,
repoName: string,
operation: (tempDir: string) => Promise<T>,
syncBack: boolean = false
): Promise<T> {
const tempDir = await syncR2ToLocal(userId, repoName);
try {
const result = await operation(tempDir);
if (syncBack) {
await syncLocalToR2(tempDir, userId, repoName);
}
return result;
} finally {
await cleanupTempDir(tempDir);
}
}

98
lib/r2.ts Normal file
View file

@ -0,0 +1,98 @@
import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand } from "@aws-sdk/client-s3";
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID!;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID!;
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY!;
export const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME || "gitbruv-repos";
export const r2Client = new S3Client({
region: "auto",
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
export async function r2Get(key: string): Promise<Buffer | null> {
try {
const response = await r2Client.send(
new GetObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
if (!response.Body) return null;
const bytes = await response.Body.transformToByteArray();
return Buffer.from(bytes);
} catch (err: unknown) {
if ((err as { name?: string })?.name === "NoSuchKey") return null;
throw err;
}
}
export async function r2Put(key: string, data: Buffer | Uint8Array | string): Promise<void> {
await r2Client.send(
new PutObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
Body: data instanceof Buffer ? data : Buffer.from(data),
})
);
}
export async function r2Delete(key: string): Promise<void> {
await r2Client.send(
new DeleteObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
}
export async function r2Exists(key: string): Promise<boolean> {
try {
await r2Client.send(
new HeadObjectCommand({
Bucket: R2_BUCKET_NAME,
Key: key,
})
);
return true;
} catch {
return false;
}
}
export async function r2List(prefix: string): Promise<string[]> {
const keys: string[] = [];
let continuationToken: string | undefined;
do {
const response = await r2Client.send(
new ListObjectsV2Command({
Bucket: R2_BUCKET_NAME,
Prefix: prefix,
ContinuationToken: continuationToken,
})
);
if (response.Contents) {
for (const obj of response.Contents) {
if (obj.Key) keys.push(obj.Key);
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
return keys;
}
export async function r2DeletePrefix(prefix: string): Promise<void> {
const keys = await r2List(prefix);
for (const key of keys) {
await r2Delete(key);
}
}

9
lib/session.ts Normal file
View file

@ -0,0 +1,9 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
export async function getSession() {
const session = await auth.api.getSession({
headers: await headers(),
});
return session;
}

6
lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -2,6 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
experimental: {
serverActions: {
bodySizeLimit: "1mb",
},
},
}; };
export default nextConfig; export default nextConfig;

View file

@ -1,26 +1,59 @@
{ {
"name": "guthib", "name": "gitbruv",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.956.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.12",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"isomorphic-git": "^1.36.1",
"lucide-react": "^0.562.0",
"next": "16.1.1-canary.1", "next": "16.1.1-canary.1",
"next-themes": "^0.4.6",
"postgres": "^3.4.7",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"react-hook-form": "^7.68.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.20.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"drizzle-kit": "^0.31.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.1-canary.1", "eslint-config-next": "16.1.1-canary.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
}, },
"ignoreScripts": [ "ignoreScripts": [