Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
2 files+82−230/2 viewed
| 218 | 218 | background: radial-gradient(circle at 70% 30%, rgb(182 168 255 / 0.12), rgb(255 189 140 / 0.04) 45%, transparent 70%); | |
| 219 | 219 | box-shadow: inset 14px -8px 50px rgb(255 214 186 / 0.07); | |
| 220 | 220 | } | |
| 221 | + | ||
| 222 | + | /* The sidebar trading one far-side menu for another, in place. */ | |
| 223 | + | @keyframes g1t-swap { | |
| 224 | + | from { | |
| 225 | + | opacity: 0; | |
| 226 | + | transform: translateX(10px); | |
| 227 | + | } | |
| 228 | + | to { | |
| 229 | + | opacity: 1; | |
| 230 | + | transform: none; | |
| 231 | + | } | |
| 232 | + | } |
| 254 | 254 | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/; | |
| 255 | 255 | ||
| 256 | 256 | /** | |
| 257 | − | * The sidebar's menus sit side by side on one track, and the track slides: | |
| 258 | − | * the main menu leaves to the left as the settings arrive from the right, | |
| 259 | − | * together, with a long ease-out, the way a phone pushes a screen. | |
| 257 | + | * The sidebar's menus are two layers, the way a phone pushes a screen: the | |
| 258 | + | * one arriving slides in from the right over the full width, while the one | |
| 259 | + | * leaving drifts a quarter of the way left and fades, both on one long | |
| 260 | + | * ease-out. Going back reverses it. | |
| 260 | 261 | */ | |
| 261 | − | const TRACK = "flex h-full w-[200%] transition-transform duration-[380ms] ease-[cubic-bezier(0.45,0,0.15,1)] will-change-transform motion-reduce:transition-none"; | |
| 262 | − | /** One menu on the track. */ | |
| 263 | − | const PANEL = "h-full w-1/2 shrink-0 overflow-y-auto px-2 pb-4"; | |
| 262 | + | const LAYER = | |
| 263 | + | "absolute inset-0 transition-[transform,opacity] duration-[380ms] ease-[cubic-bezier(0.32,0.72,0,1)] will-change-transform motion-reduce:transition-none"; | |
| 264 | + | /** One menu, filling its layer. */ | |
| 265 | + | const PANEL = "h-full overflow-y-auto px-2 pb-4"; | |
| 264 | 266 | ||
| 265 | 267 | /** A workspace's settings, as the sidebar shows them in place of everything else. */ | |
| 266 | 268 | function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) { | |
| 306 | 308 | ); | |
| 307 | 309 | } | |
| 308 | 310 | ||
| 309 | − | type ActiveRepo = NonNullable<ShellData["repo"]>; | |
| 311 | + | /** | |
| 312 | + | * What the repository menu needs. Known from the address before the page's | |
| 313 | + | * data arrives, so the menu is right from the first frame; the counts fill | |
| 314 | + | * in once it does. | |
| 315 | + | */ | |
| 316 | + | type MenuRepo = { | |
| 317 | + | namespace: string; | |
| 318 | + | name: string; | |
| 319 | + | member: boolean; | |
| 320 | + | issues?: number; | |
| 321 | + | pulls?: number; | |
| 322 | + | }; | |
| 323 | + | ||
| 324 | + | function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) { | |
| 325 | + | return ( | |
| 326 | + | a != null && | |
| 327 | + | b != null && | |
| 328 | + | a.namespace.toLowerCase() === b.namespace.toLowerCase() && | |
| 329 | + | a.name.toLowerCase() === b.name.toLowerCase() | |
| 330 | + | ); | |
| 331 | + | } | |
| 310 | 332 | ||
| 311 | 333 | /** | |
| 312 | 334 | * A repository's own menu, which the sidebar slides to while you are in | |
| 313 | 335 | * it, as it does for settings: everything about the repository and nothing | |
| 314 | 336 | * else, with the way back to everything. | |
| 315 | 337 | */ | |
| 316 | − | function RepoMenu({ repo, isPrivate, open }: { repo: ActiveRepo; isPrivate: boolean; open: boolean }) { | |
| 338 | + | function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) { | |
| 317 | 339 | const base = `/${repo.namespace}/${repo.name}`; | |
| 318 | 340 | return ( | |
| 319 | 341 | <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}> | |
| 430 | 452 | const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]); | |
| 431 | 453 | const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-"; | |
| 432 | 454 | const away = inSettings || inAccount || inRepo; | |
| 455 | + | // The repository the menu is for: the one loaded if it is the one being | |
| 456 | + | // gone to, else what the address says, at once. | |
| 457 | + | const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null; | |
| 458 | + | const menuRepo: MenuRepo | null = targetRepo | |
| 459 | + | ? sameRepo(active, targetRepo) | |
| 460 | + | ? active | |
| 461 | + | : { | |
| 462 | + | ...targetRepo, | |
| 463 | + | member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()), | |
| 464 | + | } | |
| 465 | + | : null; | |
| 433 | 466 | // What sits on the far side of the track. Kept while sliding back, so it | |
| 434 | 467 | // does not vanish on the way out. | |
| 435 | 468 | const side = useRef<"workspace" | "account" | "repo">("workspace"); | |
| 437 | 470 | else if (inSettings) side.current = "workspace"; | |
| 438 | 471 | else if (inRepo) side.current = "repo"; | |
| 439 | 472 | // The repository last shown, kept for the slide back. | |
| 440 | − | const shown = useRef(active); | |
| 441 | − | if (active) shown.current = active; | |
| 473 | + | const shown = useRef<MenuRepo | null>(menuRepo); | |
| 474 | + | if (menuRepo) shown.current = menuRepo; | |
| 475 | + | // Moving between two of the far-side menus (settings to a repository, | |
| 476 | + | // one repository to another) crossfades in place instead of sliding. | |
| 477 | + | const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current; | |
| 478 | + | const lastAway = useRef(away); | |
| 479 | + | const lastKey = useRef(detailKey); | |
| 480 | + | const swapped = lastAway.current && away && lastKey.current !== detailKey; | |
| 481 | + | useEffect(() => { | |
| 482 | + | lastAway.current = away; | |
| 483 | + | lastKey.current = detailKey; | |
| 484 | + | }); | |
| 442 | 485 | // The repository being looked at is listed even when it is someone else's. | |
| 443 | 486 | const listed = | |
| 444 | 487 | active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name) | |
| 467 | 510 | <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd> | |
| 468 | 511 | </button> | |
| 469 | 512 | </div> | |
| 470 | − | <div className="min-h-0 grow overflow-hidden"> | |
| 471 | − | <div className={`${TRACK} ${away ? "-translate-x-1/2" : "translate-x-0"}`}> | |
| 513 | + | <div className="relative min-h-0 grow overflow-hidden"> | |
| 514 | + | <div className={`${LAYER} ${away ? "pointer-events-none -translate-x-1/4 opacity-0" : "translate-x-0 opacity-100"}`}> | |
| 472 | 515 | <nav aria-label="g1t" inert={away} className={PANEL}> | |
| 473 | 516 | <div className="mt-3 space-y-px"> | |
| 474 | 517 | <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}> | |
| 516 | 559 | })} | |
| 517 | 560 | </SidebarGroup> | |
| 518 | 561 | </nav> | |
| 519 | − | {side.current === "repo" && shown.current ? ( | |
| 520 | − | <RepoMenu | |
| 521 | − | repo={shown.current} | |
| 522 | − | isPrivate={shell.repos.some((repo) => repo.namespace === shown.current!.namespace && repo.name === shown.current!.name && repo.isPrivate)} | |
| 523 | − | open={inRepo} | |
| 524 | − | /> | |
| 525 | − | ) : side.current === "account" || !ws ? ( | |
| 526 | − | <AccountSettingsMenu open={inAccount} /> | |
| 527 | − | ) : ( | |
| 528 | − | <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} /> | |
| 529 | − | )} | |
| 530 | 562 | </div> | |
| 563 | + | <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}> | |
| 564 | + | <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}> | |
| 565 | + | {side.current === "repo" && shown.current ? ( | |
| 566 | + | <RepoMenu | |
| 567 | + | repo={shown.current} | |
| 568 | + | isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)} | |
| 569 | + | open={inRepo} | |
| 570 | + | /> | |
| 571 | + | ) : side.current === "account" || !ws ? ( | |
| 572 | + | <AccountSettingsMenu open={inAccount} /> | |
| 573 | + | ) : ( | |
| 574 | + | <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} /> | |
| 575 | + | )} | |
| 576 | + | </div> | |
| 577 | + | </div> | |
| 531 | 578 | </div> | |
| 532 | 579 | ||
| 533 | 580 | {/* The workspace's own things sit at the bottom, by its usage and the account. */} |