| 1 | import { Box, Code2, GitBranch, Image as ImageIcon, Link2, Rocket } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, Link, redirect } from "react-router"; |
| 4 | |
| 5 | import { DEFAULT_MEMBER_PRIVILEGES } from "@g1t/contracts"; |
| 6 | |
| 7 | import type { Route } from "./+types/settings"; |
| 8 | import { LIFECYCLE_INTENTS, type LifecycleIntent, ProjectDangerZone } from "../../components/project-lifecycle"; |
| 9 | import { confirmsName } from "../../lib/repo-lifecycle"; |
| 10 | import { page } from "../../lib/meta"; |
| 11 | import { useAddresses } from "../../lib/addresses"; |
| 12 | import { productionImageUrl, readProductionImageUpload } from "../../lib/production-image"; |
| 13 | import { KindFields, LinkFields } from "../../components/project-about"; |
| 14 | import { ProductionImageField } from "../../components/production-image-field"; |
| 15 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; |
| 16 | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; |
| 17 | import { Card } from "../../components/ui/card"; |
| 18 | import { KIND_CHOICE_SETS, type KindChoice, choiceOf, linksFromForm, neverDeploys } from "../../lib/project-kind"; |
| 19 | import { deployments, identity, projects, repos } from "../../lib/services.server"; |
| 20 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 21 | import { refusal, requireCapability, requireInsider } from "../../lib/access.server"; |
| 22 | |
| 23 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 24 | return page(args, { title: `Settings · ${params.owner}/${params.repo} · g1t` }); |
| 25 | } |
| 26 | |
| 27 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 28 | const viewer = getViewer(context); |
| 29 | // Maintain and up; to anyone without a role here the page does not exist. |
| 30 | const { repo, access } = await requireInsider(context, params, "manage_settings"); |
| 31 | const [project, deploys] = await Promise.all([ |
| 32 | projects.get(params.owner, params.repo, viewer), |
| 33 | // Whether Deployments are on: needs Admin, so unknown (null) below it. |
| 34 | deployments.settings({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null), |
| 35 | ]); |
| 36 | const found = unwrap(project); |
| 37 | // Who made it is kept as an id: named by identity, or left out if it cannot say. |
| 38 | const names = await identity.usernames([found.createdBy]).catch(() => ({}) as Record<string, string>); |
| 39 | return { |
| 40 | project: found, |
| 41 | deploymentsOn: deploys?.ok ? deploys.value.enabled : null, |
| 42 | mine: viewer?.id === found.createdBy, |
| 43 | creator: names[found.createdBy] ?? null, |
| 44 | // Archiving is for Admins; transferring and deleting, for owners of the |
| 45 | // workspace unless its member privileges let its members with Admin. |
| 46 | lifecycle: access.can.administer |
| 47 | ? { archived: Boolean(repo.archivedAt), remove: access.can.delete, destinations: destinations(viewer, params.owner, repo.isPrivate, access.can.delete) } |
| 48 | : null, |
| 49 | }; |
| 50 | } |
| 51 | |
| 52 | /** Where it can be transferred: a workspace where the person can create a repository like it. */ |
| 53 | function destinations(viewer: ReturnType<typeof getViewer>, from: string, isPrivate: boolean, allowed: boolean): { slug: string; name: string }[] { |
| 54 | if (!allowed) return []; |
| 55 | return (viewer?.workspaces ?? []) |
| 56 | .filter((m) => m.slug !== from.toLowerCase()) |
| 57 | .filter((m) => { |
| 58 | if (m.role === "owner") return true; |
| 59 | const privileges = { ...DEFAULT_MEMBER_PRIVILEGES, ...(m.privileges ?? {}) }; |
| 60 | return isPrivate ? privileges.members_can_create_private_repositories : privileges.members_can_create_public_repositories; |
| 61 | }) |
| 62 | .map((m) => ({ slug: m.slug, name: m.name ?? m.slug })); |
| 63 | } |
| 64 | |
| 65 | /** Archiving, transferring and deleting: the repository goes with the project. */ |
| 66 | async function lifecycle(intent: LifecycleIntent, form: FormData, request: Request, params: Route.ActionArgs["params"], context: Route.ActionArgs["context"]) { |
| 67 | const user = requireUser(context, request); |
| 68 | const refused = await refusal(context, params, intent === "archive" ? "administer" : "delete"); |
| 69 | if (refused) return { lifecycle: intent, error: refused }; |
| 70 | const path = { namespace: params.owner, name: params.repo }; |
| 71 | const full = `${params.owner}/${params.repo}`; |
| 72 | const typed = String(form.get("confirm") ?? "").trim(); |
| 73 | const failed = (result: { ok: false; error: { message: string } }) => ({ lifecycle: intent, error: result.error.message }); |
| 74 | switch (intent) { |
| 75 | case "archive": { |
| 76 | const done = await repos.archive(user, path, form.get("archived") === "true"); |
| 77 | return done.ok ? { lifecycle: intent, error: null } : failed(done); |
| 78 | } |
| 79 | case "transfer": { |
| 80 | // repos checks both workspaces' owners, the name and storage, and |
| 81 | // keeps the old address as a redirect. |
| 82 | if (!confirmsName(typed, full)) return { lifecycle: intent, error: `Type ${full} to confirm.` }; |
| 83 | const moved = await repos.transfer( |
| 84 | user, |
| 85 | path, |
| 86 | String(form.get("to") ?? "") |
| 87 | .trim() |
| 88 | .toLowerCase(), |
| 89 | ); |
| 90 | if (!moved.ok) return failed(moved); |
| 91 | throw redirect(`/${moved.value.namespace}/${moved.value.name}/settings`); |
| 92 | } |
| 93 | case "delete": { |
| 94 | if (!confirmsName(typed, full)) return { lifecycle: intent, error: `Type ${full} to confirm.` }; |
| 95 | const deleted = await repos.delete(user, path, typed); |
| 96 | if (!deleted.ok) return failed(deleted); |
| 97 | throw redirect(`/${params.owner}/-/repositories?deleted=${encodeURIComponent(deleted.value.name)}`); |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 103 | assertSameOrigin(request); |
| 104 | const user = requireUser(context, request); |
| 105 | const form = await request.formData(); |
| 106 | const intent = form.get("intent"); |
| 107 | if (LIFECYCLE_INTENTS.includes(intent as LifecycleIntent)) { |
| 108 | return lifecycle(intent as LifecycleIntent, form, request, params, context); |
| 109 | } |
| 110 | await requireCapability(context, params, "manage_settings"); |
| 111 | // Production's picture on the overview: whoever may change its address may change it. |
| 112 | if (intent === "production-image" || intent === "remove-production-image" || intent === "prefer-production-image") { |
| 113 | let change: { image?: string | null; preferred?: boolean }; |
| 114 | if (intent === "production-image") { |
| 115 | const upload = await readProductionImageUpload(form); |
| 116 | if ("error" in upload) return { error: upload.error }; |
| 117 | change = { image: upload.image }; |
| 118 | } else { |
| 119 | change = intent === "remove-production-image" ? { image: null } : { preferred: form.get("preferred") === "on" }; |
| 120 | } |
| 121 | const saved = await projects.setProductionImage(user, params.owner, params.repo, change); |
| 122 | return saved.ok ? { imageSaved: true as const } : { error: saved.error.message }; |
| 123 | } |
| 124 | const choice = form.get("choice") as KindChoice | null; |
| 125 | const set = choice && choice in KIND_CHOICE_SETS ? KIND_CHOICE_SETS[choice] : null; |
| 126 | const saved = await projects.update(user, params.owner, params.repo, { |
| 127 | name: String(form.get("name") ?? ""), |
| 128 | // Blank clears it. |
| 129 | description: String(form.get("description") ?? ""), |
| 130 | rootDir: String(form.get("rootDir") ?? ""), |
| 131 | ...(set ?? {}), |
| 132 | // Production's address is asked for beside Deploys itself, and kept otherwise. |
| 133 | ...(form.has("productionUrl") ? { productionUrl: String(form.get("productionUrl")) } : {}), |
| 134 | homepage: String(form.get("homepage") ?? ""), |
| 135 | docsUrl: String(form.get("docsUrl") ?? ""), |
| 136 | links: linksFromForm(form), |
| 137 | }); |
| 138 | return saved.ok ? { saved: true as const } : { error: saved.error.message }; |
| 139 | } |
| 140 | |
| 141 | export default function ProjectSettings({ loaderData, actionData, params }: Route.ComponentProps) { |
| 142 | const { project, deploymentsOn, mine, creator, lifecycle } = loaderData; |
| 143 | const [choice, setChoice] = useState<KindChoice | null>(choiceOf(project.setting)); |
| 144 | // Something that never deploys, while Deployments are on, is refused: they are turned off first. |
| 145 | const stops = choice === "library" || choice === "tool" || choice === "other"; |
| 146 | const blocked = stops && !neverDeploys(project) && deploymentsOn === true; |
| 147 | const base = `/${params.owner}/${params.repo}`; |
| 148 | const source = project.source.kind === "hosted" ? project.source : null; |
| 149 | const { usercontent } = useAddresses(); |
| 150 | return ( |
| 151 | <div className="max-w-4xl"> |
| 152 | <RepoSettingsHeading base={base} /> |
| 153 | <Form method="post" className="space-y-8"> |
| 154 | {/* Enter in a field saves: the form's first submit button is the one |
| 155 | Enter presses, and otherwise it would be the description reset. */} |
| 156 | <button type="submit" name="intent" value="save" tabIndex={-1} aria-hidden="true" className="absolute size-0 overflow-hidden opacity-0" /> |
| 157 | <section className="space-y-4"> |
| 158 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 159 | <Box size={15} className="text-accent" /> |
| 160 | Project |
| 161 | </h2> |
| 162 | <div className="grid gap-4 md:grid-cols-2"> |
| 163 | <Field label="Name" hint={`Its address stays g1t.sh/${project.workspace}/${project.slug}.`}> |
| 164 | <Input name="name" defaultValue={project.name} required maxLength={100} /> |
| 165 | </Field> |
| 166 | <Field label="Description" hint="What it is, in a line. Search, link previews and lists of repositories show it."> |
| 167 | <Input |
| 168 | key={project.description ?? ""} |
| 169 | name="description" |
| 170 | defaultValue={project.description ?? ""} |
| 171 | maxLength={200} |
| 172 | placeholder="What it is, in a line" |
| 173 | /> |
| 174 | </Field> |
| 175 | </div> |
| 176 | </section> |
| 177 | |
| 178 | <section className="space-y-4"> |
| 179 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 180 | <Code2 size={15} className="text-accent" /> |
| 181 | Source |
| 182 | </h2> |
| 183 | {source && ( |
| 184 | <Card className="p-4 text-sm"> |
| 185 | <div className="flex flex-wrap items-center justify-between gap-3"> |
| 186 | <div> |
| 187 | <Link to={`${base}/code`} className="font-mono hover:text-accent"> |
| 188 | {source.repo.namespace}/{source.repo.name} |
| 189 | </Link> |
| 190 | <p className="mt-0.5 text-xs text-muted"> |
| 191 | Hosted on g1t · <GitBranch size={11} className="inline" /> {source.defaultBranch} |
| 192 | </p> |
| 193 | </div> |
| 194 | <Link to={`${base}/settings/repository`} className="text-xs text-muted hover:text-fg"> |
| 195 | Repository settings |
| 196 | </Link> |
| 197 | </div> |
| 198 | </Card> |
| 199 | )} |
| 200 | <Field label="Root directory" hint="Where in the repository this project lives, such as apps/web. Builds run there. Empty for the whole repository."> |
| 201 | <Input name="rootDir" defaultValue={source?.rootDir ?? ""} placeholder="apps/web" className="font-mono" /> |
| 202 | </Field> |
| 203 | </section> |
| 204 | |
| 205 | <section className="scroll-mt-20 space-y-4" id="kind"> |
| 206 | {/* Old links to #deploys land here too. */} |
| 207 | <span id="deploys" /> |
| 208 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 209 | <Rocket size={15} className="text-accent" /> |
| 210 | What it is |
| 211 | </h2> |
| 212 | <p className="max-w-2xl text-xs text-muted"> |
| 213 | Its overview follows: production for what is deployed, packages and releases for a library or a tool, and the steps that apply to it. Only an app or |
| 214 | site hosted on g1t.page is asked to turn on Deployments. |
| 215 | </p> |
| 216 | <KindFields project={project} choice={choice} onChoice={setChoice} /> |
| 217 | {blocked && ( |
| 218 | <p className="rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft"> |
| 219 | Deployments are on for {project.name}. Turn them off in{" "} |
| 220 | <Link to={`${base}/settings/deployments`} className="text-fg underline underline-offset-4"> |
| 221 | Deployments settings |
| 222 | </Link>{" "} |
| 223 | first, then choose this here. |
| 224 | </p> |
| 225 | )} |
| 226 | </section> |
| 227 | |
| 228 | <section className="scroll-mt-20 space-y-4" id="links"> |
| 229 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 230 | <Link2 size={15} className="text-accent" /> |
| 231 | Links |
| 232 | </h2> |
| 233 | <p className="max-w-2xl text-xs text-muted"> |
| 234 | Shown on its overview, its card, and its workspace's Projects. Each is an http or https address; https:// is added when you leave it out. |
| 235 | </p> |
| 236 | <LinkFields links={project.links} /> |
| 237 | </section> |
| 238 | |
| 239 | <div className="flex items-center gap-3"> |
| 240 | <SubmitButton name="intent" value="save" pending="Saving…" disabled={blocked}> |
| 241 | Save |
| 242 | </SubmitButton> |
| 243 | {actionData && "saved" in actionData && <span className="text-sm text-success">Saved.</span>} |
| 244 | <span className="ml-auto text-xs text-faint"> |
| 245 | Created{" "} |
| 246 | {mine ? ( |
| 247 | "by you " |
| 248 | ) : creator ? ( |
| 249 | <> |
| 250 | by{" "} |
| 251 | <Link to={`/${creator}`} className="text-muted hover:text-fg"> |
| 252 | {creator} |
| 253 | </Link>{" "} |
| 254 | </> |
| 255 | ) : null} |
| 256 | <TimeAgo at={project.createdAt} /> |
| 257 | </span> |
| 258 | </div> |
| 259 | <ErrorText>{actionData && "error" in actionData && !("lifecycle" in actionData) ? actionData.error : null}</ErrorText> |
| 260 | </Form> |
| 261 | |
| 262 | {/* Its own forms, sent as soon as an image is picked: outside the settings form. */} |
| 263 | <section className="mt-10 scroll-mt-20 space-y-4" id="production-image"> |
| 264 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 265 | <ImageIcon size={15} className="text-accent" /> |
| 266 | Production image |
| 267 | </h2> |
| 268 | <p className="max-w-2xl text-xs text-muted"> |
| 269 | The picture on its overview's production card. A screenshot is taken by itself for an app hosted on g1t.page, and of production's address for one that |
| 270 | deploys itself, about once a day and after each production deployment reported to g1t. |
| 271 | </p> |
| 272 | <ProductionImageField |
| 273 | image={project.productionImage} |
| 274 | src={project.productionImage ? productionImageUrl(project.productionImage.file, usercontent) : null} |
| 275 | screenshots={project.runs === "g1t" || (project.runs === "elsewhere" && !!project.productionUrl)} |
| 276 | /> |
| 277 | </section> |
| 278 | |
| 279 | {lifecycle && ( |
| 280 | <ProjectDangerZone |
| 281 | full={`${params.owner}/${params.repo}`} |
| 282 | archived={lifecycle.archived} |
| 283 | remove={lifecycle.remove} |
| 284 | destinations={lifecycle.destinations} |
| 285 | error={(intent) => (actionData && "lifecycle" in actionData && actionData.lifecycle === intent ? actionData.error : null)} |
| 286 | /> |
| 287 | )} |
| 288 | </div> |
| 289 | ); |
| 290 | } |