g1t/apps/web/app/components/repo-lifecycle.tsx

155 lines5,384 bytesCodeBlame
1import { Archive } from "lucide-react";
2import { type ReactNode, useEffect, useId, useRef, useState } from "react";
3import { Form, Link } from "react-router";
4
5import { ErrorText, SubmitButton, usePending } from "./ui";
6import {
7 AlertDialog,
8 AlertDialogCancel,
9 AlertDialogContent,
10 AlertDialogDescription,
11 AlertDialogFooter,
12 AlertDialogHeader,
13 AlertDialogTitle,
14} from "./ui/alert-dialog";
15import { FieldLabel, Field as FormField } from "./ui/field";
16import { Input as TextInput } from "./ui/input";
17import { confirmsName } from "../lib/repo-lifecycle";
18
19/**
20 * A dialog that posts one `intent`, says what will happen as a list, and,
21 * when `confirm` is given, asks for that name to be typed first. It opens
22 * from `trigger`, and opens by itself again when the action sent back an
23 * error, so the error is seen beside what caused it.
24 */
25export function ConfirmDialog({
26 intent,
27 fields,
28 title,
29 description,
30 children,
31 confirm,
32 submit,
33 busy,
34 error,
35 danger = true,
36 action,
37 trigger,
38}: {
39 intent: string;
40 /** Other hidden fields to post with it. */
41 fields?: Record<string, string>;
42 title: ReactNode;
43 description?: ReactNode;
44 /** What happens, as list items. */
45 children?: ReactNode;
46 /** The name to type, such as `acme/web`; nothing to type when absent. */
47 confirm?: string;
48 submit: string;
49 /** The button's words while it is posting. */
50 busy: string;
51 error?: string | null;
52 danger?: boolean;
53 /** Where to post, when not the page's own action. */
54 action?: string;
55 trigger: (open: () => void) => ReactNode;
56}) {
57 const [open, setOpen] = useState(Boolean(error));
58 const [typed, setTyped] = useState("");
59 const id = useId();
60 const posting = usePending({ intent, ...fields });
61 // Done without an error, it closes: the page behind it now shows the change.
62 const wasPosting = useRef(false);
63 useEffect(() => {
64 if (wasPosting.current && !posting && !error) {
65 setOpen(false);
66 setTyped("");
67 }
68 wasPosting.current = posting;
69 }, [posting, error]);
70 const ready = confirm == null || confirmsName(typed, confirm);
71 return (
72 <>
73 {trigger(() => setOpen(true))}
74 <AlertDialog
75 open={open}
76 onOpenChange={(next) => {
77 setOpen(next);
78 setTyped("");
79 }}
80 >
81 <AlertDialogContent>
82 <Form method="post" action={action} className="grid gap-4">
83 <input type="hidden" name="intent" value={intent} />
84 {Object.entries(fields ?? {}).map(([name, value]) => (
85 <input key={name} type="hidden" name={name} value={value} />
86 ))}
87 <AlertDialogHeader>
88 <AlertDialogTitle>{title}</AlertDialogTitle>
89 {description && <AlertDialogDescription>{description}</AlertDialogDescription>}
90 </AlertDialogHeader>
91 {children && <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">{children}</ul>}
92 {confirm != null && (
93 <FormField>
94 <FieldLabel htmlFor={`${id}-confirm`}>
95 Type <span className="font-mono break-all text-fg">{confirm}</span> to confirm
96 </FieldLabel>
97 <TextInput
98 id={`${id}-confirm`}
99 name="confirm"
100 value={typed}
101 spellCheck={false}
102 autoCapitalize="off"
103 autoComplete="off"
104 onChange={(event) => setTyped(event.target.value)}
105 className="font-mono"
106 />
107 </FormField>
108 )}
109 <ErrorText>{error}</ErrorText>
110 <AlertDialogFooter>
111 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
112 <SubmitButton variant={danger ? "danger" : "primary"} disabled={!ready} match={{ intent, ...fields }} pending={busy}>
113 {submit}
114 </SubmitButton>
115 </AlertDialogFooter>
116 </Form>
117 </AlertDialogContent>
118 </AlertDialog>
119 </>
120 );
121}
122
123/**
124 * Said across the top of an archived repository's pages: what archived
125 * means, and for owners, where to undo it. On its settings pages it says
126 * why the settings are turned off.
127 */
128export function ArchivedBanner({ base, owner, settings }: { base: string; owner: boolean; settings?: boolean }) {
129 return (
130 <div
131 role="status"
132 className="flex flex-col gap-2 rounded-lg border border-warn/40 bg-warn/5 px-4 py-3 text-sm sm:flex-row sm:items-start sm:justify-between"
133 >
134 <p className="flex gap-2.5">
135 <Archive size={16} className="mt-0.5 shrink-0 text-warn" />
136 <span>
137 <span className="font-medium text-fg">This repository is archived.</span>{" "}
138 <span className="text-muted">
139 {settings
140 ? "It is read-only, so settings that change it are turned off until it is unarchived."
141 : "It is read-only: pushes and merges are refused, issues and pull requests are locked, and agents and workflows do not run. Its deployments keep serving."}
142 </span>
143 </span>
144 </p>
145 {owner && (
146 <Link
147 to={`${base}/settings/repository#danger-zone`}
148 className="shrink-0 pl-6.5 font-medium text-warn hover:underline sm:pl-0"
149 >
150 Unarchive
151 </Link>
152 )}
153 </div>
154 );
155}