| 1 | /** |
| 2 | * Which frame a page is drawn in. |
| 3 | * |
| 4 | * - `standalone`: signing in, signing up and choosing a workspace. The |
| 5 | * page alone, centred, with the logo and a quiet row of links at the |
| 6 | * foot: no header, no sidebar, no rail. |
| 7 | * - `app`: someone signed in, everywhere else. The rail, the mode's |
| 8 | * sidebar and the page in its panel, always about their current |
| 9 | * workspace, even on public pages such as Explore, a profile or another |
| 10 | * workspace's public project. |
| 11 | * - `public`: a visitor who is not signed in (or has not confirmed their |
| 12 | * address yet), on every other page: a plain header with the logo, |
| 13 | * Explore, Docs and signing in, the page at full width, and the footer. |
| 14 | */ |
| 15 | |
| 16 | import type { Abilities, Capability } from "@g1t/contracts"; |
| 17 | |
| 18 | export type Frame = "standalone" | "public" | "app"; |
| 19 | |
| 20 | /** Pages that are drawn on their own, whoever opens them. */ |
| 21 | const STANDALONE = new Set([ |
| 22 | "/login", |
| 23 | "/login/two-factor", |
| 24 | "/register", |
| 25 | "/logout", |
| 26 | "/verify", |
| 27 | "/confirm-email", |
| 28 | "/forgot", |
| 29 | "/reset", |
| 30 | "/device", |
| 31 | "/oauth/authorize", |
| 32 | "/workspaces/new", |
| 33 | "/integrations/github/setup", |
| 34 | ]); |
| 35 | |
| 36 | /** Marketing pages: the public header and footer, even for someone signed in. */ |
| 37 | const MARKETING = new Set(["/download"]); |
| 38 | |
| 39 | /** The page's path without a trailing slash or a click's `.data`. */ |
| 40 | function framePath(pathname: string): string { |
| 41 | const path = pathname.replace(/\.data$/, ""); |
| 42 | return path.length > 1 ? path.replace(/\/+$/, "") : path; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * The frame for `pathname`. `viewer` says whether someone is signed in and |
| 47 | * confirmed (`signedIn`), and whether they belong to a workspace yet: until |
| 48 | * they do, answering an invitation is part of getting started too. |
| 49 | */ |
| 50 | export function frameOf(pathname: string, viewer: { signedIn: boolean; workspace: boolean }): Frame { |
| 51 | const path = framePath(pathname); |
| 52 | if (STANDALONE.has(path)) return "standalone"; |
| 53 | // Marketing pages are the site's front, whoever reads them: never inside the app's rail and sidebar. |
| 54 | if (MARKETING.has(path)) return "public"; |
| 55 | // An invite link, and signing in with GitHub, are the front door. |
| 56 | if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/")) return "standalone"; |
| 57 | if (path === "/invitations" && viewer.signedIn && !viewer.workspace) return "standalone"; |
| 58 | return viewer.signedIn ? "app" : "public"; |
| 59 | } |
| 60 | |
| 61 | /** The project pages the sidebar lists, by key, for a member or not. */ |
| 62 | export type ProjectPage = |
| 63 | | "code" |
| 64 | | "issues" |
| 65 | | "pulls" |
| 66 | | "agents" |
| 67 | | "actions" |
| 68 | | "deployments" |
| 69 | | "observability" |
| 70 | | "security" |
| 71 | | "insights" |
| 72 | | "settings"; |
| 73 | |
| 74 | /** |
| 75 | * What a page needs beyond a role on the repository, once what the viewer |
| 76 | * may do is known: Security's findings are for people who can push. |
| 77 | */ |
| 78 | export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" }; |
| 79 | |
| 80 | /** |
| 81 | * A project's menu, in order. Security and Settings are for people with a |
| 82 | * role on its repository, and a page in `PAGE_NEEDS` only for those whose |
| 83 | * role has its capability (`can`, once the repository's page has loaded); |
| 84 | * everyone who can see the project sees the rest. |
| 85 | */ |
| 86 | export function projectPages(member: boolean, can?: Partial<Abilities>): ProjectPage[] { |
| 87 | const pages: (ProjectPage | false)[] = [ |
| 88 | "code", |
| 89 | "issues", |
| 90 | "pulls", |
| 91 | "agents", |
| 92 | "actions", |
| 93 | // A public repository's deployments and build logs are for anyone, as its code is. |
| 94 | "deployments", |
| 95 | "observability", |
| 96 | member && "security", |
| 97 | "insights", |
| 98 | member && "settings", |
| 99 | ]; |
| 100 | return pages.filter( |
| 101 | (page): page is ProjectPage => page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])), |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * A project's pages as the phone's strip of tabs under its name names them |
| 107 | * (routes/repo/layout.tsx): the sidebar's words, and the paths under the |
| 108 | * project at which each is the current one. |
| 109 | */ |
| 110 | export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean }> = { |
| 111 | code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare"] }, |
| 112 | issues: { label: "Issues", path: "issues", also: ["plans", "milestones", "labels"] }, |
| 113 | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue"] }, |
| 114 | agents: { label: "Agents", path: "agents", also: ["sessions", "memory"] }, |
| 115 | actions: { label: "Workflows", path: "actions", also: [] }, |
| 116 | deployments: { label: "Deployments", path: "deployments", also: [] }, |
| 117 | observability: { label: "Observability", path: "soon/logs", also: [], soon: true }, |
| 118 | security: { label: "Security", path: "security", also: [] }, |
| 119 | insights: { label: "Insights", path: "contributors", also: ["activity", "stargazers"] }, |
| 120 | settings: { label: "Settings", path: "settings", also: [] }, |
| 121 | }; |
| 122 | |
| 123 | /** |
| 124 | * Which of a project's pages `rest` (the path under the project, no |
| 125 | * leading slash) is on: "overview" for the project itself. `soon` says |
| 126 | * which page a roadmap page (`soon/<key>`) sits under. |
| 127 | */ |
| 128 | export function projectPageAt(rest: string, soon: Record<string, ProjectPage> = {}): ProjectPage | "overview" | null { |
| 129 | const path = rest.replace(/^\/+|\/+$/g, ""); |
| 130 | if (path === "") return "overview"; |
| 131 | const soonKey = /^soon\/([^/]+)/.exec(path)?.[1]; |
| 132 | if (soonKey && soon[soonKey]) return soon[soonKey]!; |
| 133 | for (const [page, link] of Object.entries(PROJECT_PAGE_LINKS) as [ProjectPage, (typeof PROJECT_PAGE_LINKS)[ProjectPage]][]) { |
| 134 | if ([link.path, ...link.also].some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return page; |
| 135 | } |
| 136 | return null; |
| 137 | } |