Skip to content
159 linesCodeBlameRaw
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 extra,
39}: {
40 intent: string;
41 /** Other hidden fields to post with it. */
42 fields?: Record<string, string>;
43 title: ReactNode;
44 description?: ReactNode;
45 /** What happens, as list items. */
46 children?: ReactNode;
47 /** The name to type, such as `acme/web`; nothing to type when absent. */
48 confirm?: string;
49 submit: string;
50 /** The button's words while it is posting. */
51 busy: string;
52 error?: string | null;
53 danger?: boolean;
54 /** Where to post, when not the page's own action. */
55 action?: string;
56 trigger: (open: () => void) => ReactNode;
57 /** More fields to post, under the list: a choice that goes with it. */
58 extra?: ReactNode;
59}) {
60 const [open, setOpen] = useState(Boolean(error));
61 const [typed, setTyped] = useState("");
62 const id = useId();
63 const posting = usePending({ intent, ...fields });
64 // Done without an error, it closes: the page behind it now shows the change.
65 const wasPosting = useRef(false);
66 useEffect(() => {
67 if (wasPosting.current && !posting && !error) {
68 setOpen(false);
69 setTyped("");
70 }
71 wasPosting.current = posting;
72 }, [posting, error]);
73 const ready = confirm == null || confirmsName(typed, confirm);
74 return (
75 <>
76 {trigger(() => setOpen(true))}
77 <AlertDialog
78 open={open}
79 onOpenChange={(next) => {
80 setOpen(next);
81 setTyped("");
82 }}
83 >
84 <AlertDialogContent>
85 <Form method="post" action={action} className="grid gap-4">
86 <input type="hidden" name="intent" value={intent} />
87 {Object.entries(fields ?? {}).map(([name, value]) => (
88 <input key={name} type="hidden" name={name} value={value} />
89 ))}
90 <AlertDialogHeader>
91 <AlertDialogTitle>{title}</AlertDialogTitle>
92 {description && <AlertDialogDescription>{description}</AlertDialogDescription>}
93 </AlertDialogHeader>
94 {children && <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">{children}</ul>}
95 {extra}
96 {confirm != null && (
97 <FormField>
98 <FieldLabel htmlFor={`${id}-confirm`}>
99 Type <span className="font-mono break-all text-fg">{confirm}</span> to confirm
100 </FieldLabel>
101 <TextInput
102 id={`${id}-confirm`}
103 name="confirm"
104 value={typed}
105 spellCheck={false}
106 autoCapitalize="off"
107 autoComplete="off"
108 onChange={(event) => setTyped(event.target.value)}
109 className="font-mono"
110 />
111 </FormField>
112 )}
113 <ErrorText>{error}</ErrorText>
114 <AlertDialogFooter>
115 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
116 <SubmitButton variant={danger ? "danger" : "primary"} disabled={!ready} match={{ intent, ...fields }} pending={busy}>
117 {submit}
118 </SubmitButton>
119 </AlertDialogFooter>
120 </Form>
121 </AlertDialogContent>
122 </AlertDialog>
123 </>
124 );
125}
126
127/**
128 * Said across the top of an archived repository's pages: what archived
129 * means, and for owners, where to undo it. On its settings pages it says
130 * why the settings are turned off.
131 */
132export function ArchivedBanner({ base, owner, settings }: { base: string; owner: boolean; settings?: boolean }) {
133 return (
134 <div
135 role="status"
136 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"
137 >
138 <p className="flex gap-2.5">
139 <Archive size={16} className="mt-0.5 shrink-0 text-warn" />
140 <span>
141 <span className="font-medium text-fg">This repository is archived.</span>{" "}
142 <span className="text-muted">
143 {settings
144 ? "It is read-only, so settings that change it are turned off until it is unarchived."
145 : "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."}
146 </span>
147 </span>
148 </p>
149 {owner && (
150 <Link
151 to={`${base}/settings/repository#danger-zone`}
152 className="shrink-0 pl-6.5 font-medium text-warn hover:underline sm:pl-0"
153 >
154 Unarchive
155 </Link>
156 )}
157 </div>
158 );
159}