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