diff --git a/actions/repositories.ts b/actions/repositories.ts index 0e46c6f..0b590b9 100644 --- a/actions/repositories.ts +++ b/actions/repositories.ts @@ -1,9 +1,9 @@ "use server"; import { db } from "@/db"; -import { repositories, users } from "@/db/schema"; +import { repositories, users, stars } from "@/db/schema"; import { getSession } from "@/lib/session"; -import { eq, and, desc } from "drizzle-orm"; +import { eq, and, desc, count, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import git from "isomorphic-git"; import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs"; @@ -301,3 +301,273 @@ export async function getRepoFile(owner: string, repoName: string, branch: strin return null; } } + +export async function toggleStar(repoId: string) { + const session = await getSession(); + if (!session?.user) { + throw new Error("Unauthorized"); + } + + const existing = await db.query.stars.findFirst({ + where: and(eq(stars.userId, session.user.id), eq(stars.repositoryId, repoId)), + }); + + if (existing) { + await db.delete(stars).where(and(eq(stars.userId, session.user.id), eq(stars.repositoryId, repoId))); + return { starred: false }; + } else { + await db.insert(stars).values({ + userId: session.user.id, + repositoryId: repoId, + }); + return { starred: true }; + } +} + +export async function getStarCount(repoId: string) { + const result = await db.select({ count: count() }).from(stars).where(eq(stars.repositoryId, repoId)); + return result[0]?.count ?? 0; +} + +export async function isStarredByUser(repoId: string) { + const session = await getSession(); + if (!session?.user) { + return false; + } + + const existing = await db.query.stars.findFirst({ + where: and(eq(stars.userId, session.user.id), eq(stars.repositoryId, repoId)), + }); + + return !!existing; +} + +export async function getRepositoryWithStars(owner: string, name: string) { + const repo = await getRepository(owner, name); + if (!repo) return null; + + const starCount = await getStarCount(repo.id); + const starred = await isStarredByUser(repo.id); + + return { ...repo, starCount, starred }; +} + +export async function getUserRepositoriesWithStars(username: string) { + const repos = await getUserRepositories(username); + + const reposWithStars = await Promise.all( + repos.map(async (repo) => { + const starCount = await getStarCount(repo.id); + return { ...repo, starCount }; + }) + ); + + return reposWithStars; +} + +export async function updateRepository( + repoId: string, + data: { name?: string; description?: string; visibility?: "public" | "private" } +) { + 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 oldName = repo.name; + let newName = oldName; + + if (data.name && data.name !== oldName) { + newName = data.name.toLowerCase().replace(/\s+/g, "-"); + + if (!/^[a-zA-Z0-9_.-]+$/.test(newName)) { + throw new Error("Invalid repository name"); + } + + const existing = await db.query.repositories.findFirst({ + where: and(eq(repositories.ownerId, session.user.id), eq(repositories.name, newName)), + }); + + if (existing) { + throw new Error("Repository with this name already exists"); + } + } + + const [updated] = await db + .update(repositories) + .set({ + name: newName, + description: data.description !== undefined ? data.description || null : repo.description, + visibility: data.visibility || repo.visibility, + updatedAt: new Date(), + }) + .where(eq(repositories.id, repoId)) + .returning(); + + const username = (session.user as { username?: string }).username; + revalidatePath(`/${username}/${oldName}`); + revalidatePath(`/${username}/${newName}`); + revalidatePath(`/${username}`); + revalidatePath("/"); + + return updated; +} + +export async function getRepoBranches(owner: string, repoName: string) { + const user = await db.query.users.findFirst({ + where: eq(users.username, owner), + }); + + if (!user) { + return []; + } + + const repoPrefix = getRepoPrefix(user.id, `${repoName}.git`); + const fs = createR2Fs(repoPrefix); + + try { + const branches = await git.listBranches({ fs, gitdir: "/" }); + return branches; + } catch { + return []; + } +} + +export async function getRepoCommits( + owner: string, + repoName: string, + branch: string, + limit: number = 30, + skip: number = 0 +) { + const user = await db.query.users.findFirst({ + where: eq(users.username, owner), + }); + + if (!user) { + return { commits: [], hasMore: false }; + } + + const repoPrefix = getRepoPrefix(user.id, `${repoName}.git`); + const fs = createR2Fs(repoPrefix); + + try { + const commits = await git.log({ + fs, + gitdir: "/", + ref: branch, + depth: skip + limit + 1, + }); + + const paginatedCommits = commits.slice(skip, skip + limit); + const hasMore = commits.length > skip + limit; + + return { + commits: paginatedCommits.map((c) => ({ + oid: c.oid, + message: c.commit.message, + author: { + name: c.commit.author.name, + email: c.commit.author.email, + }, + timestamp: c.commit.author.timestamp * 1000, + })), + hasMore, + }; + } catch { + return { commits: [], hasMore: false }; + } +} + +export async function getRepoCommitCount(owner: string, repoName: string, branch: string) { + const user = await db.query.users.findFirst({ + where: eq(users.username, owner), + }); + + if (!user) { + return 0; + } + + const repoPrefix = getRepoPrefix(user.id, `${repoName}.git`); + const fs = createR2Fs(repoPrefix); + + try { + const commits = await git.log({ + fs, + gitdir: "/", + ref: branch, + }); + return commits.length; + } catch { + return 0; + } +} + +export async function getPublicRepositories( + sortBy: "stars" | "updated" | "created" = "updated", + limit: number = 20, + offset: number = 0 +) { + const allRepos = await db + .select({ + id: repositories.id, + name: repositories.name, + description: repositories.description, + visibility: repositories.visibility, + ownerId: repositories.ownerId, + defaultBranch: repositories.defaultBranch, + createdAt: repositories.createdAt, + updatedAt: repositories.updatedAt, + ownerUsername: users.username, + ownerName: users.name, + ownerImage: users.image, + starCount: sql`(SELECT COUNT(*) FROM stars WHERE stars.repository_id = ${repositories.id})`.as("star_count"), + }) + .from(repositories) + .innerJoin(users, eq(repositories.ownerId, users.id)) + .where(eq(repositories.visibility, "public")) + .orderBy( + sortBy === "stars" + ? desc(sql`star_count`) + : sortBy === "created" + ? desc(repositories.createdAt) + : desc(repositories.updatedAt) + ) + .limit(limit + 1) + .offset(offset); + + const hasMore = allRepos.length > limit; + const repos = allRepos.slice(0, limit); + + return { + repos: repos.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + visibility: r.visibility as "public" | "private", + defaultBranch: r.defaultBranch, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + starCount: Number(r.starCount), + owner: { + id: r.ownerId, + username: r.ownerUsername, + name: r.ownerName, + image: r.ownerImage, + }, + })), + hasMore, + }; +} diff --git a/app/(main)/[username]/[repo]/blob/[...path]/page.tsx b/app/(main)/[username]/[repo]/blob/[...path]/page.tsx index 269c90a..46ee6b0 100644 --- a/app/(main)/[username]/[repo]/blob/[...path]/page.tsx +++ b/app/(main)/[username]/[repo]/blob/[...path]/page.tsx @@ -1,7 +1,8 @@ import { notFound } from "next/navigation"; import Link from "next/link"; -import { getRepository, getRepoFile } from "@/actions/repositories"; +import { getRepository, getRepoFile, getRepoBranches } from "@/actions/repositories"; import { CodeViewer } from "@/components/code-viewer"; +import { BranchSelector } from "@/components/branch-selector"; import { Badge } from "@/components/ui/badge"; import { Lock, Globe, ChevronRight, Home, FileCode } from "lucide-react"; @@ -42,7 +43,10 @@ export default async function BlobPage({ params }: { params: Promise<{ username: notFound(); } - const file = await getRepoFile(username, repoName, branch, filePath); + const [file, branches] = await Promise.all([ + getRepoFile(username, repoName, branch, filePath), + getRepoBranches(username, repoName), + ]); if (!file) { notFound(); @@ -81,7 +85,15 @@ export default async function BlobPage({ params }: { params: Promise<{ username:
-
- +
+ + + {isOwner && ( + + )} +
{repo.description &&

{repo.description}

} @@ -59,9 +80,23 @@ export default async function RepoPage({ params }: { params: Promise<{ username:
-
- - {repo.defaultBranch} +
+ + {commitCount > 0 && ( + + + {commitCount} + commits + + )}
{fileTree?.isEmpty ? ( diff --git a/app/(main)/[username]/[repo]/settings/page.tsx b/app/(main)/[username]/[repo]/settings/page.tsx new file mode 100644 index 0000000..62295d9 --- /dev/null +++ b/app/(main)/[username]/[repo]/settings/page.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useState, useEffect, use } from "react"; +import { useRouter } from "next/navigation"; +import { getRepositoryWithStars, updateRepository, deleteRepository } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { Loader2, Lock, Globe, Trash2, AlertTriangle } from "lucide-react"; +import Link from "next/link"; + +type RepoData = { + id: string; + name: string; + description: string | null; + visibility: "public" | "private"; + ownerId: string; +}; + +export default function RepoSettingsPage({ + params, +}: { + params: Promise<{ username: string; repo: string }>; +}) { + const { username, repo: repoName } = use(params); + const router = useRouter(); + const { data: session } = useSession(); + const [repo, setRepo] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(""); + const [deleteOpen, setDeleteOpen] = useState(false); + const [formData, setFormData] = useState({ + name: "", + description: "", + visibility: "public" as "public" | "private", + }); + + useEffect(() => { + async function loadRepo() { + try { + const data = await getRepositoryWithStars(username, repoName); + if (data) { + setRepo(data); + setFormData({ + name: data.name, + description: data.description || "", + visibility: data.visibility, + }); + } + } finally { + setLoading(false); + } + } + loadRepo(); + }, [username, repoName]); + + const isOwner = session?.user?.id === repo?.ownerId; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!repo) return; + + setSaving(true); + try { + const updated = await updateRepository(repo.id, { + name: formData.name, + description: formData.description, + visibility: formData.visibility, + }); + toast.success("Settings saved"); + if (updated.name !== repo.name) { + router.push(`/${username}/${updated.name}/settings`); + } + setRepo({ ...repo, ...updated }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to save settings"); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!repo || deleteConfirm !== repo.name) return; + + setDeleting(true); + try { + await deleteRepository(repo.id); + toast.success("Repository deleted"); + router.push(`/${username}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to delete repository"); + setDeleting(false); + } + } + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + if (!repo || !isOwner) { + return ( +
+ + + +

Access Denied

+

+ You don't have permission to access this page +

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

Repository Settings

+

+ Manage settings for{" "} + + {username}/{repo.name} + +

+
+ +
+ + + General + Basic repository information + + +
+ + setFormData({ ...formData, name: e.target.value })} + pattern="^[a-zA-Z0-9_.-]+$" + required + /> +
+ +
+ +