mirror of
https://github.com/better-auth/better-hub
synced 2026-08-09 12:26:04 +00:00
feat: repo selector in breadcrumb (#19)
https://github.com/user-attachments/assets/a7f58bae-6c11-4c55-b38f-2be493f1f632
This commit is contained in:
@@ -154,6 +154,7 @@ export default async function RepoLayout({
|
||||
<RepoLayoutWrapper
|
||||
owner={owner}
|
||||
repo={repoName}
|
||||
ownerType={repoData.owner.type}
|
||||
initialCollapsed={sidebarState?.collapsed}
|
||||
initialWidth={sidebarState?.width}
|
||||
sidebar={
|
||||
|
||||
@@ -69,7 +69,9 @@ export default function PRDetailLoading() {
|
||||
<div className="h-3.5 w-3.5 rounded bg-muted/25 shrink-0" />
|
||||
<div
|
||||
className="h-3.5 rounded bg-muted/30"
|
||||
style={{ width: file.nameW }}
|
||||
style={{
|
||||
width: file.nameW,
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1 ml-auto shrink-0">
|
||||
<div className="h-3 w-10 rounded bg-green-500/15" />
|
||||
@@ -181,7 +183,8 @@ export default function PRDetailLoading() {
|
||||
className={`h-3 rounded ${
|
||||
line.type === "add"
|
||||
? "bg-green-500/10"
|
||||
: line.type === "del"
|
||||
: line.type ===
|
||||
"del"
|
||||
? "bg-red-500/10"
|
||||
: "bg-muted/15"
|
||||
}`}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOrgRepos } from "@/lib/github";
|
||||
|
||||
interface SlimRepo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
stargazers_count: number;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const org = request.nextUrl.searchParams.get("org");
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: "Missing org parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const repos = await getOrgRepos(org, { perPage: 100, sort: "pushed" });
|
||||
const repoList = Array.isArray(repos) ? repos : [];
|
||||
const slim: SlimRepo[] = repoList.map((r) => ({
|
||||
name: r.name,
|
||||
full_name: r.full_name,
|
||||
description: r.description ?? null,
|
||||
stargazers_count: r.stargazers_count ?? 0,
|
||||
private: r.private ?? false,
|
||||
}));
|
||||
return NextResponse.json({ repos: slim });
|
||||
}
|
||||
@@ -66,10 +66,7 @@ export default async function HomePage() {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d={LOGO_SVG_PATH}
|
||||
fill="url(#paint0_hero)"
|
||||
/>
|
||||
<path d={LOGO_SVG_PATH} fill="url(#paint0_hero)" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_hero"
|
||||
@@ -80,10 +77,7 @@ export default async function HomePage() {
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="white" />
|
||||
<stop
|
||||
offset="1"
|
||||
stopColor="#999999"
|
||||
/>
|
||||
<stop offset="1" stopColor="#999999" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
@@ -465,11 +465,11 @@ export function CommandMenu() {
|
||||
setOpen(true);
|
||||
}
|
||||
if (e.key === "t" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const target = e.target as HTMLElement;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
active?.tagName === "INPUT" ||
|
||||
active?.tagName === "TEXTAREA" ||
|
||||
active?.isContentEditable
|
||||
)
|
||||
return;
|
||||
if (!matchRepoFromPathname(window.location.pathname)) return;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronDown, Lock, Star, Loader2, Search } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { formatNumber } from "@/lib/utils";
|
||||
|
||||
interface OrgRepo {
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
stargazers_count: number;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
interface RepoBreadcrumbProps {
|
||||
owner: string;
|
||||
repoName: string;
|
||||
ownerType: string;
|
||||
}
|
||||
|
||||
export function RepoBreadcrumb({ owner, repoName, ownerType }: RepoBreadcrumbProps) {
|
||||
const router = useRouter();
|
||||
const [repos, setRepos] = useState<OrgRepo[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const fetchedRef = useRef(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isOrg = ownerType === "Organization";
|
||||
|
||||
const fetchOrgRepos = useCallback(async () => {
|
||||
if (fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/org-repos?org=${encodeURIComponent(owner)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch repos");
|
||||
const data = await response.json();
|
||||
setRepos(data.repos);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
fetchedRef.current = false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [owner]);
|
||||
|
||||
const filteredRepos = useMemo(() => {
|
||||
if (!repos) return null;
|
||||
if (!filter.trim()) return repos;
|
||||
const query = filter.toLowerCase();
|
||||
return repos.filter((repo) => repo.name.toLowerCase().includes(query));
|
||||
}, [repos, filter]);
|
||||
|
||||
const handleRepoSelect = (selectedRepo: OrgRepo) => {
|
||||
if (selectedRepo.name !== repoName) {
|
||||
router.push(`/${owner}/${selectedRepo.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
fetchOrgRepos();
|
||||
} else {
|
||||
setFilter("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Link
|
||||
href={`/${owner}`}
|
||||
className="text-muted-foreground/60 hover:text-foreground transition-colors tracking-tight"
|
||||
>
|
||||
{owner}
|
||||
</Link>
|
||||
<span className="text-muted-foreground/30">/</span>
|
||||
{isOrg ? (
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1 font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight outline-none">
|
||||
{repoName}
|
||||
<ChevronDown className="w-3 h-3 mt-0.5 text-muted-foreground/50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-72 max-h-80 overflow-hidden flex flex-col p-2"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : hasError ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
|
||||
Failed to load repositories
|
||||
</div>
|
||||
) : repos && repos.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border mb-2">
|
||||
<Search className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Filter repositories..."
|
||||
value={filter}
|
||||
onChange={(e) =>
|
||||
setFilter(
|
||||
e
|
||||
.target
|
||||
.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{filteredRepos &&
|
||||
filteredRepos.length > 0 ? (
|
||||
filteredRepos.map(
|
||||
(repo) => (
|
||||
<DropdownMenuItem
|
||||
key={
|
||||
repo.name
|
||||
}
|
||||
onClick={() =>
|
||||
handleRepoSelect(
|
||||
repo,
|
||||
)
|
||||
}
|
||||
className={`flex flex-col items-start gap-0.5 py-2 cursor-pointer ${
|
||||
repo.name ===
|
||||
repoName
|
||||
? "bg-accent"
|
||||
: ""
|
||||
}`}
|
||||
autoFocus={
|
||||
false
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 w-full">
|
||||
<span className="font-medium text-xs truncate">
|
||||
{
|
||||
repo.name
|
||||
}
|
||||
</span>
|
||||
{repo.private && (
|
||||
<Lock className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
<span className="flex items-center gap-0.5 ml-auto text-[10px] text-muted-foreground/60 shrink-0">
|
||||
<Star className="size-2 opacity-50" />
|
||||
{formatNumber(
|
||||
repo.stargazers_count,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] min-h-[14px] text-muted-foreground/70 line-clamp-1">
|
||||
{repo.description ??
|
||||
null}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
|
||||
No matching
|
||||
repositories
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground text-center">
|
||||
No repositories found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${owner}/${repoName}`}
|
||||
className="font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight"
|
||||
>
|
||||
{repoName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { RepoBreadcrumb } from "@/components/repo/repo-breadcrumb";
|
||||
import { useState, useCallback, useRef, useTransition } from "react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { setRepoSidebarState } from "./repo-sidebar-actions";
|
||||
@@ -11,6 +11,7 @@ interface RepoLayoutWrapperProps {
|
||||
children: React.ReactNode;
|
||||
owner: string;
|
||||
repo: string;
|
||||
ownerType: string;
|
||||
initialCollapsed?: boolean;
|
||||
initialWidth?: number;
|
||||
}
|
||||
@@ -26,6 +27,7 @@ export function RepoLayoutWrapper({
|
||||
repo,
|
||||
initialCollapsed = false,
|
||||
initialWidth = DEFAULT_WIDTH,
|
||||
ownerType,
|
||||
}: RepoLayoutWrapperProps) {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(initialCollapsed ? 0 : initialWidth);
|
||||
const lastOpenWidthRef = useRef(initialWidth);
|
||||
@@ -199,21 +201,13 @@ export function RepoLayoutWrapper({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`hidden lg:flex items-center gap-1 text-xs px-2 pt-3 pb-1 ${collapsed ? "visible" : "invisible"}`}
|
||||
className={`hidden lg:flex px-2 pt-3 pb-1 ${collapsed ? "visible" : "invisible"}`}
|
||||
>
|
||||
<Link
|
||||
href={`/${owner}`}
|
||||
className="text-muted-foreground/60 hover:text-foreground transition-colors tracking-tight"
|
||||
>
|
||||
{owner}
|
||||
</Link>
|
||||
<span className="text-muted-foreground/30">/</span>
|
||||
<Link
|
||||
href={`/${owner}/${repo}`}
|
||||
className="font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight"
|
||||
>
|
||||
{repo}
|
||||
</Link>
|
||||
<RepoBreadcrumb
|
||||
owner={owner}
|
||||
repoName={repo}
|
||||
ownerType={ownerType}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -906,7 +906,6 @@ function HighlightedActivityTicker({ items }: { items: HotItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export interface RepoOverviewProps {
|
||||
owner: string;
|
||||
repo: string;
|
||||
@@ -1069,12 +1068,7 @@ export function RepoOverview({
|
||||
);
|
||||
}
|
||||
|
||||
const { data: readmeHtml } = useReadme(
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
initialReadmeHtml ?? null,
|
||||
);
|
||||
const { data: readmeHtml } = useReadme(owner, repo, branch, initialReadmeHtml ?? null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { StarButton } from "@/components/repo/star-button";
|
||||
import { SidebarLanguages } from "@/components/repo/sidebar-languages";
|
||||
import { SidebarContributors } from "@/components/repo/sidebar-contributors";
|
||||
import { LatestCommitSection } from "@/components/repo/latest-commit-section";
|
||||
import { RepoBreadcrumb } from "@/components/repo/repo-breadcrumb";
|
||||
import type { ContributorAvatarsData } from "@/lib/repo-data-cache";
|
||||
|
||||
interface LatestCommit {
|
||||
@@ -94,21 +95,11 @@ export function RepoSidebar({
|
||||
<aside className="hidden lg:flex w-[260px] shrink-0 overflow-y-auto pt-0 px-4 pb-4 flex-col gap-5">
|
||||
{/* Name + Avatar + Description + Badges */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Link
|
||||
href={`/${owner}`}
|
||||
className="text-muted-foreground/60 hover:text-foreground transition-colors tracking-tight"
|
||||
>
|
||||
{owner}
|
||||
</Link>
|
||||
<span className="text-muted-foreground/30">/</span>
|
||||
<Link
|
||||
href={`/${owner}/${repoName}`}
|
||||
className="font-medium text-foreground hover:text-foreground/80 transition-colors tracking-tight"
|
||||
>
|
||||
{repoName}
|
||||
</Link>
|
||||
</div>
|
||||
<RepoBreadcrumb
|
||||
owner={owner}
|
||||
repoName={repoName}
|
||||
ownerType={ownerType}
|
||||
/>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
|
||||
Reference in New Issue
Block a user