- New AppSwitcher component in navbar — hover/click logo pill to switch between Grove, Canopy, Ring, Collab - useAppSwitcherItems hook builds context-aware subdomain links - Removed product tabs (Canopy/Ring/Collab) from grove repo tab bar - Removed canopy status polling from main nav - GroveLogo animates on its own row hover via .hover-row:hover - Mobile: icon-only switcher with symmetric padding, pinned nav - Ring nav updated with productName and link breadcrumbs
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { Badge } from "@/app/components/ui/badge"; |
| 9 | 9 | import { NavBar } from "@/app/components/ui/navbar"; |
| 10 | 10 | import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events"; |
| 11 | import { useAppSwitcherItems } from "@/lib/use-app-switcher"; | |
| 11 | 12 | |
| 12 | 13 | const statusLabels: Record<string, string> = { |
| 13 | 14 | pending: "Pending", |
| @@ -189,11 +190,14 @@ | ||
| 189 | 190 | const isStatusPending = runId ? runStatusLoading : !repoStatusLoaded; |
| 190 | 191 | useStatusFavicon(effectiveStatus, !!isStatusPending); |
| 191 | 192 | |
| 193 | const appSwitcherItems = useAppSwitcherItems("canopy", { owner, repo }); | |
| 194 | ||
| 192 | 195 | return ( |
| 193 | 196 | <NavBar |
| 194 | 197 | logo={<CanopyLogo size={28} />} |
| 195 | 198 | productName="Canopy" |
| 196 | 199 | showProductName={!owner} |
| 200 | appSwitcherItems={appSwitcherItems} | |
| 197 | 201 | breadcrumbs={ |
| 198 | 202 | <> |
| 199 | 203 | {owner && ( |
| 200 | 204 | |
| @@ -5,15 +5,18 @@ | ||
| 5 | 5 | import { CollabLogo } from "@/app/components/collab-logo"; |
| 6 | 6 | import { NavBar } from "@/app/components/ui/navbar"; |
| 7 | 7 | import { CollabNavActionsSlot } from "./collab-nav-actions"; |
| 8 | import { useAppSwitcherItems } from "@/lib/use-app-switcher"; | |
| 8 | 9 | |
| 9 | 10 | export function CollabNav() { |
| 10 | 11 | const params = useParams<{ owner?: string; repo?: string }>(); |
| 11 | 12 | const owner = params?.owner; |
| 12 | 13 | const repo = params?.repo; |
| 14 | const appSwitcherItems = useAppSwitcherItems("collab", { owner, repo }); | |
| 13 | 15 | |
| 14 | 16 | return ( |
| 15 | 17 | <NavBar |
| 16 | 18 | logo={<CollabLogo size={28} />} |
| 19 | appSwitcherItems={appSwitcherItems} | |
| 17 | 20 | productName="Collab" |
| 18 | 21 | showProductName={!owner} |
| 19 | 22 | breadcrumbs={ |
| 20 | 23 | |
| @@ -37,30 +37,33 @@ | ||
| 37 | 37 | cursor: pointer; |
| 38 | 38 | transition: transform 0.5s cubic-bezier(0.25, 1, 0.5, 1); |
| 39 | 39 | } |
| 40 | .grove-logo-${styleId}:hover { | |
| 40 | .grove-logo-${styleId}:hover, | |
| 41 | .hover-row:hover .grove-logo-${styleId} { | |
| 41 | 42 | transform: translateY(-2px); |
| 42 | 43 | } |
| 43 | 44 | .grove-logo-${styleId} .gl-bg { |
| 44 | 45 | transition: fill 0.6s ease; |
| 45 | 46 | } |
| 46 | .grove-logo-${styleId}:hover .gl-bg { | |
| 47 | .grove-logo-${styleId}:hover .gl-bg, | |
| 48 | .hover-row:hover .grove-logo-${styleId} .gl-bg { | |
| 47 | 49 | filter: brightness(0.9); |
| 48 | 50 | } |
| 49 | 51 | .grove-logo-${styleId} .gl-tree { |
| 50 | 52 | transform-origin: 30px 52px; |
| 51 | 53 | transform: rotate(var(--sway, 0deg)); |
| 52 | transition: transform 0.15s ease-out; | |
| 53 | } | |
| 54 | .grove-logo-${styleId}:not(:hover) .gl-tree { | |
| 55 | 54 | transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1); |
| 56 | 55 | } |
| 56 | .grove-logo-${styleId}:hover .gl-tree, | |
| 57 | .hover-row:hover .grove-logo-${styleId} .gl-tree { | |
| 58 | transition: transform 0.15s ease-out; | |
| 59 | } | |
| 57 | 60 | .grove-logo-${styleId} .gl-branch { |
| 58 | 61 | transition: stroke-width 0.5s cubic-bezier(0.25, 1, 0.5, 1); |
| 59 | 62 | } |
| 60 | .grove-logo-${styleId}:hover .gl-b0 { stroke-width: 4.5; transition-delay: 0s; } | |
| 61 | .grove-logo-${styleId}:hover .gl-b1 { stroke-width: 3.5; transition-delay: 0.05s; } | |
| 62 | .grove-logo-${styleId}:hover .gl-b2 { stroke-width: 3; transition-delay: 0.1s; } | |
| 63 | .grove-logo-${styleId}:hover .gl-b3 { stroke-width: 2.5; transition-delay: 0.15s; } | |
| 63 | .grove-logo-${styleId}:hover .gl-b0, .hover-row:hover .grove-logo-${styleId} .gl-b0 { stroke-width: 4.5; transition-delay: 0s; } | |
| 64 | .grove-logo-${styleId}:hover .gl-b1, .hover-row:hover .grove-logo-${styleId} .gl-b1 { stroke-width: 3.5; transition-delay: 0.05s; } | |
| 65 | .grove-logo-${styleId}:hover .gl-b2, .hover-row:hover .grove-logo-${styleId} .gl-b2 { stroke-width: 3; transition-delay: 0.1s; } | |
| 66 | .grove-logo-${styleId}:hover .gl-b3, .hover-row:hover .grove-logo-${styleId} .gl-b3 { stroke-width: 2.5; transition-delay: 0.15s; } | |
| 64 | 67 | .grove-logo-${styleId} .gl-node { |
| 65 | 68 | transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); |
| 66 | 69 | } |
| @@ -69,12 +72,12 @@ | ||
| 69 | 72 | .grove-logo-${styleId} .gl-n2 { transform-origin: 48px 23px; } |
| 70 | 73 | .grove-logo-${styleId} .gl-n3 { transform-origin: 14px 33px; } |
| 71 | 74 | .grove-logo-${styleId} .gl-n4 { transform-origin: 19px 11px; } |
| 72 | .grove-logo-${styleId}:hover .gl-node { transform: scale(1.25); } | |
| 73 | .grove-logo-${styleId}:hover .gl-n0 { transition-delay: 0s; } | |
| 74 | .grove-logo-${styleId}:hover .gl-n3 { transition-delay: 0.05s; } | |
| 75 | .grove-logo-${styleId}:hover .gl-n2 { transition-delay: 0.1s; } | |
| 76 | .grove-logo-${styleId}:hover .gl-n4 { transition-delay: 0.15s; } | |
| 77 | .grove-logo-${styleId}:hover .gl-n1 { transition-delay: 0.2s; } | |
| 75 | .grove-logo-${styleId}:hover .gl-node, .hover-row:hover .grove-logo-${styleId} .gl-node { transform: scale(1.25); } | |
| 76 | .grove-logo-${styleId}:hover .gl-n0, .hover-row:hover .grove-logo-${styleId} .gl-n0 { transition-delay: 0s; } | |
| 77 | .grove-logo-${styleId}:hover .gl-n3, .hover-row:hover .grove-logo-${styleId} .gl-n3 { transition-delay: 0.05s; } | |
| 78 | .grove-logo-${styleId}:hover .gl-n2, .hover-row:hover .grove-logo-${styleId} .gl-n2 { transition-delay: 0.1s; } | |
| 79 | .grove-logo-${styleId}:hover .gl-n4, .hover-row:hover .grove-logo-${styleId} .gl-n4 { transition-delay: 0.15s; } | |
| 80 | .grove-logo-${styleId}:hover .gl-n1, .hover-row:hover .grove-logo-${styleId} .gl-n1 { transition-delay: 0.2s; } | |
| 78 | 81 | `}</style> |
| 79 | 82 | |
| 80 | 83 | <circle cx="32" cy="32" r="32" fill="var(--accent)" className="gl-bg" /> |
| 81 | 84 | |
| @@ -1,25 +1,8 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { useState, useRef, useCallback } from "react"; | |
| 3 | import { useState, useRef, useCallback, useEffect } from "react"; | |
| 4 | 4 | import Link from "next/link"; |
| 5 | 5 | import { useAuth } from "@/lib/auth"; |
| 6 | import { Dropdown, DropdownItem } from "@/app/components/ui/dropdown"; | |
| 7 | ||
| 8 | function HamburgerIcon() { | |
| 9 | return ( | |
| 10 | <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | |
| 11 | <path d="M3 5h12M3 9h12M3 13h12" /> | |
| 12 | </svg> | |
| 13 | ); | |
| 14 | } | |
| 15 | ||
| 16 | function CloseIcon() { | |
| 17 | return ( | |
| 18 | <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | |
| 19 | <path d="M4 4l10 10M14 4L4 14" /> | |
| 20 | </svg> | |
| 21 | ); | |
| 22 | } | |
| 23 | 6 | |
| 24 | 7 | function UserPill({ |
| 25 | 8 | user, |
| @@ -32,7 +15,6 @@ | ||
| 32 | 15 | }) { |
| 33 | 16 | const [expanded, setExpanded] = useState(false); |
| 34 | 17 | const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 35 | const menuRef = useRef<HTMLDivElement>(null); | |
| 36 | 18 | |
| 37 | 19 | const handleEnter = useCallback(() => { |
| 38 | 20 | if (collapseTimer.current) clearTimeout(collapseTimer.current); |
| @@ -82,6 +64,7 @@ | ||
| 82 | 64 | {/* Pill trigger row */} |
| 83 | 65 | <div |
| 84 | 66 | className="flex items-center gap-2" |
| 67 | onClick={() => setExpanded((v) => !v)} | |
| 85 | 68 | style={{ |
| 86 | 69 | padding: "5px 12px 5px 8px", |
| 87 | 70 | color: "var(--accent)", |
| @@ -114,7 +97,6 @@ | ||
| 114 | 97 | |
| 115 | 98 | {/* Menu items — expand below the pill row */} |
| 116 | 99 | <div |
| 117 | ref={menuRef} | |
| 118 | 100 | style={{ |
| 119 | 101 | display: "grid", |
| 120 | 102 | gridTemplateRows: expanded ? "1fr" : "0fr", |
| @@ -154,6 +136,136 @@ | ||
| 154 | 136 | ); |
| 155 | 137 | } |
| 156 | 138 | |
| 139 | export interface AppSwitcherItem { | |
| 140 | name: string; | |
| 141 | logo: React.ReactNode; | |
| 142 | href: string; | |
| 143 | } | |
| 144 | ||
| 145 | function AppSwitcher({ | |
| 146 | logo, | |
| 147 | productName, | |
| 148 | showProductName, | |
| 149 | items, | |
| 150 | }: { | |
| 151 | logo: React.ReactNode; | |
| 152 | productName?: string; | |
| 153 | showProductName?: boolean; | |
| 154 | items: AppSwitcherItem[]; | |
| 155 | }) { | |
| 156 | const [expanded, setExpanded] = useState(false); | |
| 157 | const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| 158 | ||
| 159 | const handleEnter = useCallback(() => { | |
| 160 | if (collapseTimer.current) clearTimeout(collapseTimer.current); | |
| 161 | setExpanded(true); | |
| 162 | }, []); | |
| 163 | ||
| 164 | const handleLeave = useCallback(() => { | |
| 165 | collapseTimer.current = setTimeout(() => setExpanded(false), 200); | |
| 166 | }, []); | |
| 167 | ||
| 168 | const rowClass = "flex items-center gap-2.5 p-[5px] sm:py-[5px] sm:pl-[5px] sm:pr-[12px]"; | |
| 169 | ||
| 170 | return ( | |
| 171 | <div | |
| 172 | onMouseEnter={handleEnter} | |
| 173 | onMouseLeave={handleLeave} | |
| 174 | style={{ position: "relative", zIndex: 50 }} | |
| 175 | className="shrink-0" | |
| 176 | > | |
| 177 | {/* Invisible spacer — reserves trigger size in navbar flow */} | |
| 178 | <div | |
| 179 | className={rowClass} | |
| 180 | style={{ | |
| 181 | border: "1px solid transparent", | |
| 182 | visibility: "hidden", | |
| 183 | whiteSpace: "nowrap", | |
| 184 | }} | |
| 185 | > | |
| 186 | <span style={{ width: 28, height: 28, flexShrink: 0 }} /> | |
| 187 | {productName && ( | |
| 188 | <span className="hidden sm:inline text-sm font-medium">{productName}</span> | |
| 189 | )} | |
| 190 | </div> | |
| 191 | ||
| 192 | {/* Expanding border container — positioned over the spacer */} | |
| 193 | <div | |
| 194 | style={{ | |
| 195 | position: "absolute", | |
| 196 | top: 0, | |
| 197 | left: 0, | |
| 198 | minWidth: "100%", | |
| 199 | width: expanded ? "max-content" : undefined, | |
| 200 | border: "1px solid var(--border)", | |
| 201 | borderRadius: "16px", | |
| 202 | backgroundColor: "var(--bg-page)", | |
| 203 | overflow: "hidden", | |
| 204 | transition: "width 0.2s ease", | |
| 205 | }} | |
| 206 | > | |
| 207 | {/* Current app trigger row */} | |
| 208 | <div | |
| 209 | className={rowClass} | |
| 210 | onClick={() => setExpanded((v) => !v)} | |
| 211 | style={{ cursor: "pointer", whiteSpace: "nowrap" }} | |
| 212 | > | |
| 213 | <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}> | |
| 214 | {logo} | |
| 215 | </span> | |
| 216 | {productName && ( | |
| 217 | <span | |
| 218 | className="hidden sm:inline text-sm font-medium" | |
| 219 | style={{ color: "var(--text-primary)" }} | |
| 220 | > | |
| 221 | {productName} | |
| 222 | </span> | |
| 223 | )} | |
| 224 | </div> | |
| 225 | ||
| 226 | {/* Other apps — expand below */} | |
| 227 | <div | |
| 228 | style={{ | |
| 229 | display: "grid", | |
| 230 | gridTemplateRows: expanded ? "1fr" : "0fr", | |
| 231 | transition: "grid-template-rows 0.2s ease", | |
| 232 | }} | |
| 233 | > | |
| 234 | <div style={{ overflow: "hidden" }}> | |
| 235 | <div | |
| 236 | style={{ | |
| 237 | borderTop: "1px solid var(--border)", | |
| 238 | opacity: expanded ? 1 : 0, | |
| 239 | transition: "opacity 0.15s ease", | |
| 240 | }} | |
| 241 | > | |
| 242 | {items.map((item) => ( | |
| 243 | <a | |
| 244 | key={item.name} | |
| 245 | href={item.href} | |
| 246 | className={`${rowClass} hover-row`} | |
| 247 | style={{ | |
| 248 | color: "var(--text-primary)", | |
| 249 | textDecoration: "none", | |
| 250 | fontSize: "0.8125rem", | |
| 251 | whiteSpace: "nowrap", | |
| 252 | }} | |
| 253 | onClick={() => setExpanded(false)} | |
| 254 | > | |
| 255 | <span className="shrink-0" style={{ width: 28, height: 28, display: "flex", alignItems: "center", justifyContent: "center" }}> | |
| 256 | {item.logo} | |
| 257 | </span> | |
| 258 | <span className="hidden sm:inline">{item.name}</span> | |
| 259 | </a> | |
| 260 | ))} | |
| 261 | </div> | |
| 262 | </div> | |
| 263 | </div> | |
| 264 | </div> | |
| 265 | </div> | |
| 266 | ); | |
| 267 | } | |
| 268 | ||
| 157 | 269 | export interface NavBarProps { |
| 158 | 270 | /** Logo element (e.g. <GroveLogo size={28} />) */ |
| 159 | 271 | logo: React.ReactNode; |
| @@ -169,6 +281,8 @@ | ||
| 169 | 281 | belowBar?: React.ReactNode; |
| 170 | 282 | /** Extra items for the user dropdown, rendered before Sign out */ |
| 171 | 283 | menuItems?: React.ReactNode; |
| 284 | /** App switcher items shown when clicking the logo */ | |
| 285 | appSwitcherItems?: AppSwitcherItem[]; | |
| 172 | 286 | /** Extra items for the mobile side-panel user section (rendered as links, not DropdownItems) */ |
| 173 | 287 | mobileMenuItems?: React.ReactNode; |
| 174 | 288 | /** Mobile menu content (if provided, shows hamburger on mobile and hides user pill on mobile) */ |
| @@ -183,6 +297,7 @@ | ||
| 183 | 297 | actions, |
| 184 | 298 | belowBar, |
| 185 | 299 | menuItems, |
| 300 | appSwitcherItems, | |
| 186 | 301 | mobileMenuItems, |
| 187 | 302 | mobileMenu, |
| 188 | 303 | }: NavBarProps) { |
| @@ -190,7 +305,7 @@ | ||
| 190 | 305 | const [menuOpen, setMenuOpen] = useState(false); |
| 191 | 306 | |
| 192 | 307 | return ( |
| 193 | <nav style={{ borderBottom: "1px solid var(--border)", backgroundColor: "var(--bg-page)" }}> | |
| 308 | <nav style={{ borderBottom: "1px solid var(--border)", backgroundColor: "var(--bg-page)", position: "relative", zIndex: 50 }}> | |
| 194 | 309 | <div className="px-3 sm:px-6 h-14 flex items-center justify-between"> |
| 195 | 310 | <div className="flex items-center gap-1.5 sm:gap-3 text-sm min-w-0"> |
| 196 | 311 | {mobileMenu && ( |
| @@ -203,17 +318,26 @@ | ||
| 203 | 318 | {menuOpen ? <CloseIcon /> : <HamburgerIcon />} |
| 204 | 319 | </button> |
| 205 | 320 | )} |
| 206 | <Link href="/" className="shrink-0 flex items-center gap-2"> | |
| 207 | {logo} | |
| 208 | {productName && (showProductName ?? true) && ( | |
| 209 | <span | |
| 210 | className="text-lg font-medium" | |
| 211 | style={{ color: "var(--text-primary)", marginLeft: "0.25rem" }} | |
| 212 | > | |
| 213 | {productName} | |
| 214 | </span> | |
| 215 | )} | |
| 216 | </Link> | |
| 321 | {appSwitcherItems && appSwitcherItems.length > 0 ? ( | |
| 322 | <AppSwitcher | |
| 323 | logo={logo} | |
| 324 | productName={productName} | |
| 325 | showProductName={showProductName} | |
| 326 | items={appSwitcherItems} | |
| 327 | /> | |
| 328 | ) : ( | |
| 329 | <Link href="/" className="shrink-0 flex items-center gap-2"> | |
| 330 | {logo} | |
| 331 | {productName && (showProductName ?? true) && ( | |
| 332 | <span | |
| 333 | className="text-lg font-medium" | |
| 334 | style={{ color: "var(--text-primary)", marginLeft: "0.25rem" }} | |
| 335 | > | |
| 336 | {productName} | |
| 337 | </span> | |
| 338 | )} | |
| 339 | </Link> | |
| 340 | )} | |
| 217 | 341 | {breadcrumbs} |
| 218 | 342 | </div> |
| 219 | 343 | <div className={`${mobileMenu ? "hidden sm:flex" : "flex"} items-center gap-2 text-sm shrink-0`}> |
| @@ -303,3 +427,19 @@ | ||
| 303 | 427 | </nav> |
| 304 | 428 | ); |
| 305 | 429 | } |
| 430 | ||
| 431 | function HamburgerIcon() { | |
| 432 | return ( | |
| 433 | <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | |
| 434 | <path d="M3 5h12M3 9h12M3 13h12" /> | |
| 435 | </svg> | |
| 436 | ); | |
| 437 | } | |
| 438 | ||
| 439 | function CloseIcon() { | |
| 440 | return ( | |
| 441 | <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"> | |
| 442 | <path d="M4 4l10 10M14 4L4 14" /> | |
| 443 | </svg> | |
| 444 | ); | |
| 445 | } | |
| 306 | 446 | |
| @@ -111,6 +111,20 @@ | ||
| 111 | 111 | overflow-x: hidden; |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | .grove-body { | |
| 115 | height: 100dvh; | |
| 116 | overflow: hidden; | |
| 117 | display: flex; | |
| 118 | flex-direction: column; | |
| 119 | } | |
| 120 | .grove-scroll { | |
| 121 | flex: 1; | |
| 122 | min-height: 0; | |
| 123 | overflow: auto; | |
| 124 | /* iOS momentum scrolling */ | |
| 125 | -webkit-overflow-scrolling: touch; | |
| 126 | } | |
| 127 | ||
| 114 | 128 | code, pre, .font-mono, kbd, samp { |
| 115 | 129 | font-family: 'JetBrains Mono', 'Menlo', monospace; |
| 116 | 130 | } |
| @@ -174,7 +188,12 @@ | ||
| 174 | 188 | .hover-row { |
| 175 | 189 | transition: background-color 0.1s; |
| 176 | 190 | } |
| 177 | .hover-row:hover { | |
| 191 | @media (hover: hover) { | |
| 192 | .hover-row:hover { | |
| 193 | background-color: var(--bg-hover); | |
| 194 | } | |
| 195 | } | |
| 196 | .hover-row:active { | |
| 178 | 197 | background-color: var(--bg-hover); |
| 179 | 198 | } |
| 180 | 199 | |
| 181 | 200 | |
| @@ -21,13 +21,13 @@ | ||
| 21 | 21 | }) { |
| 22 | 22 | return ( |
| 23 | 23 | <html lang="en" suppressHydrationWarning> |
| 24 | <body style={{ height: "100vh", overflow: "hidden", display: "flex", flexDirection: "column", margin: 0 }}> | |
| 24 | <body className="grove-body" style={{ margin: 0 }}> | |
| 25 | 25 | <DevErrorReporter /> |
| 26 | 26 | <ThemeProvider> |
| 27 | 27 | <AuthProvider> |
| 28 | 28 | <ToastProvider> |
| 29 | 29 | <Nav /> |
| 30 | <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |
| 30 | <div className="grove-scroll"> | |
| 31 | 31 | <div style={{ display: "flex", flexDirection: "column", minHeight: "100%" }}> |
| 32 | 32 | <main style={{ flex: 1 }}> |
| 33 | 33 | {children} |
| 34 | 34 | |
| @@ -1,37 +1,14 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { useState, useEffect, useCallback, useRef, type CSSProperties } from "react"; | |
| 3 | import { useState, useEffect } from "react"; | |
| 4 | 4 | import Link from "next/link"; |
| 5 | 5 | import { useParams, usePathname } from "next/navigation"; |
| 6 | import { canopy, repos as reposApi, orgs as orgsApi } from "@/lib/api"; | |
| 6 | import { orgs as orgsApi } from "@/lib/api"; | |
| 7 | 7 | import { useAuth } from "@/lib/auth"; |
| 8 | 8 | import { GroveLogo } from "@/app/components/grove-logo"; |
| 9 | import { CanopyLogo } from "@/app/components/canopy-logo"; | |
| 10 | import { RingLogo } from "@/app/components/ring-logo"; | |
| 11 | import { CollabLogo } from "@/app/components/collab-logo"; | |
| 12 | 9 | import { DropdownItem } from "@/app/components/ui/dropdown"; |
| 13 | 10 | import { NavBar } from "@/app/components/ui/navbar"; |
| 14 | import { useCanopyEvents } from "@/lib/use-canopy-events"; | |
| 15 | ||
| 16 | function ExternalLinkIcon() { | |
| 17 | return ( | |
| 18 | <svg | |
| 19 | aria-hidden="true" | |
| 20 | width="11" | |
| 21 | height="11" | |
| 22 | viewBox="0 0 12 12" | |
| 23 | fill="none" | |
| 24 | style={{ color: "var(--text-faint)" }} | |
| 25 | > | |
| 26 | <path | |
| 27 | d="M4 2H10V8M10 2L2 10" | |
| 28 | stroke="currentColor" | |
| 29 | strokeWidth="1.2" | |
| 30 | strokeLinecap="square" | |
| 31 | /> | |
| 32 | </svg> | |
| 33 | ); | |
| 34 | } | |
| 11 | import { useAppSwitcherItems } from "@/lib/use-app-switcher"; | |
| 35 | 12 | |
| 36 | 13 | function useIsProductHost() { |
| 37 | 14 | const [isProductHost, setIsProductHost] = useState<boolean | null>(null); |
| @@ -44,105 +21,15 @@ | ||
| 44 | 21 | return isProductHost; |
| 45 | 22 | } |
| 46 | 23 | |
| 47 | function useCanopyOrigin() { | |
| 48 | const [origin, setOrigin] = useState(""); | |
| 49 | useEffect(() => { | |
| 50 | const host = window.location.host; | |
| 51 | const parts = host.split("."); | |
| 52 | if (parts[0] === "canopy") { | |
| 53 | setOrigin(window.location.origin); | |
| 54 | } else { | |
| 55 | setOrigin(`${window.location.protocol}//canopy.${host}`); | |
| 56 | } | |
| 57 | }, []); | |
| 58 | return origin; | |
| 59 | } | |
| 60 | ||
| 61 | function useRingOrigin() { | |
| 62 | const [origin, setOrigin] = useState(""); | |
| 63 | useEffect(() => { | |
| 64 | const host = window.location.host; | |
| 65 | const parts = host.split("."); | |
| 66 | if (parts[0] === "ring") { | |
| 67 | setOrigin(window.location.origin); | |
| 68 | } else { | |
| 69 | setOrigin(`${window.location.protocol}//ring.${host}`); | |
| 70 | } | |
| 71 | }, []); | |
| 72 | return origin; | |
| 73 | } | |
| 74 | ||
| 75 | function useCollabOrigin() { | |
| 76 | const [origin, setOrigin] = useState(""); | |
| 77 | useEffect(() => { | |
| 78 | const host = window.location.host; | |
| 79 | const parts = host.split("."); | |
| 80 | if (parts[0] === "collab") { | |
| 81 | setOrigin(window.location.origin); | |
| 82 | } else { | |
| 83 | setOrigin(`${window.location.protocol}//collab.${host}`); | |
| 84 | } | |
| 85 | }, []); | |
| 86 | return origin; | |
| 87 | } | |
| 88 | ||
| 89 | 24 | export function Nav() { |
| 90 | 25 | const params = useParams<{ owner?: string; repo?: string }>(); |
| 91 | 26 | const owner = params?.owner; |
| 92 | 27 | const repo = params?.repo; |
| 93 | 28 | const pathname = usePathname(); |
| 94 | 29 | const isProductHost = useIsProductHost(); |
| 95 | const canopyOrigin = useCanopyOrigin(); | |
| 96 | const ringOrigin = useRingOrigin(); | |
| 97 | const collabOrigin = useCollabOrigin(); | |
| 98 | 30 | const { user } = useAuth(); |
| 99 | 31 | const [isRepoOwner, setIsRepoOwner] = useState(false); |
| 100 | const [canopyStatus, setCanopyStatus] = useState<string | null | undefined>(undefined); | |
| 101 | ||
| 102 | const canopyFetchActive = useRef(false); | |
| 103 | const canopyFetchQueued = useRef(false); | |
| 104 | ||
| 105 | const fetchCanopyStatus = useCallback((o: string, r: string) => { | |
| 106 | if (canopyFetchActive.current) { | |
| 107 | canopyFetchQueued.current = true; | |
| 108 | return; | |
| 109 | } | |
| 110 | canopyFetchActive.current = true; | |
| 111 | canopy | |
| 112 | .listRuns(o, r, { limit: 10 }) | |
| 113 | .then((data) => { | |
| 114 | const recent = data.runs.find((run) => run.status !== "cancelled"); | |
| 115 | setCanopyStatus(recent?.status ?? null); | |
| 116 | }) | |
| 117 | .catch(() => { | |
| 118 | setCanopyStatus(null); | |
| 119 | }) | |
| 120 | .finally(() => { | |
| 121 | canopyFetchActive.current = false; | |
| 122 | if (canopyFetchQueued.current) { | |
| 123 | canopyFetchQueued.current = false; | |
| 124 | fetchCanopyStatus(o, r); | |
| 125 | } | |
| 126 | }); | |
| 127 | }, []); | |
| 128 | ||
| 129 | useEffect(() => { | |
| 130 | if (!owner || !repo || isProductHost !== false) { | |
| 131 | setCanopyStatus(undefined); | |
| 132 | return; | |
| 133 | } | |
| 134 | fetchCanopyStatus(owner, repo); | |
| 135 | }, [owner, repo, isProductHost, fetchCanopyStatus]); | |
| 136 | ||
| 137 | useCanopyEvents({ | |
| 138 | scope: "global", | |
| 139 | enabled: !!owner && !!repo && isProductHost === false, | |
| 140 | onEvent: useCallback((event) => { | |
| 141 | if (!owner || !repo) return; | |
| 142 | if (event.type === "log:append") return; | |
| 143 | fetchCanopyStatus(owner, repo); | |
| 144 | }, [owner, repo, fetchCanopyStatus]), | |
| 145 | }); | |
| 32 | const appSwitcherItems = useAppSwitcherItems("grove", { owner, repo }); | |
| 146 | 33 | |
| 147 | 34 | // Determine if the current user owns this repo (directly or via org membership) |
| 148 | 35 | useEffect(() => { |
| @@ -171,27 +58,6 @@ | ||
| 171 | 58 | { key: "code", label: "Code", shortLabel: "Code", href: `/${owner}/${repo}` }, |
| 172 | 59 | { key: "diffs", label: "Diffs", shortLabel: "Diffs", href: `/${owner}/${repo}/diffs` }, |
| 173 | 60 | { key: "commits", label: "Commits", shortLabel: "Commits", href: `/${owner}/${repo}/commits` }, |
| 174 | { | |
| 175 | key: "pipelines", | |
| 176 | label: "Canopy", | |
| 177 | shortLabel: "Canopy", | |
| 178 | href: canopyOrigin ? `${canopyOrigin}/${owner}/${repo}/builds` : `/${owner}/${repo}/builds`, | |
| 179 | external: !!canopyOrigin, | |
| 180 | }, | |
| 181 | { | |
| 182 | key: "ring", | |
| 183 | label: "Ring", | |
| 184 | shortLabel: "Ring", | |
| 185 | href: ringOrigin ? `${ringOrigin}/${owner}/${repo}` : `/ring/${owner}/${repo}`, | |
| 186 | external: !!ringOrigin, | |
| 187 | }, | |
| 188 | { | |
| 189 | key: "collab", | |
| 190 | label: "Collab", | |
| 191 | shortLabel: "Collab", | |
| 192 | href: collabOrigin ? `${collabOrigin}/${owner}/${repo}` : `/collab/${owner}/${repo}`, | |
| 193 | external: !!collabOrigin, | |
| 194 | }, | |
| 195 | 61 | ...(isRepoOwner ? [{ key: "settings", label: "Settings", shortLabel: "Settings", href: `/${owner}/${repo}/settings` }] : []), |
| 196 | 62 | ] : null; |
| 197 | 63 | |
| @@ -200,25 +66,9 @@ | ||
| 200 | 66 | const activeTab = |
| 201 | 67 | repoPath.startsWith("/commit") ? "commits" |
| 202 | 68 | : repoPath.startsWith("/diffs") ? "diffs" |
| 203 | : repoPath.startsWith("/builds") ? "pipelines" | |
| 204 | 69 | : repoPath.startsWith("/settings") ? "settings" |
| 205 | 70 | : "code"; |
| 206 | 71 | |
| 207 | const canopyStatusLabel = | |
| 208 | canopyStatus === "running" | |
| 209 | ? "Running" | |
| 210 | : canopyStatus === "passed" | |
| 211 | ? "Passed" | |
| 212 | : canopyStatus === "failed" | |
| 213 | ? "Failed" | |
| 214 | : canopyStatus === "pending" | |
| 215 | ? "Pending" | |
| 216 | : canopyStatus === "cancelled" | |
| 217 | ? "Cancelled" | |
| 218 | : canopyStatus === null | |
| 219 | ? "No builds" | |
| 220 | : "Loading status"; | |
| 221 | ||
| 222 | 72 | // Parse file/tree path breadcrumbs from URL like /blob/main/src/lib/utils.ts |
| 223 | 73 | let pathBreadcrumbs: { name: string; href: string; isLast: boolean }[] = []; |
| 224 | 74 | const blobMatch = repoPath.match(/^\/(blob|tree)\/([^/]+)\/(.+)$/); |
| @@ -269,7 +119,7 @@ | ||
| 269 | 119 | </> |
| 270 | 120 | )} |
| 271 | 121 | {pathBreadcrumbs.map((crumb) => ( |
| 272 | <span key={crumb.href} className={`flex items-center gap-1.5 sm:gap-3 ${crumb.isLast ? "" : "hidden sm:flex"}`}> | |
| 122 | <span key={crumb.href} className={`hidden sm:flex items-center gap-1.5 sm:gap-3`}> | |
| 273 | 123 | <span style={{ color: "var(--text-faint)" }}>/</span> |
| 274 | 124 | {crumb.isLast ? ( |
| 275 | 125 | <span style={{ color: "var(--text-primary)" }}>{crumb.name}</span> |
| @@ -286,122 +136,28 @@ | ||
| 286 | 136 | ))} |
| 287 | 137 | </> |
| 288 | 138 | } |
| 139 | appSwitcherItems={appSwitcherItems} | |
| 289 | 140 | menuItems={ |
| 290 | 141 | <DropdownItem onClick={() => { window.location.href = "/dashboard"; }}> |
| 291 | 142 | Dashboard |
| 292 | 143 | </DropdownItem> |
| 293 | 144 | } |
| 294 | mobileMenuItems={ | |
| 295 | <a | |
| 296 | href="/dashboard" | |
| 297 | className="px-4 py-2.5 hover-row" | |
| 298 | style={{ color: "var(--text-muted)", textDecoration: "none" }} | |
| 299 | > | |
| 300 | Dashboard | |
| 301 | </a> | |
| 302 | } | |
| 303 | 145 | belowBar={ |
| 304 | 146 | repoTabs ? ( |
| 305 | <div className="hidden sm:flex px-3 sm:px-6 gap-0 text-sm"> | |
| 306 | {repoTabs.map((tab) => { | |
| 307 | const isActive = tab.key === activeTab; | |
| 308 | const tabStyle = { | |
| 309 | color: isActive ? "var(--text-primary)" : "var(--text-muted)", | |
| 310 | borderBottom: isActive | |
| 311 | ? "2px solid var(--accent)" | |
| 312 | : "2px solid transparent", | |
| 313 | }; | |
| 314 | if (tab.external) { | |
| 315 | return ( | |
| 316 | <a | |
| 317 | key={tab.key} | |
| 318 | href={tab.href} | |
| 319 | target="_blank" | |
| 320 | rel="noopener" | |
| 321 | title="Opens in a new tab" | |
| 322 | className="px-3 py-2 -mb-px transition-colors" | |
| 323 | style={tabStyle} | |
| 324 | > | |
| 325 | {tab.key === "pipelines" ? ( | |
| 326 | <span className="inline-flex items-center gap-1.5"> | |
| 327 | <span title={`Canopy: ${canopyStatusLabel}`}> | |
| 328 | <CanopyLogo size={14} /> | |
| 329 | </span> | |
| 330 | {tab.label} | |
| 331 | <ExternalLinkIcon /> | |
| 332 | </span> | |
| 333 | ) : tab.key === "ring" ? ( | |
| 334 | <span className="inline-flex items-center gap-1.5"> | |
| 335 | <RingLogo size={14} /> | |
| 336 | {tab.label} | |
| 337 | <ExternalLinkIcon /> | |
| 338 | </span> | |
| 339 | ) : tab.key === "collab" ? ( | |
| 340 | <span className="inline-flex items-center gap-1.5"> | |
| 341 | <CollabLogo size={14} /> | |
| 342 | {tab.label} | |
| 343 | <ExternalLinkIcon /> | |
| 344 | </span> | |
| 345 | ) : ( | |
| 346 | tab.label | |
| 347 | )} | |
| 348 | </a> | |
| 349 | ); | |
| 350 | } | |
| 351 | return ( | |
| 352 | <Link | |
| 353 | key={tab.key} | |
| 354 | href={tab.href} | |
| 355 | className="px-3 py-2 -mb-px transition-colors" | |
| 356 | style={tabStyle} | |
| 357 | > | |
| 358 | {tab.label} | |
| 359 | </Link> | |
| 360 | ); | |
| 361 | })} | |
| 362 | </div> | |
| 363 | ) : undefined | |
| 364 | } | |
| 365 | mobileMenu={ | |
| 366 | repoTabs ? ( | |
| 367 | <div className="flex flex-col py-2" style={{ borderBottom: "1px solid var(--divide)" }}> | |
| 147 | <div className="flex px-3 sm:px-6 gap-0 text-sm overflow-x-auto overflow-y-hidden"> | |
| 368 | 148 | {repoTabs.map((tab) => { |
| 369 | 149 | const isActive = tab.key === activeTab; |
| 370 | const itemStyle: CSSProperties = { | |
| 371 | color: isActive ? "var(--text-primary)" : "var(--text-muted)", | |
| 372 | fontWeight: isActive ? 600 : 400, | |
| 373 | textDecoration: "none", | |
| 374 | font: "inherit", | |
| 375 | fontSize: "0.875rem", | |
| 376 | }; | |
| 377 | if (tab.external) { | |
| 378 | return ( | |
| 379 | <a | |
| 380 | key={tab.key} | |
| 381 | href={tab.href} | |
| 382 | target="_blank" | |
| 383 | rel="noopener" | |
| 384 | className="flex items-center gap-2 px-4 py-2.5 hover-row" | |
| 385 | style={itemStyle} | |
| 386 | > | |
| 387 | {tab.key === "pipelines" && ( | |
| 388 | <span title={`Canopy: ${canopyStatusLabel}`}> | |
| 389 | <CanopyLogo size={14} /> | |
| 390 | </span> | |
| 391 | )} | |
| 392 | {tab.key === "ring" && <RingLogo size={14} />} | |
| 393 | {tab.key === "collab" && <CollabLogo size={14} />} | |
| 394 | {tab.label} | |
| 395 | <ExternalLinkIcon /> | |
| 396 | </a> | |
| 397 | ); | |
| 398 | } | |
| 399 | 150 | return ( |
| 400 | 151 | <Link |
| 401 | 152 | key={tab.key} |
| 402 | 153 | href={tab.href} |
| 403 | className="flex items-center gap-2 px-4 py-2.5 hover-row" | |
| 404 | style={itemStyle} | |
| 154 | className="px-3 py-2 -mb-px transition-colors shrink-0" | |
| 155 | style={{ | |
| 156 | color: isActive ? "var(--text-primary)" : "var(--text-muted)", | |
| 157 | borderBottom: isActive | |
| 158 | ? "2px solid var(--accent)" | |
| 159 | : "2px solid transparent", | |
| 160 | }} | |
| 405 | 161 | > |
| 406 | 162 | {tab.label} |
| 407 | 163 | </Link> |
| 408 | 164 | |
| @@ -4,21 +4,32 @@ | ||
| 4 | 4 | import { useParams } from "next/navigation"; |
| 5 | 5 | import { RingLogo } from "@/app/components/ring-logo"; |
| 6 | 6 | import { NavBar } from "@/app/components/ui/navbar"; |
| 7 | import { useAppSwitcherItems } from "@/lib/use-app-switcher"; | |
| 7 | 8 | |
| 8 | 9 | export function RingNav() { |
| 9 | 10 | const params = useParams<{ owner?: string; repo?: string }>(); |
| 10 | 11 | const owner = params?.owner; |
| 11 | 12 | const repo = params?.repo; |
| 13 | const appSwitcherItems = useAppSwitcherItems("ring", { owner, repo }); | |
| 12 | 14 | |
| 13 | 15 | return ( |
| 14 | 16 | <NavBar |
| 15 | 17 | logo={<RingLogo size={28} />} |
| 18 | productName="Ring" | |
| 19 | showProductName={!owner} | |
| 20 | appSwitcherItems={appSwitcherItems} | |
| 16 | 21 | breadcrumbs={ |
| 17 | 22 | <> |
| 18 | 23 | {owner && ( |
| 19 | 24 | <> |
| 20 | 25 | <span style={{ color: "var(--text-faint)" }}>/</span> |
| 21 | <span className="truncate" style={{ color: "var(--text-muted)" }}>{owner}</span> | |
| 26 | <Link | |
| 27 | href={`/${owner}`} | |
| 28 | className="hover:underline truncate" | |
| 29 | style={{ color: "var(--text-muted)" }} | |
| 30 | > | |
| 31 | {owner} | |
| 32 | </Link> | |
| 22 | 33 | </> |
| 23 | 34 | )} |
| 24 | 35 | {owner && repo && ( |
| 25 | 36 | |
| @@ -0,0 +1,62 @@ | ||
| 1 | "use client"; | |
| 2 | ||
| 3 | import { useState, useEffect, useMemo } from "react"; | |
| 4 | import { GroveLogo } from "@/app/components/grove-logo"; | |
| 5 | import { CanopyLogo } from "@/app/components/canopy-logo"; | |
| 6 | import { RingLogo } from "@/app/components/ring-logo"; | |
| 7 | import { CollabLogo } from "@/app/components/collab-logo"; | |
| 8 | import type { AppSwitcherItem } from "@/app/components/ui/navbar"; | |
| 9 | ||
| 10 | function useSubdomainOrigin(subdomain: string) { | |
| 11 | const [origin, setOrigin] = useState(""); | |
| 12 | useEffect(() => { | |
| 13 | const host = window.location.host; | |
| 14 | // Strip existing subdomain prefix if present (e.g. canopy.grove.host → grove.host) | |
| 15 | const parts = host.split("."); | |
| 16 | const base = | |
| 17 | parts[0] === "canopy" || parts[0] === "ring" || parts[0] === "collab" | |
| 18 | ? parts.slice(1).join(".") | |
| 19 | : host; | |
| 20 | setOrigin(`${window.location.protocol}//${subdomain}.${base}`); | |
| 21 | }, [subdomain]); | |
| 22 | return origin; | |
| 23 | } | |
| 24 | ||
| 25 | function useBaseOrigin() { | |
| 26 | const [origin, setOrigin] = useState(""); | |
| 27 | useEffect(() => { | |
| 28 | const host = window.location.host; | |
| 29 | const parts = host.split("."); | |
| 30 | const base = | |
| 31 | parts[0] === "canopy" || parts[0] === "ring" || parts[0] === "collab" | |
| 32 | ? parts.slice(1).join(".") | |
| 33 | : host; | |
| 34 | setOrigin(`${window.location.protocol}//${base}`); | |
| 35 | }, []); | |
| 36 | return origin; | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Returns app switcher items for all apps except the current one. | |
| 41 | * Pass the current app name (e.g. "grove", "canopy") to exclude it. | |
| 42 | */ | |
| 43 | export function useAppSwitcherItems( | |
| 44 | currentApp: "grove" | "canopy" | "ring" | "collab", | |
| 45 | context?: { owner?: string; repo?: string }, | |
| 46 | ): AppSwitcherItem[] { | |
| 47 | const groveOrigin = useBaseOrigin(); | |
| 48 | const canopyOrigin = useSubdomainOrigin("canopy"); | |
| 49 | const ringOrigin = useSubdomainOrigin("ring"); | |
| 50 | const collabOrigin = useSubdomainOrigin("collab"); | |
| 51 | ||
| 52 | return useMemo(() => { | |
| 53 | const repoPath = context?.owner && context?.repo ? `/${context.owner}/${context.repo}` : ""; | |
| 54 | const all: (AppSwitcherItem & { key: string })[] = [ | |
| 55 | { key: "grove", name: "Grove", logo: <GroveLogo size={28} />, href: `${groveOrigin}${repoPath}` }, | |
| 56 | { key: "canopy", name: "Canopy", logo: <CanopyLogo size={28} />, href: `${canopyOrigin}${repoPath}${repoPath ? "/builds" : ""}` }, | |
| 57 | { key: "ring", name: "Ring", logo: <RingLogo size={28} />, href: `${ringOrigin}${repoPath}` }, | |
| 58 | { key: "collab", name: "Collab", logo: <CollabLogo size={28} />, href: `${collabOrigin}${repoPath}` }, | |
| 59 | ]; | |
| 60 | return all.filter((item) => item.key !== currentApp); | |
| 61 | }, [currentApp, context?.owner, context?.repo, groveOrigin, canopyOrigin, ringOrigin, collabOrigin]); | |
| 62 | } | |
| 0 | 63 | |