The app's frame and your own pages fail one part at a time. Root loader: the viewer stays strict; the sidebar's projects, the open counts, Shared with you and the live feed's ticket go through part() and do without quietly, so one of them failing no longer drops the sidebar to its bare form or, for a visitor, takes down every page. A repository's frame: the repository and its project stay strict (404s, renames, transfers); its open counts and the project lookup do without. Profile: activity, workspaces, stars and contributions each say so in place. Notifications, invitations ("No invitations waiting" was what a failure said), pricing's usage and price changes, importing from GitHub on New, and the installations already on GitHub. Settings: account's erasure and deletion check show Unavailable instead of their forms; notifications' email, security and new-repository sections show Unavailable instead of forms that could save over unknown values (the page used to blank entirely), and watched repositories in place; GitHub ("not set up here" was what a failure said); integrations' GitHub link and grants; two-factor's message is Unavailable. Kept strict on purpose: sign-in, registration, invites, OAuth, device, two-factor sign-in and email confirmation, and every page that reads only its subject. Kept quiet on purpose: profile descriptions, notification tab counts, billing on pricing, hasPassword (it only asks for a password, which identity checks anyway), sounds. Checked: apps/web tsc -b; npm test (987, 1 skipped).
- G1t-Session:
- ses_01m4zzpc96ect8d41mffgr1a45
13 files+257−1240/13 viewed
| 65 | 65 | import { getViewer, viewerMiddleware } from "./lib/session.server"; | |
| 66 | 66 | import { shortcutOf, workspaceProjects } from "./lib/workspace-projects.server"; | |
| 67 | 67 | import { addresses } from "./lib/addresses.server"; | |
| 68 | + | import { orElse, part } from "./lib/partial"; | |
| 68 | 69 | import { | |
| 69 | 70 | RELOADED_KEY, | |
| 70 | 71 | RELOAD_GIVE_UP_MS, | |
| ⋯ | |||
| 161 | 162 | shell, | |
| 162 | 163 | // For a page opened with an access token: the feed socket's ticket, | |
| 163 | 164 | // minted here so the feed opens without asking for one (lib/live-socket.ts). | |
| 165 | + | // Without one the feed asks for its own ticket, so a ticket that could | |
| 166 | + | // not be minted is no reason to lose the page. | |
| 164 | 167 | liveTicket: user?.token?.website | |
| 165 | − | ? await socketTicketFor(context, request, "/-/live") | |
| 168 | + | ? orElse(await part(socketTicketFor(context, request, "/-/live"), "The live feed's ticket"), null) | |
| 166 | 169 | : null, | |
| 167 | 170 | // Whether they folded the sidebar away, so the page is drawn that way from the start. | |
| 168 | 171 | sidebarClosed: sidebarClosed(readCookie(cookies, SIDEBAR_COOKIE)), | |
| ⋯ | |||
| 191 | 194 | params.owner && params.repo | |
| 192 | 195 | ? { namespace: params.owner, name: params.repo } | |
| 193 | 196 | : null; | |
| 194 | − | const counts = path ? await countsFor(context, params) : null; | |
| 197 | + | // The counts are the sidebar's, never the page's: without them it shows none. | |
| 198 | + | const counts = path ? orElse(await part(countsFor(context, params), "Open counts"), null) : null; | |
| 195 | 199 | return { | |
| 196 | 200 | workspace: null, | |
| 197 | 201 | repos: [], | |
| ⋯ | |||
| 288 | 292 | ] = await Promise.all([ | |
| 289 | 293 | // Every project, for the palette and the count; the sidebar lists only | |
| 290 | 294 | // the person's pinned and recent ones (lib/pins.ts). | |
| 291 | − | workspace ? workspaceProjects(workspace.slug, user) : Promise.resolve(null), | |
| 292 | − | path ? countsFor(context, params) : Promise.resolve(null), | |
| 295 | + | // Each of these is one part of the sidebar: one that fails leaves only | |
| 296 | + | // itself out (logged by `part`), never the rest of the sidebar with it. | |
| 297 | + | workspace | |
| 298 | + | ? part(workspaceProjects(workspace.slug, user), "Projects").then((value) => orElse(value, null)) | |
| 299 | + | : Promise.resolve(null), | |
| 300 | + | path | |
| 301 | + | ? part(countsFor(context, params), "Open counts").then((value) => orElse(value, null)) | |
| 302 | + | : Promise.resolve(null), | |
| 293 | 303 | // Whether billing is on, without reading the account: that asks the | |
| 294 | 304 | // card processor about the workspace's cards, too slow for every page. | |
| 295 | 305 | kept("status", () => billing.status()).catch(() => null), | |
| ⋯ | |||
| 300 | 310 | kept("entitlements", () => billing.entitlements(workspace!.slug)).catch( | |
| 301 | 311 | () => null, | |
| 302 | 312 | ), | |
| 303 | − | sharedRepos(user), | |
| 313 | + | part(sharedRepos(user), "Shared with you").then((value) => orElse(value, [])), | |
| 304 | 314 | // The bell's count: one query, never kept, so marking an item shows at once. | |
| 305 | 315 | inbox.counts(user.username).catch(() => null), | |
| 306 | 316 | // Never kept: opening a project moves it up Recent. | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/invitations"; | |
| 4 | 4 | import { InvitationList } from "../components/invitation-list"; | |
| 5 | − | import { answerInvitation, loadInvitations } from "../lib/invitations.server"; | |
| 5 | + | import { answerInvitation } from "../lib/invitations.server"; | |
| 6 | 6 | import { declinedLine } from "../lib/invitations"; | |
| 7 | 7 | import { page } from "../lib/meta"; | |
| 8 | 8 | import { assertSameOrigin, nextPath, requireUser } from "../lib/session.server"; | |
| 9 | 9 | import { Card } from "../components/ui/card"; | |
| 10 | + | import { identity } from "../lib/services.server"; | |
| 11 | + | import { Unavailable } from "../components/boundary"; | |
| 12 | + | import { isFailed, part } from "../lib/partial"; | |
| 10 | 13 | ||
| 11 | 14 | export function meta(args: Route.MetaArgs) { | |
| 12 | 15 | return page(args, { title: "Invitations · g1t" }); | |
| ⋯ | |||
| 21 | 24 | const user = requireUser(context, request); | |
| 22 | 25 | const next = nextPath(request); | |
| 23 | 26 | return { | |
| 24 | − | invitations: await loadInvitations(user), | |
| 27 | + | // Read here rather than with loadInvitations, which reads a failure as | |
| 28 | + | // none: on this page that would say "No invitations waiting" when some | |
| 29 | + | // may be. An account still to confirm its address is shown none, as there. | |
| 30 | + | invitations: user.verified ? await part(identity.listInvitations(user), "Your invitations") : [], | |
| 25 | 31 | hasWorkspace: (user.workspaces ?? []).length > 0, | |
| 26 | 32 | next: next === "/" ? null : next, | |
| 27 | 33 | }; | |
| ⋯ | |||
| 47 | 53 | </p> | |
| 48 | 54 | )} | |
| 49 | 55 | <div className="mt-6"> | |
| 50 | − | {invitations.length > 0 ? ( | |
| 56 | + | {isFailed(invitations) ? ( | |
| 57 | + | <Unavailable what="Your invitations" /> | |
| 58 | + | ) : invitations.length > 0 ? ( | |
| 51 | 59 | <InvitationList | |
| 52 | 60 | invitations={invitations} | |
| 53 | 61 | error={actionData?.invitationError} | |
| 13 | 13 | import { page } from "../lib/meta"; | |
| 14 | 14 | import { assertSameOrigin, requireUser, roleIn } from "../lib/session.server"; | |
| 15 | 15 | import { currentWorkspace } from "../lib/current-workspace.server"; | |
| 16 | + | import { Unavailable } from "../components/boundary"; | |
| 17 | + | import { isFailed, part } from "../lib/partial"; | |
| 16 | 18 | ||
| 17 | 19 | export function meta(args: Route.MetaArgs) { | |
| 18 | 20 | return page(args, { title: "Import from GitHub · g1t" }); | |
| ⋯ | |||
| 29 | 31 | // an installation made there directly is offered here to be added. | |
| 30 | 32 | const [status, visible] = await Promise.all([ | |
| 31 | 33 | githubApp.status(user, workspace), | |
| 32 | − | githubApp.visibleInstallations(user).catch(() => null), | |
| 34 | + | part(githubApp.visibleInstallations(user), "Accounts the app is already installed on"), | |
| 33 | 35 | ]); | |
| 34 | 36 | if (!status.ok || !status.value.configured) throw redirect("/new"); | |
| 35 | 37 | const { installations, linked } = status.value; | |
| 36 | 38 | const recorded = new Set(installations.map((item) => item.id)); | |
| 37 | 39 | const claimable = | |
| 38 | − | linked && visible?.ok | |
| 40 | + | linked && !isFailed(visible) && visible.ok | |
| 39 | 41 | ? notYetAdded(visible.value, workspace) | |
| 40 | 42 | .filter((item) => !recorded.has(item.id)) | |
| 41 | 43 | .map((item) => ({ ...item, summary: installationSummary(item) })) | |
| ⋯ | |||
| 57 | 59 | linked, | |
| 58 | 60 | installations, | |
| 59 | 61 | claimable, | |
| 62 | + | // Not asked is not "none": the page says so where they would be offered. | |
| 63 | + | claimableUnknown: linked && isFailed(visible), | |
| 60 | 64 | installation, | |
| 61 | 65 | repositories, | |
| 62 | 66 | error, | |
| ⋯ | |||
| 156 | 160 | Your request went to the organization's owners on GitHub. Once one approves it, add the account here again. | |
| 157 | 161 | </p> | |
| 158 | 162 | )} | |
| 163 | + | {loaderData.claimableUnknown && ( | |
| 164 | + | <div className="mt-3"> | |
| 165 | + | <Unavailable what="Accounts the app is already installed on" compact /> | |
| 166 | + | </div> | |
| 167 | + | )} | |
| 159 | 168 | {claimable.length > 0 && ( | |
| 160 | 169 | <div className="mt-3 rounded-lg border border-accent/30 bg-accent/5 p-4"> | |
| 161 | 170 | <h3 className="text-sm font-medium">Already installed on GitHub</h3> | |
| 13 | 13 | import { githubApp } from "../lib/github.server"; | |
| 14 | 14 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 15 | 15 | import { currentWorkspace } from "../lib/current-workspace.server"; | |
| 16 | + | import { Unavailable } from "../components/boundary"; | |
| 17 | + | import { isFailed, part } from "../lib/partial"; | |
| 16 | 18 | ||
| 17 | 19 | export function meta(args: Route.MetaArgs) { | |
| 18 | 20 | return page(args, { title: "New project · g1t" }); | |
| ⋯ | |||
| 26 | 28 | if (!current) throw redirect("/workspaces/new"); | |
| 27 | 29 | const selected = current.slug; | |
| 28 | 30 | // With g1t's GitHub App configured, repositories can come from GitHub. | |
| 29 | − | const github = await githubApp.status(user, selected).catch(() => null); | |
| 30 | − | return { selected, name: current.name?.trim() || current.slug, github: Boolean(github?.ok && github.value.configured) }; | |
| 31 | + | // Not asked is not "not set up": the other sources stay, and the picker | |
| 32 | + | // says that importing from GitHub could not be checked. | |
| 33 | + | const github = await part(githubApp.status(user, selected), "Importing from GitHub"); | |
| 34 | + | return { | |
| 35 | + | selected, | |
| 36 | + | name: current.name?.trim() || current.slug, | |
| 37 | + | github: !isFailed(github) && github.ok && github.value.configured, | |
| 38 | + | githubUnknown: isFailed(github), | |
| 39 | + | }; | |
| 31 | 40 | } | |
| 32 | 41 | ||
| 33 | 42 | export async function action({ request, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 116 | 125 | /> | |
| 117 | 126 | ))} | |
| 118 | 127 | </RadioGroup> | |
| 128 | + | {loaderData.githubUnknown && <Unavailable what="Importing from GitHub" compact />} | |
| 119 | 129 | </FieldSet> | |
| 120 | 130 | ||
| 121 | 131 | {source === "import" && ( | |
| 10 | 10 | ||
| 11 | 11 | import type { Route } from "./+types/notifications"; | |
| 12 | 12 | import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox"; | |
| 13 | − | import { Card } from "../components/ui/card"; | |
| 14 | 13 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 15 | 14 | import { cn } from "../lib/cn"; | |
| 16 | 15 | import { REASON_FILTERS, inboxReason, inboxTab, markFromForm, severityOf, tabCount } from "../lib/inbox"; | |
| ⋯ | |||
| 18 | 17 | import { notFound } from "../lib/not-found.server"; | |
| 19 | 18 | import { page } from "../lib/meta"; | |
| 20 | 19 | import { inbox } from "../lib/services.server"; | |
| 20 | + | import { Unavailable } from "../components/boundary"; | |
| 21 | + | import { isFailed, part } from "../lib/partial"; | |
| 21 | 22 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 22 | 23 | ||
| 23 | 24 | const PAGE_ITEMS = 50; | |
| ⋯ | |||
| 39 | 40 | const reason = inboxReason(url.searchParams.get("reason")); | |
| 40 | 41 | const before = url.searchParams.get("before"); | |
| 41 | 42 | const [list, counts] = await Promise.all([ | |
| 42 | − | inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }).catch(() => null), | |
| 43 | + | part(inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }), "Notifications"), | |
| 44 | + | // Only the tabs' numbers: without them the tabs show none. | |
| 43 | 45 | inbox.counts(user.username).catch(() => null), | |
| 44 | 46 | ]); | |
| 45 | − | return { tab, view, reason, before, items: list?.items ?? null, next: list?.next ?? null, counts }; | |
| 47 | + | return { | |
| 48 | + | tab, | |
| 49 | + | view, | |
| 50 | + | reason, | |
| 51 | + | before, | |
| 52 | + | items: isFailed(list) ? list : list.items, | |
| 53 | + | next: isFailed(list) ? null : list.next, | |
| 54 | + | counts, | |
| 55 | + | }; | |
| 46 | 56 | } | |
| 47 | 57 | ||
| 48 | 58 | export async function action({ request, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 117 | 127 | </div> | |
| 118 | 128 | ||
| 119 | 129 | <div className="mt-4"> | |
| 120 | − | {items == null ? ( | |
| 121 | − | <Card asChild tone="plain" radius="lg" className="px-4 py-6 text-sm text-muted"> | |
| 122 | − | <p>Notifications could not be loaded. Try again in a moment.</p> | |
| 123 | − | </Card> | |
| 130 | + | {isFailed(items) ? ( | |
| 131 | + | <Unavailable what="Notifications" /> | |
| 124 | 132 | ) : items.length === 0 ? ( | |
| 125 | 133 | <InboxEmpty tab={tab} view={view} /> | |
| 126 | 134 | ) : ( | |
| 10 | 10 | import { TimeAgo } from "../components/ui"; | |
| 11 | 11 | import { Card } from "../components/ui/card"; | |
| 12 | 12 | import { billing } from "../lib/services.server"; | |
| 13 | + | import { Unavailable } from "../components/boundary"; | |
| 14 | + | import { isFailed, orElse, part } from "../lib/partial"; | |
| 13 | 15 | ||
| 14 | 16 | export function meta(args: Route.MetaArgs) { | |
| 15 | 17 | const plan = args.loaderData?.book?.plans?.find((p) => p.feature === "plan") ?? DEFAULT_PLAN; | |
| ⋯ | |||
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | export async function loader() { | |
| 28 | − | const [book, status] = await Promise.all([billing.prices().catch(() => null), billing.status().catch(() => null)]); | |
| 29 | − | return { book, free: status?.free ?? false }; | |
| 30 | + | // The page reads without the price book: the plan and the rest are its | |
| 31 | + | // defaults, and the usage table and price changes say they could not be | |
| 32 | + | // loaded instead of looking empty. | |
| 33 | + | const [book, status] = await Promise.all([part(billing.prices(), "The price book"), billing.status().catch(() => null)]); | |
| 34 | + | return { book: orElse(book, null), unpriced: isFailed(book), free: status?.free ?? false }; | |
| 30 | 35 | } | |
| 31 | 36 | ||
| 32 | 37 | /** A price, with the fraction of a cent it is set to: `$0.024`, `$0.25`. */ | |
| ⋯ | |||
| 279 | 284 | } | |
| 280 | 285 | ||
| 281 | 286 | export default function Pricing({ loaderData }: Route.ComponentProps) { | |
| 282 | − | const { book, free } = loaderData; | |
| 287 | + | const { book, unpriced, free } = loaderData; | |
| 283 | 288 | const checked = book?.prices.map((p) => p.checkedAt).filter((at): at is string => !!at).sort().at(-1); | |
| 284 | 289 | const given = Object.fromEntries(Object.entries(book?.free ?? {}).filter(([, value]) => value != null)); | |
| 285 | 290 | const tier: Required<FreeTier> = { ...DEFAULT_FREE, ...given }; | |
| ⋯ | |||
| 635 | 640 | <td className="hidden px-4 py-3 tabular-nums sm:table-cell">—</td> | |
| 636 | 641 | <td className="px-4 py-3 text-muted">Not charged</td> | |
| 637 | 642 | </tr> | |
| 643 | + | {unpriced && ( | |
| 644 | + | <tr> | |
| 645 | + | <td colSpan={4} className="px-4 py-3"> | |
| 646 | + | <Unavailable what="The metered prices" compact /> | |
| 647 | + | </td> | |
| 648 | + | </tr> | |
| 649 | + | )} | |
| 638 | 650 | {(book?.prices ?? []).filter((price) => !SHOWN_APART.has(price.meter)).map((price) => ( | |
| 639 | 651 | <tr key={price.meter}> | |
| 640 | 652 | <td className="px-4 py-3"> | |
| ⋯ | |||
| 731 | 743 | })} | |
| 732 | 744 | </ul> | |
| 733 | 745 | </Card> | |
| 746 | + | ) : unpriced ? ( | |
| 747 | + | <div className="mt-4"> | |
| 748 | + | <Unavailable what="Price changes" /> | |
| 749 | + | </div> | |
| 734 | 750 | ) : ( | |
| 735 | 751 | <p className="mt-2 text-sm text-muted">None yet. When a price moves, it is listed here with why.</p> | |
| 736 | 752 | )} | |
| 27 | 27 | import { accessFor, countsFor, projectFor, repoFor } from "../../lib/access.server"; | |
| 28 | 28 | import { inbox, projects, repos, work } from "../../lib/services.server"; | |
| 29 | 29 | import { getViewer, roleIn, unwrap } from "../../lib/session.server"; | |
| 30 | + | import { isFailed, orElse, part } from "../../lib/partial"; | |
| 30 | 31 | ||
| 31 | 32 | /** How many forks the header counts before it says "100+". */ | |
| 32 | 33 | const FORKS_COUNTED = 100; | |
| ⋯ | |||
| 42 | 43 | const member = viewer ? roleIn(viewer, params.owner) != null : false; | |
| 43 | 44 | const [repo, counts, found, watching, shortcuts, stars, forks, issues] = await Promise.all([ | |
| 44 | 45 | repoFor(context, params), | |
| 45 | − | countsFor(context, params), | |
| 46 | + | // The repository is what the frame is about; its open counts are only | |
| 47 | + | // the tabs' badges, which do without them. | |
| 48 | + | part(countsFor(context, params), "Open counts"), | |
| 46 | 49 | projectFor(context, params), | |
| 47 | 50 | // How the person watches it, for the header's Watch menu, as soon as | |
| 48 | 51 | // the repository is known. | |
| ⋯ | |||
| 69 | 72 | let project: Project | null = found.ok ? found.value : null; | |
| 70 | 73 | // A repository made a moment ago, before its project: make it now. | |
| 71 | 74 | if (!project && repo.ok && !repo.value.forkOf) { | |
| 72 | − | const own = await projects.byRepo(repo.value.id); | |
| 75 | + | // The repository still has its page when its project cannot be read: drawn by its own name. | |
| 76 | + | const own = orElse(await part(projects.byRepo(repo.value.id), "Its project"), []); | |
| 73 | 77 | project = own.find((p) => p.slug === params.repo.toLowerCase()) ?? own[0] ?? null; | |
| 74 | 78 | } | |
| 75 | 79 | // Opened just now: it leads their Recent, recorded after the page is sent. | |
| ⋯ | |||
| 92 | 96 | welcome, | |
| 93 | 97 | repo: value, | |
| 94 | 98 | project, | |
| 95 | − | open: counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 99 | + | open: !isFailed(counts) && counts.ok ? counts.value : { issues: 0, pulls: 0 }, | |
| 96 | 100 | access, | |
| 97 | 101 | member: access.insider, | |
| 98 | 102 | watching, | |
| 9 | 9 | import { githubSignIn } from "../../lib/github.server"; | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { accounts, privacy } from "../../lib/services.server"; | |
| 12 | + | import { Unavailable } from "../../components/boundary"; | |
| 13 | + | import { isFailed, part } from "../../lib/partial"; | |
| 12 | 14 | import { assertSameOrigin, clientOf, endSession, requireUser, sessionTokenOf } from "../../lib/session.server"; | |
| 13 | 15 | ||
| 14 | 16 | export function meta(args: Route.MetaArgs) { | |
| ⋯ | |||
| 19 | 21 | const user = requireUser(context, request); | |
| 20 | 22 | // What deleting it would take, and anything in the way, shown before | |
| 21 | 23 | // anyone types: workspaces you own alone, each with what billing needs. | |
| 24 | + | // Each section reads its own: one that could not be read says so in its | |
| 25 | + | // place, and its form is not offered on what is not known. Whether they | |
| 26 | + | // have a password only says whether deleting asks for one, and identity | |
| 27 | + | // checks that anyway: asking for it is the safe guess. | |
| 22 | 28 | const [deletion, github, erasure] = await Promise.all([ | |
| 23 | − | accounts.checkAccountDeletion(user).catch(() => null), | |
| 29 | + | part(accounts.checkAccountDeletion(user), "What deleting your account would take"), | |
| 24 | 30 | githubSignIn.account(user).catch(() => null), | |
| 25 | − | privacy.mine(user).catch(() => undefined), | |
| 31 | + | part(privacy.mine(user), "Your erasure request"), | |
| 26 | 32 | ]); | |
| 27 | 33 | return { | |
| 28 | 34 | erasure, | |
| 29 | 35 | username: user.username, | |
| 30 | − | deletion: deletion?.ok ? deletion.value : null, | |
| 36 | + | deletion: isFailed(deletion) ? deletion : deletion.ok ? deletion.value : null, | |
| 31 | 37 | hasPassword: github?.hasPassword ?? true, | |
| 32 | 38 | }; | |
| 33 | 39 | } | |
| ⋯ | |||
| 95 | 101 | <section aria-labelledby="erasure-heading" className="space-y-3"> | |
| 96 | 102 | <h2 id="erasure-heading" className="font-medium">Erase my personal data</h2> | |
| 97 | 103 | <p className="text-sm text-muted">Request erasure even if workspace ownership or billing prevents account closure. We review shared content and any records we must retain, then explain the outcome. This request does not immediately delete your account.</p> | |
| 98 | − | {loaderData.erasure ? <p className="text-sm">Request {loaderData.erasure.id}: {loaderData.erasure.status}. Response due {loaderData.erasure.due_at.slice(0, 10)}.</p> : loaderData.erasure === undefined ? <p className="text-sm">Request status is unavailable. Try again or email <a href="mailto:support@g1t.sh" className="underline">support@g1t.sh</a>.</p> : null} | |
| 104 | + | {isFailed(loaderData.erasure) ? <Unavailable what="Your erasure request" /> : loaderData.erasure ? <p className="text-sm">Request {loaderData.erasure.id}: {loaderData.erasure.status}. Response due {loaderData.erasure.due_at.slice(0, 10)}.</p> : null} | |
| 99 | 105 | {actionData?.erasureNotice && <p role="status" className="text-sm">{actionData.erasureNotice}</p>} | |
| 100 | 106 | <ErrorText>{actionData?.erasureError}</ErrorText> | |
| 101 | − | {(!loaderData.erasure || ["completed", "refused"].includes(loaderData.erasure.status)) && <Form method="post"> | |
| 107 | + | {!isFailed(loaderData.erasure) && (!loaderData.erasure || ["completed", "refused"].includes(loaderData.erasure.status)) && <Form method="post"> | |
| 102 | 108 | <SubmitButton name="intent" value="request-erasure" pending="Recording request...">Request erasure</SubmitButton> | |
| 103 | 109 | </Form>} | |
| 104 | 110 | <p className="text-sm text-muted">For specific content, a request without signing in, or a question about an existing request, email <a href="mailto:support@g1t.sh" className="underline">support@g1t.sh</a>.</p> | |
| 105 | 111 | </section> | |
| 106 | 112 | <DangerZone> | |
| 107 | − | <DeleteAccountAction | |
| 108 | − | username={username} | |
| 109 | − | deletion={deletion} | |
| 110 | − | hasPassword={hasPassword} | |
| 111 | − | error={actionData?.deleteError} | |
| 112 | − | /> | |
| 113 | + | {isFailed(deletion) ? ( | |
| 114 | + | <Unavailable what="What deleting your account would take" /> | |
| 115 | + | ) : ( | |
| 116 | + | <DeleteAccountAction | |
| 117 | + | username={username} | |
| 118 | + | deletion={deletion} | |
| 119 | + | hasPassword={hasPassword} | |
| 120 | + | error={actionData?.deleteError} | |
| 121 | + | /> | |
| 122 | + | )} | |
| 113 | 123 | </DangerZone> | |
| 114 | 124 | </div> | |
| 115 | 125 | ); | |
| 7 | 7 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 8 | 8 | import { GithubMark } from "../../components/github"; | |
| 9 | 9 | import { githubSignIn } from "../../lib/github.server"; | |
| 10 | + | import { Unavailable } from "../../components/boundary"; | |
| 11 | + | import { isFailed, part } from "../../lib/partial"; | |
| 10 | 12 | ||
| 11 | 13 | export function meta(args: Route.MetaArgs) { | |
| 12 | 14 | return page(args, { title: "GitHub · Settings · g1t" }); | |
| ⋯ | |||
| 14 | 16 | ||
| 15 | 17 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 16 | 18 | const user = requireUser(context, request); | |
| 17 | − | return { github: await githubSignIn.account(user).catch(() => null) }; | |
| 19 | + | // Not read is not "not set up": the page says it could not be loaded. | |
| 20 | + | return { github: await part(githubSignIn.account(user), "Your GitHub link") }; | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 20 | 23 | export async function action({ request, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 30 | 33 | ||
| 31 | 34 | export default function GithubSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 32 | 35 | const { github } = loaderData; | |
| 36 | + | if (isFailed(github)) return <Unavailable what="Your GitHub link" />; | |
| 33 | 37 | if (!github?.enabled) { | |
| 34 | 38 | return <p className="text-sm text-muted">Signing in with GitHub is not set up here.</p>; | |
| 35 | 39 | } | |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { identity } from "../../lib/services.server"; | |
| 11 | 11 | import { requireUser } from "../../lib/session.server"; | |
| 12 | + | import { Unavailable } from "../../components/boundary"; | |
| 13 | + | import { isFailed, part } from "../../lib/partial"; | |
| 12 | 14 | ||
| 13 | 15 | export function meta(args: Route.MetaArgs) { | |
| 14 | 16 | return page(args, { title: "Integrations · Settings · g1t" }); | |
| ⋯ | |||
| 21 | 23 | */ | |
| 22 | 24 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 23 | 25 | const user = requireUser(context, request); | |
| 26 | + | // What is connected marks the directory's tiles; one that could not be | |
| 27 | + | // read is said above it, so a tile is never shown unconnected when it | |
| 28 | + | // may well be connected. | |
| 24 | 29 | const [github, grants] = await Promise.all([ | |
| 25 | − | githubSignIn.account(user).catch(() => null), | |
| 26 | − | identity.listOAuthGrants(user).catch(() => []), | |
| 30 | + | part(githubSignIn.account(user), "Your GitHub link"), | |
| 31 | + | part(identity.listOAuthGrants(user), "Applications on the MCP server"), | |
| 27 | 32 | ]); | |
| 33 | + | const unread = [github, grants].filter(isFailed).map((failed) => failed.what); | |
| 28 | 34 | const connected: Record<string, ConnectedState> = {}; | |
| 29 | − | if (github?.account) { | |
| 35 | + | if (!isFailed(github) && github?.account) { | |
| 30 | 36 | connected.github = { | |
| 31 | 37 | detail: `Linked to @${github.account.login}`, | |
| 32 | 38 | problem: github.account.authorized ? null : "Access ended: link it again to import repositories.", | |
| 33 | 39 | manage: null, | |
| 34 | 40 | }; | |
| 35 | 41 | } | |
| 36 | − | if (grants.length > 0) { | |
| 42 | + | if (!isFailed(grants) && grants.length > 0) { | |
| 37 | 43 | connected.mcp = { | |
| 38 | 44 | detail: grants.length === 1 ? grants[0]!.clientName : `${grants.length} applications`, | |
| 39 | 45 | problem: null, | |
| ⋯ | |||
| 41 | 47 | }; | |
| 42 | 48 | } | |
| 43 | 49 | const workspaces = (user.workspaces ?? []).map((membership) => ({ slug: membership.slug, name: membership.name ?? membership.slug })); | |
| 44 | − | return { connected, workspaces }; | |
| 50 | + | return { connected, workspaces, unread }; | |
| 45 | 51 | } | |
| 46 | 52 | ||
| 47 | 53 | export default function PersonalIntegrations({ loaderData }: Route.ComponentProps) { | |
| 48 | − | const { connected, workspaces } = loaderData; | |
| 54 | + | const { connected, workspaces, unread } = loaderData; | |
| 49 | 55 | return ( | |
| 50 | 56 | <div> | |
| 57 | + | {unread.length > 0 && ( | |
| 58 | + | <div className="mb-4 space-y-1"> | |
| 59 | + | {unread.map((what) => ( | |
| 60 | + | <Unavailable key={what} what={what} compact /> | |
| 61 | + | ))} | |
| 62 | + | </div> | |
| 63 | + | )} | |
| 51 | 64 | <ConnectorDirectory | |
| 52 | 65 | scope="personal" | |
| 53 | 66 | views={connectorsFor("personal")} | |
| 21 | 21 | import { page } from "../../lib/meta"; | |
| 22 | 22 | import { identity, inbox } from "../../lib/services.server"; | |
| 23 | 23 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 24 | + | import { Unavailable } from "../../components/boundary"; | |
| 25 | + | import { isFailed, part } from "../../lib/partial"; | |
| 24 | 26 | // Live notifications: pop-ups and browser notifications (services/notify). | |
| 25 | 27 | import { LiveNotificationSettings } from "../../components/notifications/settings"; | |
| 26 | 28 | // Sounds, Do not disturb and desktop notifications (identity, notify). | |
| ⋯ | |||
| 32 | 34 | ||
| 33 | 35 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 34 | 36 | const user = requireUser(context, request); | |
| 37 | + | // Each section stands on its own read: one that could not be read says so | |
| 38 | + | // in its place, and its form is not offered over values not known. The | |
| 39 | + | // sounds save one change at a time, so without them the section starts | |
| 40 | + | // from what the page already has (the root's read) and loses nothing. | |
| 35 | 41 | const [settings, watched, sounds] = await Promise.all([ | |
| 36 | − | inbox.settings(user.username).catch(() => null), | |
| 37 | − | inbox.watched(user.username).catch(() => null), | |
| 42 | + | part(inbox.settings(user.username), "Notification settings"), | |
| 43 | + | part(inbox.watched(user.username), "Repositories you watch"), | |
| 38 | 44 | identity.soundSettings(user).catch(() => null), | |
| 39 | 45 | ]); | |
| 40 | 46 | return { settings, watched, sounds, workspaces: user.workspaces ?? [] }; | |
| ⋯ | |||
| 84 | 90 | >; | |
| 85 | 91 | ||
| 86 | 92 | export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 87 | − | const { settings, watched, sounds, workspaces } = loaderData; | |
| 93 | + | const { watched, sounds, workspaces } = loaderData; | |
| 94 | + | const settings = isFailed(loaderData.settings) ? null : loaderData.settings; | |
| 88 | 95 | const navigation = useNavigation(); | |
| 89 | 96 | const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null); | |
| 90 | − | if (!settings) { | |
| 91 | − | return <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">Your notification settings could not be loaded. Try again in a moment.</p>; | |
| 92 | − | } | |
| 93 | 97 | return ( | |
| 94 | 98 | <div className="space-y-10"> | |
| 95 | 99 | <SoundSettingsSection initial={sounds} /> | |
| ⋯ | |||
| 101 | 105 | <p className="mt-1 text-sm text-muted"> | |
| 102 | 106 | Besides your <Link to="/notifications" className="text-fg underline underline-offset-4">notifications</Link>, email me at my primary address when: | |
| 103 | 107 | </p> | |
| 104 | − | <Form method="post" className="mt-4 space-y-3"> | |
| 105 | − | <input type="hidden" name="intent" value="email" /> | |
| 106 | − | {EMAIL_REASONS.map((entry) => ( | |
| 107 | − | <CheckboxOption | |
| 108 | − | key={entry.reason} | |
| 109 | − | name="email" | |
| 110 | − | value={entry.reason} | |
| 111 | − | defaultChecked={settings.email.includes(entry.reason)} | |
| 112 | − | label={entry.label} | |
| 113 | − | description={entry.detail} | |
| 114 | − | /> | |
| 115 | − | ))} | |
| 116 | − | <div className="flex items-center gap-3 pt-2"> | |
| 117 | − | <SubmitButton variant="outline" match={{ intent: "email" }} pending="Saving…"> | |
| 118 | − | Save email settings | |
| 119 | − | </SubmitButton> | |
| 120 | − | {said("email")?.saved && <p className="text-xs text-success">{said("email")?.saved}</p>} | |
| 108 | + | {settings ? ( | |
| 109 | + | <Form method="post" className="mt-4 space-y-3"> | |
| 110 | + | <input type="hidden" name="intent" value="email" /> | |
| 111 | + | {EMAIL_REASONS.map((entry) => ( | |
| 112 | + | <CheckboxOption | |
| 113 | + | key={entry.reason} | |
| 114 | + | name="email" | |
| 115 | + | value={entry.reason} | |
| 116 | + | defaultChecked={settings.email.includes(entry.reason)} | |
| 117 | + | label={entry.label} | |
| 118 | + | description={entry.detail} | |
| 119 | + | /> | |
| 120 | + | ))} | |
| 121 | + | <div className="flex items-center gap-3 pt-2"> | |
| 122 | + | <SubmitButton variant="outline" match={{ intent: "email" }} pending="Saving…"> | |
| 123 | + | Save email settings | |
| 124 | + | </SubmitButton> | |
| 125 | + | {said("email")?.saved && <p className="text-xs text-success">{said("email")?.saved}</p>} | |
| 126 | + | </div> | |
| 127 | + | <ErrorText>{said("email")?.error}</ErrorText> | |
| 128 | + | </Form> | |
| 129 | + | ) : ( | |
| 130 | + | <div className="mt-4"> | |
| 131 | + | <Unavailable what="Your email settings" /> | |
| 121 | 132 | </div> | |
| 122 | − | <ErrorText>{said("email")?.error}</ErrorText> | |
| 123 | − | </Form> | |
| 133 | + | )} | |
| 124 | 134 | <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p> | |
| 125 | 135 | </section> | |
| 126 | 136 | ||
| ⋯ | |||
| 133 | 143 | and a vulnerability report reach you at once; the rest, as you choose here. The workspaces you own, or manage security for, tell you of their | |
| 134 | 144 | urgent alerts whatever you choose. | |
| 135 | 145 | </p> | |
| 136 | − | <Form method="post" className="mt-4 space-y-4"> | |
| 137 | − | <input type="hidden" name="intent" value="security" /> | |
| 138 | − | <SwitchCard name="security_alerts" title="Critical and high alerts at once" defaultChecked={settings.securityAlerts}> | |
| 139 | − | On repositories you watch for security alerts. Off, you hear of them only in the repositories you own or manage security for. | |
| 140 | − | </SwitchCard> | |
| 141 | − | <div> | |
| 142 | − | <p className="mb-2 text-sm font-medium">Medium and low alerts</p> | |
| 143 | − | <RadioGroup name="security_digest" defaultValue={settings.securityDigest}> | |
| 144 | − | {SECURITY_DIGEST_CHOICES.map((choice) => ( | |
| 145 | − | <RadioOption key={choice.cadence} value={choice.cadence} label={choice.label} description={choice.detail} /> | |
| 146 | − | ))} | |
| 147 | − | </RadioGroup> | |
| 148 | − | </div> | |
| 149 | − | <div className="flex items-center gap-3"> | |
| 150 | − | <SubmitButton variant="outline" match={{ intent: "security" }} pending="Saving…"> | |
| 151 | − | Save security settings | |
| 152 | − | </SubmitButton> | |
| 153 | − | {said("security")?.saved && <p className="text-xs text-success">{said("security")?.saved}</p>} | |
| 146 | + | {settings ? ( | |
| 147 | + | <Form method="post" className="mt-4 space-y-4"> | |
| 148 | + | <input type="hidden" name="intent" value="security" /> | |
| 149 | + | <SwitchCard name="security_alerts" title="Critical and high alerts at once" defaultChecked={settings.securityAlerts}> | |
| 150 | + | On repositories you watch for security alerts. Off, you hear of them only in the repositories you own or manage security for. | |
| 151 | + | </SwitchCard> | |
| 152 | + | <div> | |
| 153 | + | <p className="mb-2 text-sm font-medium">Medium and low alerts</p> | |
| 154 | + | <RadioGroup name="security_digest" defaultValue={settings.securityDigest}> | |
| 155 | + | {SECURITY_DIGEST_CHOICES.map((choice) => ( | |
| 156 | + | <RadioOption key={choice.cadence} value={choice.cadence} label={choice.label} description={choice.detail} /> | |
| 157 | + | ))} | |
| 158 | + | </RadioGroup> | |
| 159 | + | </div> | |
| 160 | + | <div className="flex items-center gap-3"> | |
| 161 | + | <SubmitButton variant="outline" match={{ intent: "security" }} pending="Saving…"> | |
| 162 | + | Save security settings | |
| 163 | + | </SubmitButton> | |
| 164 | + | {said("security")?.saved && <p className="text-xs text-success">{said("security")?.saved}</p>} | |
| 165 | + | </div> | |
| 166 | + | <ErrorText>{said("security")?.error}</ErrorText> | |
| 167 | + | </Form> | |
| 168 | + | ) : ( | |
| 169 | + | <div className="mt-4"> | |
| 170 | + | <Unavailable what="Your security notification settings" /> | |
| 154 | 171 | </div> | |
| 155 | − | <ErrorText>{said("security")?.error}</ErrorText> | |
| 156 | − | </Form> | |
| 172 | + | )} | |
| 157 | 173 | <p className="mt-3 text-xs text-faint">Email for security alerts follows the Email section above: on by default.</p> | |
| 158 | 174 | </section> | |
| 159 | 175 | ||
| ⋯ | |||
| 162 | 178 | Repositories you create | |
| 163 | 179 | </h2> | |
| 164 | 180 | <p className="mt-1 text-sm text-muted">How you watch a repository when you make it. Change any one from its Watch menu.</p> | |
| 165 | − | <Form method="post" className="mt-4"> | |
| 166 | − | <input type="hidden" name="intent" value="default_watch" /> | |
| 167 | − | <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}> | |
| 168 | − | {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => ( | |
| 169 | − | <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} /> | |
| 170 | − | ))} | |
| 171 | − | </RadioGroup> | |
| 172 | − | <div className="mt-4 flex items-center gap-3"> | |
| 173 | − | <SubmitButton variant="outline" match={{ intent: "default_watch" }} pending="Saving…"> | |
| 174 | − | Save | |
| 175 | − | </SubmitButton> | |
| 176 | − | {said("default_watch")?.saved && <p className="text-xs text-success">{said("default_watch")?.saved}</p>} | |
| 181 | + | {settings ? ( | |
| 182 | + | <Form method="post" className="mt-4"> | |
| 183 | + | <input type="hidden" name="intent" value="default_watch" /> | |
| 184 | + | <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}> | |
| 185 | + | {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => ( | |
| 186 | + | <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} /> | |
| 187 | + | ))} | |
| 188 | + | </RadioGroup> | |
| 189 | + | <div className="mt-4 flex items-center gap-3"> | |
| 190 | + | <SubmitButton variant="outline" match={{ intent: "default_watch" }} pending="Saving…"> | |
| 191 | + | Save | |
| 192 | + | </SubmitButton> | |
| 193 | + | {said("default_watch")?.saved && <p className="text-xs text-success">{said("default_watch")?.saved}</p>} | |
| 194 | + | </div> | |
| 195 | + | <ErrorText>{said("default_watch")?.error}</ErrorText> | |
| 196 | + | </Form> | |
| 197 | + | ) : ( | |
| 198 | + | <div className="mt-4"> | |
| 199 | + | <Unavailable what="How you watch what you create" /> | |
| 177 | 200 | </div> | |
| 178 | − | <ErrorText>{said("default_watch")?.error}</ErrorText> | |
| 179 | − | </Form> | |
| 201 | + | )} | |
| 180 | 202 | </section> | |
| 181 | 203 | ||
| 182 | 204 | <section aria-labelledby="watched-heading"> | |
| ⋯ | |||
| 184 | 206 | Repositories you watch | |
| 185 | 207 | </h2> | |
| 186 | 208 | <p className="mt-1 text-sm text-muted">Those you watch other than the default way, which is only what you take part in.</p> | |
| 187 | − | {watched == null ? ( | |
| 188 | − | <p className="mt-4 text-sm text-muted">This list could not be loaded. Try again in a moment.</p> | |
| 209 | + | {isFailed(watched) ? ( | |
| 210 | + | <div className="mt-4"> | |
| 211 | + | <Unavailable what="Repositories you watch" /> | |
| 212 | + | </div> | |
| 189 | 213 | ) : watched.length === 0 ? ( | |
| 190 | 214 | <Card asChild tone="plain" radius="lg" className="mt-4 border-dashed px-4 py-6 text-center text-sm text-muted"> | |
| 191 | 215 | <p> | |
| 9 | 9 | import { Badge } from "../../components/ui/badge"; | |
| 10 | 10 | import { Button } from "../../components/ui/button"; | |
| 11 | 11 | import { Card } from "../../components/ui/card"; | |
| 12 | + | import { Unavailable } from "../../components/boundary"; | |
| 12 | 13 | import { githubSignIn } from "../../lib/github.server"; | |
| 13 | 14 | import { page } from "../../lib/meta"; | |
| 14 | 15 | import { otpInputProps } from "../../lib/otp"; | |
| ⋯ | |||
| 89 | 90 | const { status, hasPassword } = loaderData; | |
| 90 | 91 | const setup = actionData?.setup; | |
| 91 | 92 | if (!status) { | |
| 92 | − | return <p className="text-sm text-muted">Two-factor authentication could not be loaded just now. Try again in a moment.</p>; | |
| 93 | + | return <Unavailable what="Two-factor authentication" />; | |
| 93 | 94 | } | |
| 94 | 95 | return ( | |
| 95 | 96 | <div className="space-y-6"> | |
| 41 | 41 | import { type ProfileTab, movedTab, profilePath, profileTab } from "../lib/tab-paths"; | |
| 42 | 42 | import { identity, projects, repos, work } from "../lib/services.server"; | |
| 43 | 43 | import { getViewer } from "../lib/session.server"; | |
| 44 | + | import { Unavailable } from "../components/boundary"; | |
| 45 | + | import { type Failed, isFailed, part } from "../lib/partial"; | |
| 44 | 46 | ||
| 45 | 47 | type Tab = ProfileTab; | |
| 46 | 48 | ||
| ⋯ | |||
| 106 | 108 | .then((person) => (person ? repos.publicNamespaces(person.id) : [])) | |
| 107 | 109 | .catch(() => [] as string[]) | |
| 108 | 110 | .then((publicIn) => identity.profileWorkspaces(username, viewer, publicIn)); | |
| 111 | + | // The person is what the page is about; each of these is one part of it, | |
| 112 | + | // and one that fails says so in its own place. | |
| 109 | 113 | // The card is drawn for no one in particular, so its version uses what | |
| 110 | 114 | // everyone sees; for a signed-out viewer that is these counts already. | |
| 111 | 115 | const [authored, shown, starred, year] = await Promise.all([ | |
| 112 | − | work.byAuthor(username, viewer, filter), | |
| 113 | − | workspaces, | |
| 116 | + | part(work.byAuthor(username, viewer, filter), "Activity"), | |
| 117 | + | part(workspaces, "Workspaces"), | |
| 114 | 118 | // What they starred that the viewer can see, on its own tab. | |
| 115 | − | tab === "stars" ? repos.starred(username, viewer).catch(() => [] as StarredRepo[]) : ([] as StarredRepo[]), | |
| 119 | + | tab === "stars" ? part(repos.starred(username, viewer), "Stars") : ([] as StarredRepo[]), | |
| 116 | 120 | // Their year, on the overview: counted only where the viewer can see. | |
| 117 | 121 | tab === "overview" | |
| 118 | − | ? work | |
| 119 | − | .contributions(username, viewer) | |
| 120 | − | .then((result) => (result.ok ? result.value : null)) | |
| 121 | − | .catch(() => null as Contributions | null) | |
| 122 | − | : null, | |
| 122 | + | ? part( | |
| 123 | + | work.contributions(username, viewer).then((result) => (result.ok ? result.value : null)), | |
| 124 | + | "Contributions", | |
| 125 | + | ) | |
| 126 | + | : (null as Contributions | null), | |
| 123 | 127 | ]); | |
| 124 | − | const activity = authored.ok ? authored.value : EMPTY; | |
| 125 | − | // A repository has no description: its project's is shown. | |
| 126 | − | const about = await projects.descriptions(starred.map(({ repo }) => repo.id)).catch(() => ({}) as Record<string, string>); | |
| 128 | + | const activity = !isFailed(authored) && authored.ok ? authored.value : EMPTY; | |
| 129 | + | // A repository has no description: its project's is shown, and without it none. | |
| 130 | + | const about = isFailed(starred) | |
| 131 | + | ? ({} as Record<string, string>) | |
| 132 | + | : await projects.descriptions(starred.map(({ repo }) => repo.id)).catch(() => ({}) as Record<string, string>); | |
| 127 | 133 | // The calendar ends today, as the work service counted it (UTC). | |
| 128 | − | const today = (year && lastDay(year.from)) || new Date().toISOString().slice(0, 10); | |
| 134 | + | const today = (year && !isFailed(year) && lastDay(year.from)) || new Date().toISOString().slice(0, 10); | |
| 129 | 135 | return { | |
| 130 | 136 | profile, | |
| 131 | 137 | tab, | |
| 132 | 138 | filter: { state: state ?? null, repo: repo ?? null, sort: sort ?? "created", before: before ?? null }, | |
| 133 | 139 | activity, | |
| 134 | − | failed: !authored.ok, | |
| 140 | + | failed: isFailed(authored) || !authored.ok, | |
| 135 | 141 | workspaces: shown, | |
| 136 | − | starred: starred.map((star) => ({ ...star, description: about[star.repo.id] ?? null })), | |
| 137 | − | contributions: year ? { ...year, today } : null, | |
| 142 | + | starred: isFailed(starred) ? starred : starred.map((star) => ({ ...star, description: about[star.repo.id] ?? null })), | |
| 143 | + | contributions: year && !isFailed(year) ? { ...year, today } : year, | |
| 138 | 144 | isSelf: viewer?.username === profile.username, | |
| 139 | 145 | publicCounts: viewer ? null : activity.counts, | |
| 140 | 146 | }; | |
| ⋯ | |||
| 176 | 182 | isSelf, | |
| 177 | 183 | }: { | |
| 178 | 184 | profile: Profile; | |
| 179 | − | workspaces: { slug: string; name: string; avatar: string | null }[]; | |
| 185 | + | workspaces: { slug: string; name: string; avatar: string | null }[] | Failed; | |
| 180 | 186 | isSelf: boolean; | |
| 181 | 187 | }) { | |
| 182 | 188 | return ( | |
| ⋯ | |||
| 239 | 245 | <Detail icon={<CalendarDays size={15} />}>Joined {joined(profile.createdAt)}</Detail> | |
| 240 | 246 | </ul> | |
| 241 | 247 | ||
| 242 | − | {workspaces.length > 0 && ( | |
| 248 | + | {isFailed(workspaces) ? ( | |
| 243 | 249 | <div className="mt-6 border-t border-line pt-5"> | |
| 244 | 250 | <h2 className="text-sm font-medium">Workspaces</h2> | |
| 251 | + | <div className="mt-3"> | |
| 252 | + | <Unavailable what="Workspaces" compact /> | |
| 253 | + | </div> | |
| 254 | + | </div> | |
| 255 | + | ) : workspaces.length > 0 && ( | |
| 256 | + | <div className="mt-6 border-t border-line pt-5"> | |
| 257 | + | <h2 className="text-sm font-medium">Workspaces</h2> | |
| 245 | 258 | <ul className="mt-3 space-y-1"> | |
| 246 | 259 | {workspaces.map((workspace) => ( | |
| 247 | 260 | <li key={workspace.slug}> | |
| ⋯ | |||
| 312 | 325 | ||
| 313 | 326 | // --- Stars ------------------------------------------------------------------------- | |
| 314 | 327 | ||
| 315 | − | function StarredList({ starred, isSelf }: { starred: (StarredRepo & { description: string | null })[]; isSelf: boolean }) { | |
| 328 | + | function StarredList({ starred, isSelf }: { starred: (StarredRepo & { description: string | null })[] | Failed; isSelf: boolean }) { | |
| 329 | + | if (isFailed(starred)) return <Unavailable what="Stars" />; | |
| 316 | 330 | if (starred.length === 0) { | |
| 317 | 331 | return ( | |
| 318 | 332 | <EmptyState title={isSelf ? "You have not starred anything yet" : "No stars yet"}> | |
| ⋯ | |||
| 355 | 369 | const who = profile.name ?? shownUsername(profile); | |
| 356 | 370 | return ( | |
| 357 | 371 | <div className="space-y-8"> | |
| 358 | − | {contributions && ( | |
| 372 | + | {isFailed(contributions) ? ( | |
| 373 | + | <Unavailable what="Contributions" /> | |
| 374 | + | ) : contributions && ( | |
| 359 | 375 | <ContributionCalendar | |
| 360 | 376 | days={contributions.days} | |
| 361 | 377 | total={contributions.total} | |
Comments
Sign in to comment.