6.2 KB179 lines
Blame
1"use client";
2
3import { useState, useEffect } from "react";
4import Link from "next/link";
5import { useParams, usePathname } from "next/navigation";
6import { orgs as orgsApi } from "@/lib/api";
7import { useAuth } from "@/lib/auth";
8import { GroveLogo } from "@/app/components/grove-logo";
9import { ThemeToggle } from "@/app/theme-toggle";
10import { DropdownItem } from "@/app/components/ui/dropdown";
11import { NavBar } from "@/app/components/ui/navbar";
12import { useAppSwitcherItems } from "@/lib/use-app-switcher";
13
14function useIsProductHost() {
15 const [isProductHost, setIsProductHost] = useState<boolean | null>(null);
16 useEffect(() => {
17 const hostname = window.location.hostname;
18 setIsProductHost(
19 hostname.startsWith("canopy.") || hostname.startsWith("ring.") || hostname.startsWith("collab.")
20 );
21 }, []);
22 return isProductHost;
23}
24
25export function Nav() {
26 const params = useParams<{ owner?: string; repo?: string }>();
27 const owner = params?.owner;
28 const repo = params?.repo;
29 const pathname = usePathname();
30 const isProductHost = useIsProductHost();
31 const { user } = useAuth();
32 const [isRepoOwner, setIsRepoOwner] = useState(false);
33 const appSwitcherItems = useAppSwitcherItems("grove", { owner, repo });
34
35 // Determine if the current user owns this repo (directly or via org membership)
36 useEffect(() => {
37 if (!owner || !repo || !user) {
38 setIsRepoOwner(false);
39 return;
40 }
41 // Quick check: if the URL owner matches the username, they own it
42 if (owner === user.username) {
43 setIsRepoOwner(true);
44 return;
45 }
46 // Otherwise check org membership
47 orgsApi
48 .get(owner)
49 .then(({ members }) => {
50 setIsRepoOwner(members.some((m) => m.username === user.username));
51 })
52 .catch(() => setIsRepoOwner(false));
53 }, [owner, repo, user]);
54
55 // Don't render Grove nav on product subdomains
56 if (isProductHost === null || isProductHost) return null;
57
58 const repoTabs = owner && repo ? [
59 { key: "code", label: "Code", shortLabel: "Code", href: `/${owner}/${repo}` },
60 { key: "diffs", label: "Diffs", shortLabel: "Diffs", href: `/${owner}/${repo}/diffs` },
61 { key: "commits", label: "Commits", shortLabel: "Commits", href: `/${owner}/${repo}/commits` },
62 ...(isRepoOwner ? [{ key: "settings", label: "Settings", shortLabel: "Settings", href: `/${owner}/${repo}/settings` }] : []),
63 ] : null;
64
65 const repoPrefix = owner && repo ? `/${owner}/${repo}` : "";
66 const repoPath = pathname?.startsWith(repoPrefix) ? pathname.slice(repoPrefix.length) : "";
67 const activeTab =
68 repoPath.startsWith("/commit") ? "commits"
69 : repoPath.startsWith("/diffs") ? "diffs"
70 : repoPath.startsWith("/settings") ? "settings"
71 : "code";
72
73 // Parse file/tree path breadcrumbs from URL like /blob/main/src/lib/utils.ts
74 let pathBreadcrumbs: { name: string; href: string; isLast: boolean }[] = [];
75 const blobMatch = repoPath.match(/^\/(blob|tree)\/([^/]+)\/(.+)$/);
76 if (blobMatch && owner && repo) {
77 const [, routeType, ref, filePath] = blobMatch;
78 const parts = filePath.split("/");
79 pathBreadcrumbs = parts.map((part, i) => {
80 const partPath = parts.slice(0, i + 1).join("/");
81 const isLast = i === parts.length - 1;
82 const type = isLast && routeType === "blob" ? "blob" : "tree";
83 return {
84 name: decodeURIComponent(part),
85 href: `/${owner}/${repo}/${type}/${ref}/${partPath}`,
86 isLast,
87 };
88 });
89 }
90
91 return (
92 <NavBar
93 logo={<GroveLogo size={28} />}
94 productName="Grove"
95 showProductName={!owner}
96 breadcrumbs={
97 <>
98 {owner && (
99 <>
100 <span style={{ color: "var(--text-faint)" }}>/</span>
101 <Link
102 href={`/${owner}`}
103 className="hover:underline truncate"
104 style={{ color: "var(--text-muted)" }}
105 >
106 {owner}
107 </Link>
108 </>
109 )}
110 {owner && repo && (
111 <>
112 <span style={{ color: "var(--text-faint)" }}>/</span>
113 <Link
114 href={`/${owner}/${repo}`}
115 className="hover:underline truncate"
116 style={{ color: pathBreadcrumbs.length > 0 ? "var(--text-muted)" : "var(--text-primary)" }}
117 >
118 {repo}
119 </Link>
120 </>
121 )}
122 {pathBreadcrumbs.map((crumb) => (
123 <span key={crumb.href} className={`hidden sm:flex items-center gap-1.5 sm:gap-3`}>
124 <span style={{ color: "var(--text-faint)" }}>/</span>
125 {crumb.isLast ? (
126 <span style={{ color: "var(--text-primary)" }}>{crumb.name}</span>
127 ) : (
128 <Link
129 href={crumb.href}
130 className="hover:underline"
131 style={{ color: "var(--text-muted)" }}
132 >
133 {crumb.name}
134 </Link>
135 )}
136 </span>
137 ))}
138 </>
139 }
140 actions={!user ? <ThemeToggle /> : undefined}
141 appSwitcherItems={appSwitcherItems}
142 menuItems={
143 <>
144 <DropdownItem onClick={() => { window.location.href = "/dashboard"; }}>
145 Dashboard
146 </DropdownItem>
147 <DropdownItem onClick={() => { window.location.href = "/settings"; }}>
148 Settings
149 </DropdownItem>
150 </>
151 }
152 belowBar={
153 repoTabs ? (
154 <div className="flex px-3 sm:px-6 gap-0 text-sm overflow-x-auto overflow-y-hidden">
155 {repoTabs.map((tab) => {
156 const isActive = tab.key === activeTab;
157 return (
158 <Link
159 key={tab.key}
160 href={tab.href}
161 className="px-3 py-2 -mb-px transition-colors shrink-0"
162 style={{
163 color: isActive ? "var(--text-primary)" : "var(--text-muted)",
164 borderBottom: isActive
165 ? "2px solid var(--accent)"
166 : "2px solid transparent",
167 }}
168 >
169 {tab.label}
170 </Link>
171 );
172 })}
173 </div>
174 ) : undefined
175 }
176 />
177 );
178}
179