a tool for shared writing and social publishing
1import { useState } from "react";
2import { ButtonPrimary } from "components/Buttons";
3
4import { useSmoker, useToaster } from "components/Toast";
5import { Input, InputWithLabel } from "components/Input";
6import useSWR from "swr";
7import { useIdentityData } from "components/IdentityProvider";
8import { addDomain } from "actions/domains/addDomain";
9import { callRPC } from "app/api/rpc/client";
10import { useLeafletDomains } from "components/PageSWRDataProvider";
11import { usePublishLink } from ".";
12import { addDomainPath } from "actions/domains/addDomainPath";
13import { useReplicache } from "src/replicache";
14import { deleteDomain } from "actions/domains/deleteDomain";
15import { AddTiny } from "components/Icons/AddTiny";
16
17type DomainMenuState =
18 | {
19 state: "default";
20 }
21 | {
22 state: "domain-settings";
23 domain: string;
24 }
25 | {
26 state: "add-domain";
27 }
28 | {
29 state: "has-domain";
30 domain: string;
31 };
32export function CustomDomainMenu(props: {
33 setShareMenuState: (s: "default") => void;
34}) {
35 let { data: domains } = useLeafletDomains();
36 let [state, setState] = useState<DomainMenuState>(
37 domains?.[0]
38 ? { state: "has-domain", domain: domains[0].domain }
39 : { state: "default" },
40 );
41 switch (state.state) {
42 case "has-domain":
43 case "default":
44 return (
45 <DomainOptions
46 setDomainMenuState={setState}
47 domainConnected={false}
48 setShareMenuState={props.setShareMenuState}
49 />
50 );
51 case "domain-settings":
52 return (
53 <DomainSettings domain={state.domain} setDomainMenuState={setState} />
54 );
55 case "add-domain":
56 return <AddDomain setDomainMenuState={setState} />;
57 }
58}
59
60export const DomainOptions = (props: {
61 setShareMenuState: (s: "default") => void;
62 setDomainMenuState: (state: DomainMenuState) => void;
63 domainConnected: boolean;
64}) => {
65 let { data: domains, mutate: mutateDomains } = useLeafletDomains();
66 let [selectedDomain, setSelectedDomain] = useState<string | undefined>(
67 domains?.[0]?.domain,
68 );
69 let [selectedRoute, setSelectedRoute] = useState(
70 domains?.[0]?.route.slice(1) || "",
71 );
72 let { identity } = useIdentityData();
73 let { permission_token } = useReplicache();
74
75 let toaster = useToaster();
76 let smoker = useSmoker();
77 let publishLink = usePublishLink();
78
79 return (
80 <div className="px-3 py-1 flex flex-col gap-3 max-w-full w-[600px]">
81 <h3 className="text-secondary">Choose a Domain</h3>
82 <div className="flex flex-col gap-1 text-secondary">
83 {identity?.custom_domains
84 .filter((d) => !d.publication_domains.length)
85 .map((domain) => {
86 return (
87 <DomainOption
88 selectedRoute={selectedRoute}
89 setSelectedRoute={setSelectedRoute}
90 key={domain.domain}
91 domain={domain.domain}
92 checked={selectedDomain === domain.domain}
93 setChecked={setSelectedDomain}
94 setDomainMenuState={props.setDomainMenuState}
95 />
96 );
97 })}
98 <button
99 onMouseDown={() => {
100 props.setDomainMenuState({ state: "add-domain" });
101 }}
102 className="text-accent-contrast flex gap-2 items-center px-1 py-0.5"
103 >
104 <AddTiny /> Add a New Domain
105 </button>
106 </div>
107
108 {/* ONLY SHOW IF A DOMAIN IS CURRENTLY CONNECTED */}
109 <div className="flex gap-3 items-center justify-end">
110 {props.domainConnected && (
111 <button
112 onMouseDown={() => {
113 props.setShareMenuState("default");
114 toaster({
115 content: (
116 <div className="font-bold">
117 Unpublished from custom domain!
118 </div>
119 ),
120 type: "error",
121 });
122 }}
123 >
124 Unpublish
125 </button>
126 )}
127
128 <ButtonPrimary
129 id="publish-to-domain"
130 disabled={
131 domains?.[0]
132 ? domains[0].domain === selectedDomain &&
133 domains[0].route.slice(1) === selectedRoute
134 : !selectedDomain
135 }
136 onClick={async () => {
137 // let rect = document
138 // .getElementById("publish-to-domain")
139 // ?.getBoundingClientRect();
140 // smoker({
141 // error: true,
142 // text: "url already in use!",
143 // position: {
144 // x: rect ? rect.left : 0,
145 // y: rect ? rect.top + 26 : 0,
146 // },
147 // });
148 if (!selectedDomain || !publishLink) return;
149 await addDomainPath({
150 domain: selectedDomain,
151 route: "/" + selectedRoute,
152 view_permission_token: publishLink,
153 edit_permission_token: permission_token.id,
154 });
155
156 toaster({
157 content: (
158 <div className="font-bold">
159 Published to custom domain!{" "}
160 <a
161 className="underline text-accent-2"
162 href={`https://${selectedDomain}/${selectedRoute}`}
163 target="_blank"
164 >
165 View
166 </a>
167 </div>
168 ),
169 type: "success",
170 });
171 mutateDomains();
172 props.setShareMenuState("default");
173 }}
174 >
175 Publish!
176 </ButtonPrimary>
177 </div>
178 </div>
179 );
180};
181
182const DomainOption = (props: {
183 selectedRoute: string;
184 setSelectedRoute: (s: string) => void;
185 checked: boolean;
186 setChecked: (checked: string) => void;
187 domain: string;
188 setDomainMenuState: (state: DomainMenuState) => void;
189}) => {
190 let [value, setValue] = useState("");
191 let { data } = useSWR(props.domain, async (domain) => {
192 return await callRPC("get_domain_status", { domain });
193 });
194 let pending = data?.config?.misconfigured || data?.error;
195 return (
196 <label htmlFor={props.domain}>
197 <input
198 type="radio"
199 name={props.domain}
200 id={props.domain}
201 value={props.domain}
202 checked={props.checked}
203 className="hidden appearance-none"
204 onChange={() => {
205 if (pending) return;
206 props.setChecked(props.domain);
207 }}
208 />
209 <div
210 className={`
211 px-[6px] py-1
212 flex
213 border rounded-md
214 ${
215 pending
216 ? "border-border-light text-secondary justify-between gap-2 items-center "
217 : !props.checked
218 ? "flex-wrap border-border-light"
219 : "flex-wrap border-accent-1 bg-accent-1 text-accent-2 font-bold"
220 } `}
221 >
222 <div className={`w-max truncate ${pending && "animate-pulse"}`}>
223 {props.domain}
224 </div>
225 {props.checked && (
226 <div className="flex gap-0 w-full">
227 <span
228 className="font-normal"
229 style={value === "" ? { opacity: "0.5" } : {}}
230 >
231 /
232 </span>
233
234 <Input
235 type="text"
236 autoFocus
237 className="appearance-none focus:outline-hidden font-normal text-accent-2 w-full bg-transparent placeholder:text-accent-2 placeholder:opacity-50"
238 placeholder="add-optional-path"
239 onChange={(e) => props.setSelectedRoute(e.target.value)}
240 value={props.selectedRoute}
241 />
242 </div>
243 )}
244 {pending && (
245 <button
246 className="text-accent-contrast text-sm"
247 onMouseDown={() => {
248 props.setDomainMenuState({
249 state: "domain-settings",
250 domain: props.domain,
251 });
252 }}
253 >
254 pending
255 </button>
256 )}
257 </div>
258 </label>
259 );
260};
261
262export const AddDomain = (props: {
263 setDomainMenuState: (state: DomainMenuState) => void;
264}) => {
265 let [value, setValue] = useState("");
266 let { mutate } = useIdentityData();
267 let smoker = useSmoker();
268 return (
269 <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]">
270 <div>
271 <h3 className="text-secondary">Add a New Domain</h3>
272 <div className="text-xs italic text-secondary">
273 Don't include the protocol or path, just the base domain name for now
274 </div>
275 </div>
276
277 <Input
278 className="input-with-border text-primary"
279 placeholder="www.example.com"
280 value={value}
281 onChange={(e) => setValue(e.target.value)}
282 />
283
284 <ButtonPrimary
285 disabled={!value}
286 className="place-self-end mt-2"
287 onMouseDown={async (e) => {
288 // call the vercel api, set the thing...
289 let { error } = await addDomain(value);
290 if (error) {
291 smoker({
292 error: true,
293 text:
294 error === "invalid_domain"
295 ? "Invalid domain! Use just the base domain"
296 : error === "domain_already_in_use"
297 ? "That domain is already in use!"
298 : "An unknown error occured",
299 position: {
300 y: e.clientY,
301 x: e.clientX - 5,
302 },
303 });
304 return;
305 }
306 mutate();
307 props.setDomainMenuState({ state: "domain-settings", domain: value });
308 }}
309 >
310 Verify Domain
311 </ButtonPrimary>
312 </div>
313 );
314};
315
316const DomainSettings = (props: {
317 domain: string;
318 setDomainMenuState: (s: DomainMenuState) => void;
319}) => {
320 let isSubdomain = props.domain.split(".").length > 2;
321 return (
322 <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]">
323 <h3 className="text-secondary">Verify Domain</h3>
324
325 <div className="text-secondary text-sm flex flex-col gap-3">
326 <div className="flex flex-col gap-[6px]">
327 <div>
328 To verify this domain, add the following record to your DNS provider
329 for <strong>{props.domain}</strong>.
330 </div>
331
332 {isSubdomain ? (
333 <div className="flex gap-3 p-1 border border-border-light rounded-md py-1">
334 <div className="flex flex-col ">
335 <div className="text-tertiary">Type</div>
336 <div>CNAME</div>
337 </div>
338 <div className="flex flex-col">
339 <div className="text-tertiary">Name</div>
340 <div style={{ wordBreak: "break-word" }}>
341 {props.domain.split(".").slice(0, -2).join(".")}
342 </div>
343 </div>
344 <div className="flex flex-col">
345 <div className="text-tertiary">Value</div>
346 <div style={{ wordBreak: "break-word" }}>
347 cname.vercel-dns.com
348 </div>
349 </div>
350 </div>
351 ) : (
352 <div className="flex gap-3 p-1 border border-border-light rounded-md py-1">
353 <div className="flex flex-col ">
354 <div className="text-tertiary">Type</div>
355 <div>A</div>
356 </div>
357 <div className="flex flex-col">
358 <div className="text-tertiary">Name</div>
359 <div>@</div>
360 </div>
361 <div className="flex flex-col">
362 <div className="text-tertiary">Value</div>
363 <div>76.76.21.21</div>
364 </div>
365 </div>
366 )}
367 </div>
368 <div>
369 Once you do this, the status may be pending for up to a few hours.
370 </div>
371 <div>Check back later to see if verification was successful.</div>
372 </div>
373
374 <div className="flex gap-3 justify-between items-center mt-2">
375 <button
376 className="text-accent-contrast font-bold "
377 onMouseDown={async () => {
378 await deleteDomain({ domain: props.domain });
379 props.setDomainMenuState({ state: "default" });
380 }}
381 >
382 Delete Domain
383 </button>
384 <ButtonPrimary
385 onMouseDown={() => {
386 props.setDomainMenuState({ state: "default" });
387 }}
388 >
389 Back to Domains
390 </ButtonPrimary>
391 </div>
392 </div>
393 );
394};