| 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 dock. |
| 7 | * - `app`: someone signed in, everywhere else. The dock, 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 | /** The page's path without a trailing slash or a click's `.data`. */ |
| 37 | function framePath(pathname: string): string { |
| 38 | const path = pathname.replace(/\.data$/, ""); |
| 39 | return path.length > 1 ? path.replace(/\/+$/, "") : path; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * The frame for `pathname`. `viewer` says whether someone is signed in and |
| 44 | * confirmed (`signedIn`), and whether they belong to a workspace yet: until |
| 45 | * they do, answering an invitation is part of getting started too. |
| 46 | */ |
| 47 | export function frameOf(pathname: string, viewer: { signedIn: boolean; workspace: boolean }): Frame { |
| 48 | const path = framePath(pathname); |
| 49 | if (STANDALONE.has(path)) return "standalone"; |
| 50 | // An invite link, and signing in with GitHub, are the front door. |
| 51 | if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/")) return "standalone"; |
| 52 | if (path === "/invitations" && viewer.signedIn && !viewer.workspace) return "standalone"; |
| 53 | return viewer.signedIn ? "app" : "public"; |
| 54 | } |
| 55 | |
| 56 | /** The project pages the sidebar lists, by key, for a member or not. */ |
| 57 | export type ProjectPage = |
| 58 | | "code" |
| 59 | | "issues" |
| 60 | | "pulls" |
| 61 | | "agents" |
| 62 | | "actions" |
| 63 | | "deployments" |
| 64 | | "observability" |
| 65 | | "security" |
| 66 | | "insights" |
| 67 | | "settings"; |
| 68 | |
| 69 | /** |
| 70 | * What a page needs beyond a role on the repository, once what the viewer |
| 71 | * may do is known: Security's findings are for people who can push. |
| 72 | */ |
| 73 | export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" }; |
| 74 | |
| 75 | /** |
| 76 | * A project's menu, in order. Security and Settings are for people with a |
| 77 | * role on its repository, and a page in `PAGE_NEEDS` only for those whose |
| 78 | * role has its capability (`can`, once the repository's page has loaded); |
| 79 | * everyone who can see the project sees the rest. |
| 80 | */ |
| 81 | export function projectPages(member: boolean, can?: Partial<Abilities>): ProjectPage[] { |
| 82 | const pages: (ProjectPage | false)[] = [ |
| 83 | "code", |
| 84 | "issues", |
| 85 | "pulls", |
| 86 | "agents", |
| 87 | "actions", |
| 88 | // A public repository's deployments and build logs are for anyone, as its code is. |
| 89 | "deployments", |
| 90 | "observability", |
| 91 | member && "security", |
| 92 | "insights", |
| 93 | member && "settings", |
| 94 | ]; |
| 95 | return pages.filter( |
| 96 | (page): page is ProjectPage => page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])), |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * A project's pages as the phone's strip of tabs under its name names them |
| 102 | * (routes/repo/layout.tsx): the sidebar's words, and the paths under the |
| 103 | * project at which each is the current one. |
| 104 | */ |
| 105 | export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean }> = { |
| 106 | code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare"] }, |
| 107 | issues: { label: "Issues", path: "issues", also: ["plans", "milestones", "labels"] }, |
| 108 | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue"] }, |
| 109 | agents: { label: "Agents", path: "agents", also: ["sessions", "memory"] }, |
| 110 | actions: { label: "Workflows", path: "actions", also: [] }, |
| 111 | deployments: { label: "Deployments", path: "deployments", also: [] }, |
| 112 | observability: { label: "Observability", path: "soon/logs", also: [], soon: true }, |
| 113 | security: { label: "Security", path: "security", also: [] }, |
| 114 | insights: { label: "Insights", path: "contributors", also: ["activity", "stargazers"] }, |
| 115 | settings: { label: "Settings", path: "settings", also: [] }, |
| 116 | }; |
| 117 | |
| 118 | /** |
| 119 | * Which of a project's pages `rest` (the path under the project, no |
| 120 | * leading slash) is on: "overview" for the project itself. `soon` says |
| 121 | * which page a roadmap page (`soon/<key>`) sits under. |
| 122 | */ |
| 123 | export function projectPageAt(rest: string, soon: Record<string, ProjectPage> = {}): ProjectPage | "overview" | null { |
| 124 | const path = rest.replace(/^\/+|\/+$/g, ""); |
| 125 | if (path === "") return "overview"; |
| 126 | const soonKey = /^soon\/([^/]+)/.exec(path)?.[1]; |
| 127 | if (soonKey && soon[soonKey]) return soon[soonKey]!; |
| 128 | for (const [page, link] of Object.entries(PROJECT_PAGE_LINKS) as [ProjectPage, (typeof PROJECT_PAGE_LINKS)[ProjectPage]][]) { |
| 129 | if ([link.path, ...link.also].some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return page; |
| 130 | } |
| 131 | return null; |
| 132 | } |