The sidebar says where you are; the workspace page loses its tabs
The workspace page's tab bar repeated the sidebar. It is gone: the page is the workspace's header and its overview, and Projects, Packages, Teams, People, Insights and Settings are pages of their own, each with a heading, reached from the sidebar, with the top bar's trail leading back. The sidebar now has two parts a rule apart. Above it is what is yours in every workspace: Mission control, your Inbox with its unread count, and what is shared with you. Below it, under the workspace's name: Overview, the projects, Agent fleet to Packages with Insights (soon), then People, Teams, Usage, Support and Settings. lib/workspace-nav.ts decides which one row is lit, on every workspace page and within a team or a package, and its tests check each. A visitor reaches Packages from the workspace's page. Old ?tab= addresses still redirect, now including settings.
| 26 | 26 | ||
| 27 | 27 | A workspace's page, `g1t.sh/<workspace>`, shows your pinned projects and | |
| 28 | 28 | its most active ones, each with where it is deployed, its latest build, and | |
| 29 | − | its open issues and pull requests. Its **Projects** tab lists every one, | |
| 30 | − | with search, filters and sorting; see | |
| 31 | − | [the Projects tab](/guides/workspaces/#the-projects-tab). The sidebar keeps | |
| 29 | + | its open issues and pull requests. **All projects** in the sidebar lists | |
| 30 | + | every one, with search, filters and sorting; see | |
| 31 | + | [the Projects page](/guides/workspaces/#the-projects-page). The sidebar keeps | |
| 32 | 32 | your [pinned and recent projects](/guides/workspaces/#pinned-and-recent-projects). | |
| 33 | 33 | ||
| 34 | 34 | ## A project's pages |
| 17 | 17 | Any member of the workspace with a confirmed email address can create a | |
| 18 | 18 | team, and becomes its first maintainer. | |
| 19 | 19 | ||
| 20 | − | 1. Open **Teams** in the sidebar, or the **Teams** tab of the workspace's | |
| 21 | − | page: `g1t.sh/<workspace>/-/teams`. | |
| 20 | + | 1. Open **Teams** in the sidebar: `g1t.sh/<workspace>/-/teams`. | |
| 22 | 21 | 2. Choose **New team**, or go to `g1t.sh/<workspace>/-/teams/new`. The | |
| 23 | 22 | **+** menu in the top bar has **New team** too, for the workspace the | |
| 24 | 23 | sidebar is on. | |
| ⋯ | |||
| 39 | 38 | | Description | Up to 280 characters. | | |
| 40 | 39 | | Teams in one workspace | Up to 500. | | |
| 41 | 40 | ||
| 42 | − | The **Teams** tab lists the teams you can see, yours first, then by | |
| 41 | + | The **Teams** page lists the teams you can see, yours first, then by | |
| 43 | 42 | name, with a search box that matches names and slugs. A person's teams | |
| 44 | − | also show beside them on the workspace's **People** tab. | |
| 43 | + | also show beside them on the workspace's **People** page. | |
| 45 | 44 | ||
| 46 | 45 | ## Visibility | |
| 47 | 46 | ||
| 270 | 270 | | Owner | Everything a member can, and manage members, the base permission, the workspace's access tokens, its details, and billing: the plan, card checks, prepayment and limits. Admin on every repository, and the only ones who can transfer and delete them; see [access and roles](/guides/access-and-roles/). | | |
| 271 | 271 | ||
| 272 | 272 | Whoever creates a workspace is its owner. An owner adds people on the | |
| 273 | − | workspace's **People**, `g1t.sh/<workspace>/-/people` (a tab of the | |
| 274 | − | workspace's page, and in the sidebar): | |
| 273 | + | workspace's **People**, `g1t.sh/<workspace>/-/people` (in the sidebar): | |
| 275 | 274 | ||
| 276 | 275 | - **By username**: someone already on g1t joins at once, as a member. | |
| 277 | 276 | - **By email address**: g1t emails an invite that only that address can | |
| ⋯ | |||
| 298 | 297 | ## The workspace's page | |
| 299 | 298 | ||
| 300 | 299 | A workspace's own page, `g1t.sh/<workspace>`, has its icon, name, address | |
| 301 | − | and description at the top, and tabs under them: | |
| 300 | + | and description at the top, then its overview: your | |
| 301 | + | [pinned projects](#pinned-and-recent-projects), then the most active ones, | |
| 302 | + | the pull requests in progress across them, and **All projects**. Members | |
| 303 | + | also see a **Usage** card with this month's spend, and who belongs. | |
| 302 | 304 | ||
| 303 | − | | Tab | Address | Who | | | |
| 305 | + | The workspace's other pages each have a heading of their own and a row in | |
| 306 | + | [the sidebar](#the-sidebar), lit while you are on them. The trail in the | |
| 307 | + | top bar, such as *acme / Projects*, leads back to the workspace's page. | |
| 308 | + | ||
| 309 | + | | Page | Address | Who | | | |
| 304 | 310 | | --- | --- | --- | --- | | |
| 305 | − | | **Overview** | `g1t.sh/<workspace>` | Everyone | Your [pinned projects](#pinned-and-recent-projects), then the most active ones, the pull requests in progress across them, and **All projects**. Members also see a **Usage** card with this month's spend, and who belongs. | | |
| 306 | − | | **Projects** | `/-/projects` | Everyone | Every project you can see, with their count. See [the Projects tab](#the-projects-tab). | | |
| 307 | − | | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. | | |
| 311 | + | | **Overview** | `g1t.sh/<workspace>` | Everyone | The page above. | | |
| 312 | + | | **Projects** | `/-/projects` | Everyone | Every project you can see. See [the Projects page](#the-projects-page). | | |
| 313 | + | | [**Packages**](/guides/packages/) | `/-/packages` | Everyone | What the workspace publishes. A visitor opens it from **Packages** on the workspace's page. | | |
| 308 | 314 | | [**Teams**](/guides/teams/) | `/-/teams` | Members | Groups of members given roles on repositories together, mentioned as `@workspace/team` and asked to review together. Each team has its own page at `/-/teams/<team>`. | | |
| 309 | − | | **People** | `/-/people` | Members | Who belongs, with their count. Owners add and remove people here. | | |
| 315 | + | | **People** | `/-/people` | Members | Who belongs. Owners add and remove people here. | | |
| 310 | 316 | | **Insights** | `/-/insights` | Members | Coming soon: how the whole workspace delivers. | | |
| 311 | 317 | | **Settings** | `/-/settings` | Owners | How the workspace is set up and connected (below). | | |
| 312 | 318 | ||
| ⋯ | |||
| 317 | 323 | ||
| 318 | 324 | Older addresses still work: `/-/members` opens People, and | |
| 319 | 325 | `g1t.sh/<workspace>?tab=projects` (or `repositories`, `packages`, | |
| 320 | − | `people`) opens that tab. | |
| 326 | + | `teams`, `people`, `insights` or `settings`) opens that page. | |
| 321 | 327 | ||
| 322 | − | ### The Projects tab | |
| 328 | + | ### The Projects page | |
| 323 | 329 | ||
| 324 | − | The Projects tab is made for workspaces with hundreds of projects: | |
| 330 | + | The Projects page, `g1t.sh/<workspace>/-/projects`, is made for workspaces with hundreds of projects: | |
| 325 | 331 | ||
| 326 | 332 | - **Find a project** matches every word you type in a project's name, its | |
| 327 | 333 | address or its description. Press <kbd>/</kbd> anywhere on the page to | |
| ⋯ | |||
| 353 | 359 | [Explore](https://g1t.sh/explore), public projects from all of g1t, is in | |
| 354 | 360 | the top bar, beside **Docs**. | |
| 355 | 361 | ||
| 356 | − | In order, it lists **Mission control**; the workspace's | |
| 357 | − | [projects](#pinned-and-recent-projects); under **Workspace**, the places work | |
| 358 | − | happens across them (**Agent fleet**, **Context**, **Memory**, **Security** | |
| 359 | − | and [**Packages**](/guides/packages/), with **Boards** and **Roadmap** | |
| 360 | − | soon); then **People**, [**Teams**](/guides/teams/), **Usage**, what g1t's runs have | |
| 361 | − | cost (see [usage and billing](/guides/usage-and-billing/)), **Support** and | |
| 362 | − | **Settings**. An item with an arrow opens a list of its own in the sidebar: | |
| 362 | + | It has two parts, a rule apart. Above the rule is what is yours in every | |
| 363 | + | workspace: **Mission control**, your **Inbox** with how many items are | |
| 364 | + | unread, and the repositories **Shared with you** in workspaces you do not | |
| 365 | + | belong to. Below it, under the workspace's name, is the workspace: | |
| 366 | + | ||
| 367 | + | 1. **Overview**, the [workspace's page](#the-workspaces-page). | |
| 368 | + | 2. Its [projects](#pinned-and-recent-projects), ending with **All projects**. | |
| 369 | + | 3. The places work happens across them: **Agent fleet**, **Context**, | |
| 370 | + | **Memory**, **Security** and [**Packages**](/guides/packages/), with | |
| 371 | + | **Insights**, **Boards** and **Roadmap** soon. | |
| 372 | + | 4. **People**, [**Teams**](/guides/teams/), **Usage**, what g1t's runs have | |
| 373 | + | cost (see [usage and billing](/guides/usage-and-billing/)), **Support** | |
| 374 | + | and **Settings**. | |
| 375 | + | ||
| 376 | + | One row is lit wherever you are: **Teams** on a team's pages, **Packages** | |
| 377 | + | on a package's, and **Settings** on every page it opens. An item with an arrow opens a list of its own in the sidebar: | |
| 363 | 378 | **Settings** slides over to how the workspace is set up and connected, and | |
| 364 | 379 | the row at the top, **‹ Settings**, slides back: | |
| 365 | 380 | ||
| ⋯ | |||
| 396 | 411 | ||
| 397 | 412 | - **Pinned**: the projects you pinned, in your order, up to eight a | |
| 398 | 413 | workspace. Pin one with **Pin** on its page, or the pin on its row of the | |
| 399 | − | Projects tab or its card on the Overview. Drag a pinned project to move | |
| 414 | + | Projects page or its card on the Overview. Drag a pinned project to move | |
| 400 | 415 | it, or hold <kbd>Alt</kbd> and press the up or down arrow. | |
| 401 | 416 | - **Recent**: the projects you opened last that you have not pinned, up to | |
| 402 | 417 | five. | |
| 403 | − | - **All projects**, with how many there are, opens the Projects tab. | |
| 418 | + | - **All projects**, with how many there are, opens the Projects page. | |
| 404 | 419 | ||
| 405 | 420 | Pins and recent projects are yours: nobody else sees them, and each | |
| 406 | 421 | workspace has its own. ⌘K finds any project in the workspace, pinned or not. | |
| 1 | − | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, TrendingUp, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 22 | 22 | DropdownMenuSeparator, | |
| 23 | 23 | DropdownMenuTrigger, | |
| 24 | 24 | } from "./ui/dropdown-menu"; | |
| 25 | − | import { type RoadmapItem, roadmapIn } from "../lib/roadmap"; | |
| 25 | + | import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap"; | |
| 26 | + | import { SETTINGS_PAGES, sidebarCurrent } from "../lib/workspace-nav"; | |
| 26 | 27 | import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access"; | |
| 27 | 28 | import { VISITOR_LINKS, projectPages } from "../lib/chrome"; | |
| 28 | 29 | import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings"; | |
| ⋯ | |||
| 82 | 83 | count, | |
| 83 | 84 | also, | |
| 84 | 85 | drill, | |
| 86 | + | current: lit, | |
| 85 | 87 | children, | |
| 86 | 88 | }: { | |
| 87 | 89 | to: string; | |
| ⋯ | |||
| 92 | 94 | also?: string | string[]; | |
| 93 | 95 | /** It opens a list of its own: a chevron says so, always or on hover. */ | |
| 94 | 96 | drill?: boolean | "hover"; | |
| 97 | + | /** Whether it is the current row, when the list works that out itself. */ | |
| 98 | + | current?: boolean; | |
| 95 | 99 | children: ReactNode; | |
| 96 | 100 | }) { | |
| 97 | 101 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 102 | 106 | prefetch="intent" | |
| 103 | 107 | className={({ isActive, isPending }) => { | |
| 104 | 108 | const current = | |
| 105 | − | isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | |
| 109 | + | lit ?? | |
| 110 | + | (isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"))); | |
| 106 | 111 | return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ | |
| 107 | 112 | current | |
| 108 | 113 | ? "bg-raised font-medium text-fg" | |
| ⋯ | |||
| 157 | 162 | also, | |
| 158 | 163 | icon, | |
| 159 | 164 | about, | |
| 165 | + | current: lit, | |
| 160 | 166 | children, | |
| 161 | 167 | }: { | |
| 162 | 168 | to: string; | |
| 163 | 169 | also?: string[]; | |
| 164 | 170 | icon: ReactNode; | |
| 165 | 171 | about: string; | |
| 172 | + | /** Whether it is the current row, when the list works that out itself. */ | |
| 173 | + | current?: boolean; | |
| 166 | 174 | children: ReactNode; | |
| 167 | 175 | }) { | |
| 168 | 176 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 172 | 180 | title={about} | |
| 173 | 181 | prefetch="intent" | |
| 174 | 182 | className={({ isActive }) => { | |
| 175 | − | const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/")); | |
| 183 | + | const current = lit ?? (isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"))); | |
| 176 | 184 | return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ | |
| 177 | 185 | current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted" | |
| 178 | 186 | }`; | |
| ⋯ | |||
| 471 | 479 | * still open these. | |
| 472 | 480 | */ | |
| 473 | 481 | const SETTINGS_PAGE = | |
| 474 | − | /^\/([^/]+)\/-\/(settings|repositories|tokens|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/; | |
| 482 | + | new RegExp(`^/([^/]+)/-/(${SETTINGS_PAGES.join("|")})(/|$)`); | |
| 475 | 483 | /** A project's settings pages, which the project's menu drills into. */ | |
| 476 | 484 | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; | |
| 477 | 485 | ||
| ⋯ | |||
| 583 | 591 | * way to all of them. Pins move by dragging, or with Alt and the arrow | |
| 584 | 592 | * keys; the projects service keeps the order. | |
| 585 | 593 | */ | |
| 586 | − | function SidebarProjects({ slug, shell }: { slug: string; shell: ShellData }) { | |
| 594 | + | function SidebarProjects({ slug, shell, current }: { slug: string; shell: ShellData; current: boolean }) { | |
| 587 | 595 | const reorder = useFetcher({ key: `pins:${slug}` }); | |
| 588 | 596 | const saved = shell.pinned ?? []; | |
| 589 | 597 | // While a new order is on its way, it shows as made. | |
| ⋯ | |||
| 680 | 688 | </> | |
| 681 | 689 | )} | |
| 682 | 690 | <div className="pt-1"> | |
| 683 | − | <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length}> | |
| 691 | + | <SidebarLink to={`/${slug}/-/projects`} icon={<LayoutGrid size={15} />} count={shell.repos.length} current={current}> | |
| 684 | 692 | All projects | |
| 685 | 693 | </SidebarLink> | |
| 686 | 694 | </div> | |
| ⋯ | |||
| 689 | 697 | } | |
| 690 | 698 | ||
| 691 | 699 | /** | |
| 692 | − | * The main list: where you go, the projects, what the workspace builds and | |
| 693 | − | * runs with across them, then its usage, support and settings. | |
| 700 | + | * The main list, in two parts a rule apart. Above it, what is yours | |
| 701 | + | * whichever workspace you are in: Mission control, your inbox, and what | |
| 702 | + | * others have shared with you. Below it, under the workspace's name, the | |
| 703 | + | * workspace: its overview, its projects, what it builds and runs with | |
| 704 | + | * across them, then its people, usage, support and settings. One row is | |
| 705 | + | * lit wherever you are (lib/workspace-nav.ts). | |
| 694 | 706 | */ | |
| 695 | 707 | function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) { | |
| 696 | 708 | const ws = shell.workspace; | |
| 709 | + | const { pathname } = useLocation(); | |
| 710 | + | const going = useNavigation().location?.pathname; | |
| 711 | + | // The row lights as the link is followed, not once the page arrives. | |
| 712 | + | const here = sidebarCurrent(going ?? pathname, ws?.slug ?? null); | |
| 697 | 713 | // A visitor browses: no workspace, no projects of their own. | |
| 698 | 714 | if (!user) { | |
| 699 | 715 | return ( | |
| ⋯ | |||
| 708 | 724 | </nav> | |
| 709 | 725 | ); | |
| 710 | 726 | } | |
| 727 | + | const shared = shell.shared ?? []; | |
| 711 | 728 | return ( | |
| 712 | 729 | <nav aria-label="g1t" className={PANEL}> | |
| 713 | − | {/* Everything here is the workspace the switcher names: its home first. | |
| 714 | − | The workspace's own page is the switcher's; Explore, all of g1t, | |
| 715 | − | is in the top bar. */} | |
| 730 | + | {/* Yours, in every workspace. Explore, all of g1t, is in the top bar. */} | |
| 716 | 731 | <div className="mt-3 space-y-px"> | |
| 717 | − | <SidebarLink to="/" end icon={<House size={15} />}> | |
| 732 | + | <SidebarLink to="/" end icon={<House size={15} />} current={here === "mission"}> | |
| 718 | 733 | Mission control | |
| 719 | 734 | </SidebarLink> | |
| 735 | + | <SidebarLink to="/inbox" icon={<Inbox size={15} />} count={shell.inbox?.unread ?? undefined} current={here === "inbox"}> | |
| 736 | + | Inbox | |
| 737 | + | </SidebarLink> | |
| 720 | 738 | </div> | |
| 721 | − | ||
| 722 | − | <Rule /> | |
| 723 | − | {ws ? ( | |
| 724 | − | <SidebarProjects slug={ws.slug} shell={shell} /> | |
| 725 | − | ) : ( | |
| 726 | − | <SidebarGroup title="Projects"> | |
| 727 | − | <p className="px-2 py-1 text-xs text-faint">None yet.</p> | |
| 728 | − | </SidebarGroup> | |
| 729 | − | )} | |
| 730 | − | {(shell.shared ?? []).length > 0 && ( | |
| 739 | + | {shared.length > 0 && ( | |
| 731 | 740 | <SidebarGroup title="Shared with you" className="mt-3"> | |
| 732 | − | {(shell.shared ?? []).map((repo) => ( | |
| 741 | + | {shared.map((repo) => ( | |
| 733 | 742 | <SidebarLink | |
| 734 | 743 | key={`${repo.namespace}/${repo.name}`} | |
| 735 | 744 | to={`/${repo.namespace}/${repo.name}`} | |
| ⋯ | |||
| 743 | 752 | </SidebarGroup> | |
| 744 | 753 | )} | |
| 745 | 754 | ||
| 746 | − | {ws && ( | |
| 755 | + | <Rule /> | |
| 756 | + | {ws ? ( | |
| 747 | 757 | <> | |
| 758 | + | {/* The workspace the switcher names: its own page first. */} | |
| 759 | + | <SidebarGroup title={displayName(ws)}> | |
| 760 | + | <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />} current={here === "overview"}> | |
| 761 | + | Overview | |
| 762 | + | </SidebarLink> | |
| 763 | + | </SidebarGroup> | |
| 764 | + | <div className="mt-3"> | |
| 765 | + | <SidebarProjects slug={ws.slug} shell={shell} current={here === "projects"} /> | |
| 766 | + | </div> | |
| 767 | + | ||
| 748 | 768 | <Rule /> | |
| 749 | − | <SidebarGroup title="Workspace"> | |
| 750 | − | <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}> | |
| 769 | + | <div className="space-y-px"> | |
| 770 | + | <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />} current={here === "agents"}> | |
| 751 | 771 | Agent fleet | |
| 752 | 772 | </SidebarLink> | |
| 753 | − | <SidebarLink to={`/${ws.slug}/-/context`} icon={<Network size={15} />}> | |
| 773 | + | <SidebarLink to={`/${ws.slug}/-/context`} icon={<Network size={15} />} current={here === "context"}> | |
| 754 | 774 | Context | |
| 755 | 775 | </SidebarLink> | |
| 756 | − | <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />}> | |
| 776 | + | <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />} current={here === "memory"}> | |
| 757 | 777 | Memory | |
| 758 | 778 | </SidebarLink> | |
| 759 | − | <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />}> | |
| 779 | + | <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />} current={here === "security"}> | |
| 760 | 780 | Security | |
| 761 | 781 | </SidebarLink> | |
| 762 | − | <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}> | |
| 782 | + | <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />} current={here === "packages"}> | |
| 763 | 783 | Packages | |
| 764 | 784 | </SidebarLink> | |
| 785 | + | {INSIGHTS && ( | |
| 786 | + | <SidebarSoonLink | |
| 787 | + | to={`/${ws.slug}/-/insights`} | |
| 788 | + | icon={<TrendingUp size={15} />} | |
| 789 | + | about={INSIGHTS.summary} | |
| 790 | + | current={here === "insights"} | |
| 791 | + | > | |
| 792 | + | {INSIGHTS.title} | |
| 793 | + | </SidebarSoonLink> | |
| 794 | + | )} | |
| 765 | 795 | {roadmapIn("Workspace").filter((item) => item.key !== "insights").map((item) => ( | |
| 766 | 796 | <SidebarSoonLink | |
| 767 | 797 | key={item.key} | |
| ⋯ | |||
| 772 | 802 | {item.title === "Board" ? "Boards" : item.title} | |
| 773 | 803 | </SidebarSoonLink> | |
| 774 | 804 | ))} | |
| 775 | − | </SidebarGroup> | |
| 805 | + | </div> | |
| 776 | 806 | ||
| 777 | 807 | <Rule /> | |
| 778 | 808 | <div className="space-y-px"> | |
| 779 | 809 | {/* Who belongs, for every member to see; owners invite and manage there. */} | |
| 780 | − | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 810 | + | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />} current={here === "people"}> | |
| 781 | 811 | People | |
| 782 | 812 | </SidebarLink> | |
| 783 | − | <SidebarLink to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS.teams}> | |
| 813 | + | <SidebarLink to={`/${ws.slug}/-/teams`} icon={WORKSPACE_ICONS.teams} current={here === "teams"}> | |
| 784 | 814 | Teams | |
| 785 | 815 | </SidebarLink> | |
| 786 | − | <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}> | |
| 816 | + | <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />} current={here === "usage"}> | |
| 787 | 817 | Usage | |
| 788 | 818 | </SidebarLink> | |
| 789 | − | <SidebarLink to="/support" icon={<LifeBuoy size={15} />}> | |
| 819 | + | <SidebarLink to="/support" icon={<LifeBuoy size={15} />} current={here === "support"}> | |
| 790 | 820 | Support | |
| 791 | 821 | </SidebarLink> | |
| 792 | 822 | {/* How it is set up and connected, what it pays and its record: a list of their own. */} | |
| ⋯ | |||
| 794 | 824 | to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`} | |
| 795 | 825 | icon={<Settings size={15} />} | |
| 796 | 826 | drill | |
| 827 | + | current={here === "settings"} | |
| 797 | 828 | > | |
| 798 | 829 | Settings | |
| 799 | 830 | </SidebarLink> | |
| 800 | 831 | </div> | |
| 801 | 832 | </> | |
| 833 | + | ) : ( | |
| 834 | + | <SidebarGroup title="Projects"> | |
| 835 | + | <p className="px-2 py-1 text-xs text-faint">None yet.</p> | |
| 836 | + | </SidebarGroup> | |
| 802 | 837 | )} | |
| 803 | 838 | </nav> | |
| 804 | 839 | ); | |
| 805 | 840 | } | |
| 806 | 841 | ||
| 842 | + | /** Insights, coming: the workspace's own page says what it will be. */ | |
| 843 | + | const INSIGHTS = roadmapItem("insights"); | |
| 844 | + | ||
| 807 | 845 | /** A workspace's settings, drilled into from Settings in the main list. */ | |
| 808 | 846 | function SettingsMenu({ slug, owner }: { slug: string; owner: boolean }) { | |
| 809 | 847 | return ( | |
| 1 | 1 | /** | |
| 2 | − | * The shapes of a workspace's tabs while they load: the same layout as | |
| 2 | + | * The shapes of a workspace's pages while they load: the same layout as | |
| 3 | 3 | * the real thing, so nothing moves when it arrives (components/ui/skeleton.tsx). | |
| 4 | 4 | */ | |
| 5 | − | import type { WorkspaceTabKey } from "../lib/workspace-nav"; | |
| 5 | + | import type { WorkspacePageKey } from "../lib/workspace-nav"; | |
| 6 | 6 | import { Skeleton, SkeletonRows, SkeletonText } from "./ui/skeleton"; | |
| 7 | 7 | ||
| 8 | 8 | /** A project card, as the Overview and the Projects grid show them. */ | |
| ⋯ | |||
| 85 | 85 | ); | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | − | /** Whichever tab is on its way. */ | |
| 89 | − | export function WorkspaceTabSkeleton({ tab }: { tab: WorkspaceTabKey }) { | |
| 90 | − | if (tab === "overview") return <OverviewSkeleton />; | |
| 91 | − | if (tab === "projects") return <ProjectsSkeleton />; | |
| 88 | + | /** Whichever page is on its way. */ | |
| 89 | + | export function WorkspacePageSkeleton({ page }: { page: WorkspacePageKey }) { | |
| 90 | + | if (page === "overview") return <OverviewSkeleton />; | |
| 91 | + | if (page === "projects") return <ProjectsSkeleton />; | |
| 92 | 92 | return ( | |
| 93 | 93 | <div aria-busy="true" aria-label="Loading" className="space-y-4"> | |
| 94 | 94 | <Skeleton className="h-3.5 w-2/3 max-w-xl" /> | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { pagePath, workspaceRedirect, workspaceTab, workspaceTabs } from "./workspace-nav.ts"; | |
| 4 | + | import { SETTINGS_PAGES, WORKSPACE_PAGES, pagePath, sidebarCurrent, workspacePage, workspaceRedirect } from "./workspace-nav.ts"; | |
| 5 | 5 | ||
| 6 | − | test("everyone sees Overview, Projects and Packages; members and owners see more", () => { | |
| 7 | − | const keys = (member: boolean, owner: boolean) => workspaceTabs("acme", { member, owner }).map((tab) => tab.key); | |
| 8 | − | assert.deepEqual(keys(false, false), ["overview", "projects", "packages"]); | |
| 9 | − | assert.deepEqual(keys(true, false), ["overview", "projects", "packages", "teams", "people", "insights"]); | |
| 10 | − | assert.deepEqual(keys(true, true), ["overview", "projects", "packages", "teams", "people", "insights", "settings"]); | |
| 6 | + | test("a path is one of the workspace's pages, or none", () => { | |
| 7 | + | assert.equal(workspacePage("/acme", "acme"), "overview"); | |
| 8 | + | assert.equal(workspacePage("/Acme/", "acme"), "overview"); | |
| 9 | + | assert.equal(workspacePage("/acme.data", "acme"), "overview"); | |
| 10 | + | assert.equal(workspacePage("/acme/-/projects", "acme"), "projects"); | |
| 11 | + | assert.equal(workspacePage("/acme/-/people", "acme"), "people"); | |
| 12 | + | assert.equal(workspacePage("/acme/-/packages", "acme"), "packages"); | |
| 13 | + | assert.equal(workspacePage("/acme/-/teams", "acme"), "teams"); | |
| 14 | + | assert.equal(workspacePage("/acme/-/insights", "acme"), "insights"); | |
| 15 | + | // One package or team has a page of its own; settings and the rest have headings. | |
| 16 | + | assert.equal(workspacePage("/acme/-/packages/npm/web", "acme"), null); | |
| 17 | + | assert.equal(workspacePage("/acme/-/teams/web", "acme"), null); | |
| 18 | + | assert.equal(workspacePage("/acme/-/settings", "acme"), null); | |
| 19 | + | assert.equal(workspacePage("/acme/-/agents", "acme"), null); | |
| 20 | + | assert.equal(workspacePage("/acme/web", "acme"), null); | |
| 21 | + | assert.equal(workspacePage("/other", "acme"), null); | |
| 11 | 22 | }); | |
| 12 | 23 | ||
| 13 | − | test("tabs carry counts, and say which are coming", () => { | |
| 14 | − | const tabs = workspaceTabs("acme", { member: true, owner: false, projects: 412, people: 7 }); | |
| 15 | − | assert.equal(tabs.find((tab) => tab.key === "projects")?.count, 412); | |
| 16 | − | assert.equal(tabs.find((tab) => tab.key === "people")?.count, 7); | |
| 17 | − | assert.equal(tabs.find((tab) => tab.key === "teams")?.soon, undefined); | |
| 18 | − | assert.equal(tabs.find((tab) => tab.key === "insights")?.soon, true); | |
| 19 | − | assert.equal(tabs.find((tab) => tab.key === "overview")?.to, "/acme"); | |
| 20 | − | assert.equal(tabs.find((tab) => tab.key === "projects")?.to, "/acme/-/projects"); | |
| 24 | + | test("every page that once had a tab has a sidebar row", () => { | |
| 25 | + | for (const page of ["overview", ...WORKSPACE_PAGES] as const) { | |
| 26 | + | const path = page === "overview" ? "/acme" : `/acme/-/${page}`; | |
| 27 | + | assert.equal(sidebarCurrent(path, "acme"), page, path); | |
| 28 | + | } | |
| 21 | 29 | }); | |
| 22 | 30 | ||
| 23 | − | test("a path is a tab, or one of the workspace's other pages", () => { | |
| 24 | − | assert.equal(workspaceTab("/acme", "acme"), "overview"); | |
| 25 | − | assert.equal(workspaceTab("/Acme/", "acme"), "overview"); | |
| 26 | − | assert.equal(workspaceTab("/acme/-/projects", "acme"), "projects"); | |
| 27 | − | assert.equal(workspaceTab("/acme/-/people", "acme"), "people"); | |
| 28 | − | assert.equal(workspaceTab("/acme/-/packages", "acme"), "packages"); | |
| 29 | − | // One package has a page of its own; settings and the rest have headings. | |
| 30 | − | assert.equal(workspaceTab("/acme/-/packages/npm/web", "acme"), null); | |
| 31 | − | assert.equal(workspaceTab("/acme/-/settings", "acme"), null); | |
| 32 | − | assert.equal(workspaceTab("/acme/-/agents", "acme"), null); | |
| 33 | − | assert.equal(workspaceTab("/acme/web", "acme"), null); | |
| 34 | − | assert.equal(workspaceTab("/other", "acme"), null); | |
| 31 | + | test("one sidebar row is current wherever you are in the workspace", () => { | |
| 32 | + | assert.equal(sidebarCurrent("/", "acme"), "mission"); | |
| 33 | + | assert.equal(sidebarCurrent("/inbox", "acme"), "inbox"); | |
| 34 | + | assert.equal(sidebarCurrent("/support", "acme"), "support"); | |
| 35 | + | assert.equal(sidebarCurrent("/acme/-/projects.data", "acme"), "projects"); | |
| 36 | + | // Within a team or a package, its row stays lit. | |
| 37 | + | assert.equal(sidebarCurrent("/acme/-/teams/web/settings", "acme"), "teams"); | |
| 38 | + | assert.equal(sidebarCurrent("/acme/-/packages/npm/web", "acme"), "packages"); | |
| 39 | + | assert.equal(sidebarCurrent("/acme/-/agents", "acme"), "agents"); | |
| 40 | + | assert.equal(sidebarCurrent("/acme/-/usage", "acme"), "usage"); | |
| 41 | + | // Every page the Settings row drills into. | |
| 42 | + | for (const page of SETTINGS_PAGES) assert.equal(sidebarCurrent(`/acme/-/${page}`, "acme"), "settings", page); | |
| 43 | + | // Another workspace's pages, a project and a person light nothing here. | |
| 44 | + | assert.equal(sidebarCurrent("/other/-/projects", "acme"), null); | |
| 45 | + | assert.equal(sidebarCurrent("/acme/web", "acme"), null); | |
| 46 | + | assert.equal(sidebarCurrent("/u/ada", "acme"), null); | |
| 47 | + | assert.equal(sidebarCurrent("/acme", null), null); | |
| 35 | 48 | }); | |
| 36 | 49 | ||
| 37 | 50 | test("old addresses go to where their pages are now", () => { | |
| ⋯ | |||
| 44 | 57 | assert.equal(workspaceRedirect("/acme/web"), null); | |
| 45 | 58 | }); | |
| 46 | 59 | ||
| 47 | − | test("?tab= opens that tab, keeping the rest of the query", () => { | |
| 60 | + | test("?tab= opens that page, keeping the rest of the query", () => { | |
| 48 | 61 | assert.equal(workspaceRedirect("/acme", "?tab=repositories"), "/acme/-/projects"); | |
| 49 | 62 | assert.equal(workspaceRedirect("/acme", "?tab=projects&q=api"), "/acme/-/projects?q=api"); | |
| 50 | 63 | assert.equal(workspaceRedirect("/acme", "?tab=members"), "/acme/-/people"); | |
| 51 | 64 | assert.equal(workspaceRedirect("/acme", "?tab=overview"), "/acme"); | |
| 65 | + | assert.equal(workspaceRedirect("/acme", "?tab=settings"), "/acme/-/settings"); | |
| 52 | 66 | assert.equal(workspaceRedirect("/acme", "?tab=nonsense"), null); | |
| 53 | 67 | assert.equal(workspaceRedirect("/acme", ""), null); | |
| 54 | 68 | }); | |
| 1 | 1 | /** | |
| 2 | − | * A workspace's own page at `g1t.sh/<workspace>`: its header, and tabs for | |
| 3 | − | * what it has (Overview, Projects, Packages, Teams, People, Insights and, | |
| 4 | − | * for owners, Settings). The sidebar is always the current workspace's; | |
| 5 | − | * this page is where the workspace itself is shown, to members and to | |
| 6 | − | * everyone else. | |
| 2 | + | * A workspace's pages and where the sidebar finds them. `g1t.sh/<workspace>` | |
| 3 | + | * is the workspace's own page, its header and overview; Projects, Packages, | |
| 4 | + | * Teams, People, Insights and Settings are pages of their own under `-/`, | |
| 5 | + | * each reached from the sidebar and each with its own heading. The sidebar | |
| 6 | + | * is always the current workspace's. | |
| 7 | 7 | */ | |
| 8 | 8 | ||
| 9 | − | export type WorkspaceTabKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights" | "settings"; | |
| 9 | + | /** The workspace's pages a person moves between, and which the sidebar lists. */ | |
| 10 | + | export type WorkspacePageKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights"; | |
| 10 | 11 | ||
| 11 | − | export type WorkspaceTab = { | |
| 12 | − | key: WorkspaceTabKey; | |
| 13 | − | label: string; | |
| 14 | − | to: string; | |
| 15 | − | /** Shown beside the label; left out when unknown. */ | |
| 16 | − | count?: number | null; | |
| 17 | − | /** Not built yet: its tab opens what it will be. */ | |
| 18 | − | soon?: boolean; | |
| 19 | − | }; | |
| 12 | + | /** Those pages under `-/`: the overview is the workspace's own address. */ | |
| 13 | + | export const WORKSPACE_PAGES = ["projects", "packages", "teams", "people", "insights"] as const; | |
| 20 | 14 | ||
| 21 | − | /** The workspace's pages under `-/` that are drawn under its tabs. */ | |
| 22 | − | export const TAB_PAGES = ["projects", "packages", "teams", "people", "insights"] as const; | |
| 23 | − | ||
| 24 | − | /** Tabs whose pages say what they will be, until they are built. */ | |
| 25 | − | export const SOON_TABS = new Set<WorkspaceTabKey>(["insights"]); | |
| 15 | + | /** | |
| 16 | + | * Which of those pages a path is, under `/<workspace>`: null for the | |
| 17 | + | * workspace's other pages (settings, Agent fleet, Usage and the rest), for | |
| 18 | + | * one package's or one team's page, and for anything else. | |
| 19 | + | */ | |
| 20 | + | export function workspacePage(pathname: string, slug: string): WorkspacePageKey | null { | |
| 21 | + | const parts = pagePath(pathname).split("/").filter(Boolean); | |
| 22 | + | if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null; | |
| 23 | + | if (parts.length === 1) return "overview"; | |
| 24 | + | if (parts[1] !== "-" || parts.length !== 3) return null; | |
| 25 | + | return (WORKSPACE_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspacePageKey) : null; | |
| 26 | + | } | |
| 26 | 27 | ||
| 27 | 28 | /** | |
| 28 | − | * The tabs, in order. People, Teams and Insights are for members, and | |
| 29 | − | * Settings for owners; everyone sees the rest. | |
| 29 | + | * The sidebar's rows. The first two are the person's own, whichever | |
| 30 | + | * workspace they are in; the rest are the workspace's. | |
| 30 | 31 | */ | |
| 31 | − | export function workspaceTabs( | |
| 32 | − | slug: string, | |
| 33 | − | options: { member: boolean; owner: boolean; projects?: number | null; people?: number | null }, | |
| 34 | − | ): WorkspaceTab[] { | |
| 35 | − | const base = `/${slug}`; | |
| 36 | − | const tabs: (WorkspaceTab | false)[] = [ | |
| 37 | − | { key: "overview", label: "Overview", to: base }, | |
| 38 | − | { key: "projects", label: "Projects", to: `${base}/-/projects`, count: options.projects ?? null }, | |
| 39 | − | { key: "packages", label: "Packages", to: `${base}/-/packages` }, | |
| 40 | − | options.member && { key: "teams", label: "Teams", to: `${base}/-/teams` }, | |
| 41 | − | options.member && { key: "people", label: "People", to: `${base}/-/people`, count: options.people ?? null }, | |
| 42 | − | options.member && { key: "insights", label: "Insights", to: `${base}/-/insights`, soon: true }, | |
| 43 | − | options.owner && { key: "settings", label: "Settings", to: `${base}/-/settings` }, | |
| 44 | − | ]; | |
| 45 | − | return tabs.filter((tab): tab is WorkspaceTab => tab !== false); | |
| 46 | − | } | |
| 32 | + | export type SidebarKey = | |
| 33 | + | | "mission" | |
| 34 | + | | "inbox" | |
| 35 | + | | "overview" | |
| 36 | + | | "projects" | |
| 37 | + | | "agents" | |
| 38 | + | | "context" | |
| 39 | + | | "memory" | |
| 40 | + | | "security" | |
| 41 | + | | "packages" | |
| 42 | + | | "insights" | |
| 43 | + | | "people" | |
| 44 | + | | "teams" | |
| 45 | + | | "usage" | |
| 46 | + | | "support" | |
| 47 | + | | "settings"; | |
| 48 | + | ||
| 49 | + | /** The workspace's pages that its Settings row drills into. */ | |
| 50 | + | export const SETTINGS_PAGES = [ | |
| 51 | + | "settings", | |
| 52 | + | "repositories", | |
| 53 | + | "tokens", | |
| 54 | + | "guardrails", | |
| 55 | + | "secrets", | |
| 56 | + | "runners", | |
| 57 | + | "integrations", | |
| 58 | + | "webhooks", | |
| 59 | + | "billing", | |
| 60 | + | "audit", | |
| 61 | + | ] as const; | |
| 62 | + | ||
| 63 | + | /** Pages under `-/` whose sidebar row has another name. */ | |
| 64 | + | const ROW_OF: Record<string, SidebarKey> = { | |
| 65 | + | projects: "projects", | |
| 66 | + | agents: "agents", | |
| 67 | + | context: "context", | |
| 68 | + | memory: "memory", | |
| 69 | + | security: "security", | |
| 70 | + | packages: "packages", | |
| 71 | + | insights: "insights", | |
| 72 | + | people: "people", | |
| 73 | + | teams: "teams", | |
| 74 | + | usage: "usage", | |
| 75 | + | }; | |
| 47 | 76 | ||
| 48 | 77 | /** | |
| 49 | − | * Which tab a path is, under `/<workspace>`: null for the workspace's other | |
| 50 | − | * pages (its settings, Agent fleet, Usage and the rest), which have a page | |
| 51 | − | * heading of their own, and for one package's page. | |
| 78 | + | * The sidebar row that is current on a path: one row at most, so the | |
| 79 | + | * sidebar always says where you are. `slug` is the workspace the sidebar | |
| 80 | + | * is about; another workspace's pages light nothing. | |
| 52 | 81 | */ | |
| 53 | − | export function workspaceTab(pathname: string, slug: string): WorkspaceTabKey | null { | |
| 54 | − | const parts = pathname.split("/").filter(Boolean); | |
| 55 | − | if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null; | |
| 82 | + | export function sidebarCurrent(pathname: string, slug: string | null): SidebarKey | null { | |
| 83 | + | const path = pagePath(pathname); | |
| 84 | + | if (path === "/") return "mission"; | |
| 85 | + | if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox"; | |
| 86 | + | if (path === "/support" || path.startsWith("/support/")) return "support"; | |
| 87 | + | const parts = path.split("/").filter(Boolean); | |
| 88 | + | if (!slug || parts[0]?.toLowerCase() !== slug.toLowerCase()) return null; | |
| 56 | 89 | if (parts.length === 1) return "overview"; | |
| 57 | − | if (parts[1] !== "-" || parts.length !== 3) return null; | |
| 58 | − | return (TAB_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspaceTabKey) : null; | |
| 90 | + | if (parts[1] !== "-") return null; | |
| 91 | + | const page = parts[2] ?? ""; | |
| 92 | + | if ((SETTINGS_PAGES as readonly string[]).includes(page)) return "settings"; | |
| 93 | + | return ROW_OF[page] ?? null; | |
| 59 | 94 | } | |
| 60 | 95 | ||
| 61 | − | /** `?tab=` as people write it, from habit: the tab it means. */ | |
| 96 | + | /** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */ | |
| 62 | 97 | const TAB_WORDS: Record<string, string> = { | |
| 63 | 98 | overview: "", | |
| 64 | 99 | projects: "-/projects", | |
| ⋯ | |||
| 69 | 104 | members: "-/people", | |
| 70 | 105 | teams: "-/teams", | |
| 71 | 106 | insights: "-/insights", | |
| 107 | + | settings: "-/settings", | |
| 72 | 108 | }; | |
| 73 | 109 | ||
| 74 | 110 | /** Workspace pages that moved, by their old name under `-/`. */ | |
| ⋯ | |||
| 87 | 123 | /** | |
| 88 | 124 | * Where an old address of a workspace's pages is now, keeping its query; | |
| 89 | 125 | * null when it has not moved. `/<workspace>?tab=projects` and the like | |
| 90 | − | * open that tab. | |
| 126 | + | * open that page. | |
| 91 | 127 | */ | |
| 92 | 128 | export function workspaceRedirect(pathname: string, search = ""): string | null { | |
| 93 | 129 | // A click asks for the page's data at `<path>.data?_routes=…`; the page | |
| 62 | 62 | // A workspace's own pages sit under `-`, which no repository can be named. | |
| 63 | 63 | route(":owner", "routes/workspace/layout.tsx", [ | |
| 64 | 64 | index("routes/workspace/overview.tsx"), | |
| 65 | − | // Its tabs: Overview (above), Projects, Packages, Teams, People, Insights. | |
| 65 | + | // Its pages, each in the sidebar: Overview (above), Projects, People, Insights; Teams and Packages below. | |
| 66 | 66 | route("-/projects", "routes/workspace/projects.tsx"), | |
| 67 | 67 | route("-/people", "routes/workspace/people.tsx"), | |
| 68 | 68 | route("-/insights", "routes/workspace/tab-soon.tsx", { id: "routes/workspace/insights" }), |
| 1 | − | import { Plus } from "lucide-react"; | |
| 2 | − | import { NavLink, Outlet, data, redirect, useLocation, useNavigation } from "react-router"; | |
| 1 | + | import { Package, Plus } from "lucide-react"; | |
| 2 | + | import { Outlet, data, redirect, useLocation, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/layout"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| ⋯ | |||
| 9 | 9 | import { notFound } from "../../lib/not-found.server"; | |
| 10 | 10 | import { redirectIfRenamed } from "../../lib/renamed.server"; | |
| 11 | 11 | import { rememberWorkspace } from "../../lib/workspace-choice"; | |
| 12 | − | import { type WorkspaceTab, type WorkspaceTabKey, workspaceRedirect, workspaceTab, workspaceTabs } from "../../lib/workspace-nav"; | |
| 13 | − | import { workspaceProjects } from "../../lib/workspace-projects.server"; | |
| 14 | − | import { WorkspaceTabSkeleton } from "../../components/workspace-skeletons"; | |
| 12 | + | import { workspacePage, workspaceRedirect } from "../../lib/workspace-nav"; | |
| 13 | + | import { WorkspacePageSkeleton } from "../../components/workspace-skeletons"; | |
| 15 | 14 | import { identity } from "../../lib/services.server"; | |
| 16 | 15 | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 17 | 16 | ||
| ⋯ | |||
| 21 | 20 | ||
| 22 | 21 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 23 | 22 | const url = new URL(request.url); | |
| 24 | − | // `?tab=projects` and the like, from habit: that tab's own address. | |
| 23 | + | // `?tab=projects` and the like, from the tabs this page once had: that | |
| 24 | + | // page's own address. | |
| 25 | 25 | const moved = url.search ? workspaceRedirect(url.pathname, url.search) : null; | |
| 26 | 26 | if (moved) throw redirect(moved); | |
| 27 | 27 | const viewer = getViewer(context); | |
| 28 | − | // Under the tabs, the count of projects beside Projects: the same | |
| 29 | − | // listing the sidebar and the Projects page read, asked at once. | |
| 30 | − | const tabbed = workspaceTab(url.pathname, params.owner) != null; | |
| 31 | − | const [workspace, listed] = await Promise.all([ | |
| 32 | − | identity.getWorkspace(params.owner), | |
| 33 | − | tabbed ? workspaceProjects(params.owner, viewer).catch(() => null) : null, | |
| 34 | − | ]); | |
| 28 | + | const workspace = await identity.getWorkspace(params.owner); | |
| 35 | 29 | if (!workspace) { | |
| 36 | 30 | // A workspace's old name, after a rename: its pages are at the new one. | |
| 37 | 31 | await redirectIfRenamed(request, params.owner); | |
| 38 | 32 | throw notFound("workspace"); | |
| 39 | 33 | } | |
| 40 | 34 | const role = roleIn(viewer, workspace.slug); | |
| 41 | − | const projectCount = listed?.ok ? listed.value.length : null; | |
| 42 | 35 | // Opening one of your workspaces makes it the one you are in. | |
| 43 | − | if (!role) return { workspace, role, welcome: false, projectCount }; | |
| 36 | + | if (!role) return { workspace, role, welcome: false }; | |
| 44 | 37 | const secure = new URL(request.url).protocol === "https:"; | |
| 45 | 38 | const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) }); | |
| 46 | 39 | // Just joined with an invite: welcomed once (routes/invite.tsx). | |
| 47 | 40 | const welcome = welcomes(request.headers.get("cookie"), workspace.slug); | |
| 48 | 41 | if (welcome) headers.append("Set-Cookie", clearWelcome(secure)); | |
| 49 | − | return data({ workspace, role, welcome, projectCount }, { headers }); | |
| 42 | + | return data({ workspace, role, welcome }, { headers }); | |
| 50 | 43 | } | |
| 51 | 44 | ||
| 52 | 45 | /** A workspace's own pages, each with its title and what it is for. */ | |
| ⋯ | |||
| 120 | 113 | }, | |
| 121 | 114 | }; | |
| 122 | 115 | ||
| 123 | − | /** | |
| 124 | − | * The workspace's tabs, as a project's are: a row under its header that | |
| 125 | − | * scrolls sideways on a phone, without a scrollbar. Soon tabs say so with | |
| 126 | − | * a dot. | |
| 127 | − | */ | |
| 128 | − | function WorkspaceTabs({ tabs, current }: { tabs: WorkspaceTab[]; current: WorkspaceTabKey | null }) { | |
| 116 | + | /** A workspace page's heading: its title and what it is for. */ | |
| 117 | + | function PageHeader({ title, about }: { title: string; about: string }) { | |
| 129 | 118 | return ( | |
| 130 | − | <nav aria-label="Workspace" className="relative -mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> | |
| 131 | − | {tabs.map((tab) => ( | |
| 132 | − | <NavLink | |
| 133 | − | key={tab.key} | |
| 134 | − | to={tab.to} | |
| 135 | − | end={tab.key === "overview"} | |
| 136 | − | prefetch="intent" | |
| 137 | − | aria-current={current === tab.key ? "page" : undefined} | |
| 138 | − | className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${ | |
| 139 | − | current === tab.key | |
| 140 | − | ? "border-accent font-medium text-fg" | |
| 141 | − | : tab.soon | |
| 142 | − | ? "border-transparent text-faint hover:text-muted" | |
| 143 | − | : "border-transparent text-muted hover:text-fg" | |
| 144 | − | }`} | |
| 145 | − | > | |
| 146 | − | {tab.label} | |
| 147 | − | {tab.count != null && ( | |
| 148 | − | <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted">{tab.count}</span> | |
| 149 | − | )} | |
| 150 | − | {tab.soon && ( | |
| 151 | − | <> | |
| 152 | − | <span aria-hidden="true" className="size-1.5 rounded-full bg-accent/70" /> | |
| 153 | − | <span className="sr-only">(soon)</span> | |
| 154 | − | </> | |
| 155 | − | )} | |
| 156 | − | </NavLink> | |
| 157 | − | ))} | |
| 158 | − | </nav> | |
| 119 | + | <header className="mb-8 border-b border-line pb-6"> | |
| 120 | + | <h1 className="text-2xl font-semibold tracking-tight">{title}</h1> | |
| 121 | + | <p className="mt-1.5 text-sm text-muted">{about}</p> | |
| 122 | + | </header> | |
| 159 | 123 | ); | |
| 160 | 124 | } | |
| 161 | 125 | ||
| 162 | 126 | export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) { | |
| 163 | − | const { workspace, role, welcome, projectCount } = loaderData; | |
| 127 | + | const { workspace, role, welcome } = loaderData; | |
| 164 | 128 | const { pathname } = useLocation(); | |
| 165 | 129 | const going = useNavigation().location; | |
| 166 | − | const tab = workspaceTab(pathname, workspace.slug); | |
| 167 | − | // The sidebar finds the workspace's other pages, for everyone, so they | |
| 168 | − | // need a title, not the workspace's whole header again. | |
| 130 | + | // The workspace's own page is its overview. Its other pages are found | |
| 131 | + | // from the sidebar, which lights the one you are on, and the top bar's | |
| 132 | + | // trail leads back here; each has a heading of its own. | |
| 133 | + | const here = workspacePage(pathname, workspace.slug); | |
| 134 | + | // On the way to another of its pages: that page's heading and shape | |
| 135 | + | // until it arrives. | |
| 136 | + | const next = going && going.pathname !== pathname ? workspacePage(going.pathname, workspace.slug) : null; | |
| 137 | + | const shown = next ?? here; | |
| 169 | 138 | const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean); | |
| 170 | − | const page = tab ? undefined : PAGES[parts[0] ?? ""]; | |
| 171 | − | // A page within one (a single package) has its own heading. | |
| 172 | − | if (page && parts.length > 1) { | |
| 173 | − | return ( | |
| 174 | − | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 175 | − | <Outlet /> | |
| 176 | − | </div> | |
| 177 | − | ); | |
| 178 | − | } | |
| 179 | − | if (page) { | |
| 139 | + | if (shown !== "overview") { | |
| 140 | + | const key = shown ?? parts[0] ?? ""; | |
| 141 | + | const heading = PAGES[key]; | |
| 142 | + | // A page within one (a single package or team) and a page that is | |
| 143 | + | // coming (Insights) have headings of their own. | |
| 144 | + | const titled = heading && (shown != null || parts.length === 1); | |
| 180 | 145 | return ( | |
| 181 | 146 | <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8"> | |
| 182 | − | <header className="mb-8 border-b border-line pb-6"> | |
| 183 | − | <h1 className="text-2xl font-semibold tracking-tight">{page.title}</h1> | |
| 184 | − | <p className="mt-1.5 text-sm text-muted">{page.about}</p> | |
| 185 | − | </header> | |
| 186 | − | <Outlet /> | |
| 147 | + | {titled && <PageHeader title={heading.title} about={heading.about} />} | |
| 148 | + | {next ? <WorkspacePageSkeleton page={next} /> : <Outlet />} | |
| 187 | 149 | </div> | |
| 188 | 150 | ); | |
| 189 | 151 | } | |
| 190 | − | const tabs = workspaceTabs(workspace.slug, { | |
| 191 | − | member: role != null, | |
| 192 | − | owner: role === "owner", | |
| 193 | − | projects: projectCount, | |
| 194 | − | people: role ? workspace.memberCount : null, | |
| 195 | − | }); | |
| 196 | − | // On the way to another of its tabs: that tab's shape until it arrives. | |
| 197 | − | const next = going && going.pathname !== pathname ? workspaceTab(going.pathname, workspace.slug) : null; | |
| 198 | 152 | return ( | |
| 199 | 153 | <> | |
| 200 | − | {/* The workspace's own header band, with its tabs along the bottom. */} | |
| 154 | + | {/* The workspace's own header band, above its overview. */} | |
| 201 | 155 | <div className="border-b border-line bg-surface/60"> | |
| 202 | − | <div className="mx-auto max-w-6xl px-4 pt-8"> | |
| 156 | + | <div className="mx-auto max-w-6xl px-4 py-8"> | |
| 203 | 157 | {welcome && ( | |
| 204 | 158 | <div className="mb-6"> | |
| 205 | 159 | <WelcomeBanner title={`You're in ${workspace.name}`}> | |
| ⋯ | |||
| 218 | 172 | </div> | |
| 219 | 173 | <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p> | |
| 220 | 174 | </div> | |
| 221 | − | {role && ( | |
| 175 | + | {role ? ( | |
| 222 | 176 | <ButtonLink to={`/new?workspace=${workspace.slug}`}> | |
| 223 | 177 | <Plus size={15} /> | |
| 224 | 178 | New project | |
| 225 | 179 | </ButtonLink> | |
| 180 | + | ) : ( | |
| 181 | + | // A visitor's sidebar is not this workspace's: its packages | |
| 182 | + | // are a link here, as its projects are below. | |
| 183 | + | <ButtonLink to={`/${workspace.slug}/-/packages`} variant="quiet" prefetch="intent"> | |
| 184 | + | <Package size={15} /> | |
| 185 | + | Packages | |
| 186 | + | </ButtonLink> | |
| 226 | 187 | )} | |
| 227 | 188 | </div> | |
| 228 | 189 | {workspace.description && ( | |
| 229 | 190 | <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p> | |
| 230 | 191 | )} | |
| 231 | − | <div className="mt-6"> | |
| 232 | − | <WorkspaceTabs tabs={tabs} current={next ?? tab} /> | |
| 233 | − | </div> | |
| 234 | 192 | </div> | |
| 235 | 193 | </div> | |
| 236 | 194 | <div className="mx-auto max-w-6xl px-4 py-8"> | |
| 237 | − | {/* What a tab is for, said once above it. */} | |
| 238 | − | {tab && tab !== "overview" && !next && PAGES[tab] && <p className="mb-6 max-w-3xl text-sm text-muted">{PAGES[tab].about}</p>} | |
| 239 | − | {next ? <WorkspaceTabSkeleton tab={next} /> : <Outlet />} | |
| 195 | + | {next ? <WorkspacePageSkeleton page="overview" /> : <Outlet />} | |
| 240 | 196 | </div> | |
| 241 | 197 | </> | |
| 242 | 198 | ); | |
| 7 | 7 | import { pagePath } from "../../lib/workspace-nav"; | |
| 8 | 8 | import { SoonView } from "../repo/soon"; | |
| 9 | 9 | ||
| 10 | − | /** The tab is the last part of the address: `-/insights`. */ | |
| 10 | + | /** The page is the last part of the address: `-/insights`. */ | |
| 11 | 11 | const tabOf = (pathname: string) => pagePath(pathname).split("/").pop() ?? ""; | |
| 12 | 12 | ||
| 13 | 13 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | export function loader({ params, context, request }: Route.LoaderArgs) { | |
| 22 | − | // For the workspace's members, as their tabs are. | |
| 22 | + | // For the workspace's members, as its People and Teams are. | |
| 23 | 23 | if (!roleIn(getViewer(context), params.owner)) throw data(null, { status: 404 }); | |
| 24 | 24 | const item = roadmapItem(tabOf(new URL(request.url).pathname)); | |
| 25 | 25 | if (!item || item.section !== "Workspace") throw data(null, { status: 404 }); | |
| 26 | 26 | return { item }; | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | − | /** A tab of the workspace's page that is coming: Insights. */ | |
| 29 | + | /** A page of the workspace's that is coming: Insights. */ | |
| 30 | 30 | export default function WorkspaceTabSoon({ loaderData, params }: Route.ComponentProps) { | |
| 31 | 31 | return <SoonView item={loaderData.item} base={`/${params.owner}/-`} newProject={`/new?workspace=${params.owner}`} />; | |
| 32 | 32 | } | |