a tool for shared writing and social publishing
1"use client";
2import { callRPC } from "app/api/rpc/client";
3import { ButtonPrimary } from "components/Buttons";
4import { Input } from "components/Input";
5import React, { useState, useRef, useEffect } from "react";
6import {
7 updatePublication,
8 updatePublicationBasePath,
9} from "./updatePublication";
10import {
11 usePublicationData,
12 useNormalizedPublicationRecord,
13} from "../[did]/[publication]/dashboard/PublicationSWRProvider";
14import useSWR, { mutate } from "swr";
15import { AddTiny } from "components/Icons/AddTiny";
16import { DotLoader } from "components/utils/DotLoader";
17import { useSmoker, useToaster } from "components/Toast";
18import { addPublicationDomain } from "actions/domains/addDomain";
19import { LoadingTiny } from "components/Icons/LoadingTiny";
20import { PinTiny } from "components/Icons/PinTiny";
21import { Verification } from "@vercel/sdk/esm/models/getprojectdomainop";
22import Link from "next/link";
23import { Checkbox } from "components/Checkbox";
24import type { GetDomainConfigResponseBody } from "@vercel/sdk/esm/models/getdomainconfigop";
25import { PubSettingsHeader } from "../[did]/[publication]/dashboard/settings/PublicationSettings";
26import { Toggle } from "components/Toggle";
27
28export const EditPubForm = (props: {
29 backToMenuAction: () => void;
30 loading: boolean;
31 setLoadingAction: (l: boolean) => void;
32}) => {
33 let { data } = usePublicationData();
34 let { publication: pubData } = data || {};
35 let record = useNormalizedPublicationRecord();
36 let [formState, setFormState] = useState<"normal" | "loading">("normal");
37
38 let [nameValue, setNameValue] = useState(record?.name || "");
39 let [showInDiscover, setShowInDiscover] = useState(
40 record?.preferences?.showInDiscover === undefined
41 ? true
42 : record.preferences.showInDiscover,
43 );
44 let [showComments, setShowComments] = useState(
45 record?.preferences?.showComments === undefined
46 ? true
47 : record.preferences.showComments,
48 );
49 let showMentions =
50 record?.preferences?.showMentions === undefined
51 ? true
52 : record.preferences.showMentions;
53 let showPrevNext =
54 record?.preferences?.showPrevNext === undefined
55 ? true
56 : record.preferences.showPrevNext;
57
58 let [descriptionValue, setDescriptionValue] = useState(
59 record?.description || "",
60 );
61 let [iconFile, setIconFile] = useState<File | null>(null);
62 let [iconPreview, setIconPreview] = useState<string | null>(null);
63 let fileInputRef = useRef<HTMLInputElement>(null);
64 useEffect(() => {
65 if (!pubData || !pubData.record || !record) return;
66 setNameValue(record.name);
67 setDescriptionValue(record.description || "");
68 if (record.icon)
69 setIconPreview(
70 `/api/atproto_images?did=${pubData.identity_did}&cid=${(record.icon.ref as unknown as { $link: string })["$link"]}`,
71 );
72 }, [pubData, record]);
73 let toast = useToaster();
74
75 return (
76 <form
77 onSubmit={async (e) => {
78 if (!pubData) return;
79 e.preventDefault();
80 props.setLoadingAction(true);
81 let data = await updatePublication({
82 uri: pubData.uri,
83 name: nameValue,
84 description: descriptionValue,
85 iconFile: iconFile,
86 preferences: {
87 showInDiscover: showInDiscover,
88 showComments: showComments,
89 showMentions: showMentions,
90 showPrevNext: showPrevNext,
91 },
92 });
93 toast({ type: "success", content: "Updated!" });
94 props.setLoadingAction(false);
95 mutate("publication-data");
96 }}
97 >
98 <PubSettingsHeader
99 loading={props.loading}
100 setLoadingAction={props.setLoadingAction}
101 backToMenuAction={props.backToMenuAction}
102 state={"theme"}
103 >
104 General Settings
105 </PubSettingsHeader>
106 <div className="flex flex-col gap-3 w-[1000px] max-w-full pb-2">
107 <div className="flex items-center justify-between gap-2 mt-2 ">
108 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
109 Logo <span className="font-normal">(optional)</span>
110 </p>
111 <div
112 className={`w-8 h-8 rounded-full flex items-center justify-center cursor-pointer ${iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`}
113 onClick={() => fileInputRef.current?.click()}
114 >
115 {iconPreview ? (
116 <img
117 src={iconPreview}
118 alt="Logo preview"
119 className="w-full h-full rounded-full object-cover"
120 />
121 ) : (
122 <AddTiny className="text-accent-1" />
123 )}
124 </div>
125 <input
126 type="file"
127 accept="image/*"
128 className="hidden"
129 ref={fileInputRef}
130 onChange={(e) => {
131 const file = e.target.files?.[0];
132 if (file) {
133 setIconFile(file);
134 const reader = new FileReader();
135 reader.onload = (e) => {
136 setIconPreview(e.target?.result as string);
137 };
138 reader.readAsDataURL(file);
139 }
140 }}
141 />
142 </div>
143
144 <label>
145 <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold">
146 Publication Name
147 </p>
148 <Input
149 className="input-with-border w-full text-primary"
150 type="text"
151 id="pubName"
152 value={nameValue}
153 onChange={(e) => {
154 setNameValue(e.currentTarget.value);
155 }}
156 />
157 </label>
158 <label>
159 <p className="text-tertiary italic text-sm font-bold pl-0.5 pb-0.5">
160 Description <span className="font-normal">(optional)</span>
161 </p>
162 <Input
163 textarea
164 className="input-with-border w-full text-primary"
165 rows={3}
166 id="pubDescription"
167 value={descriptionValue}
168 onChange={(e) => {
169 setDescriptionValue(e.currentTarget.value);
170 }}
171 />
172 </label>
173
174 <CustomDomainForm />
175 <hr className="border-border-light" />
176
177 <Toggle
178 toggle={showInDiscover}
179 onToggle={() => setShowInDiscover(!showInDiscover)}
180 >
181 <div className=" pt-0.5 flex flex-col text-sm text-tertiary ">
182 <p className="font-bold">
183 Show In{" "}
184 <a href="/discover" target="_blank">
185 Discover
186 </a>
187 </p>
188 <p className="text-xs text-tertiary font-normal">
189 Your posts will appear on our{" "}
190 <a href="/discover" target="_blank">
191 Discover
192 </a>{" "}
193 page. You can change this at any time!
194 </p>
195 </div>
196 </Toggle>
197
198
199 </div>
200 </form>
201 );
202};
203
204export function CustomDomainForm() {
205 let { data } = usePublicationData();
206 let { publication: pubData } = data || {};
207 let record = useNormalizedPublicationRecord();
208 if (!pubData) return null;
209 if (!record) return null;
210 let [state, setState] = useState<
211 | { type: "default" }
212 | { type: "addDomain" }
213 | {
214 type: "domainSettings";
215 domain: string;
216 verification?: Verification[];
217 config?: GetDomainConfigResponseBody;
218 }
219 >({ type: "default" });
220 let domains = pubData?.publication_domains || [];
221
222 return (
223 <div className="flex flex-col gap-0.5">
224 <p className="text-tertiary italic text-sm font-bold">
225 Publication Domain{domains.length > 1 && "s"}
226 </p>
227
228 <div className="opaque-container px-[6px] py-1">
229 {state.type === "addDomain" ? (
230 <AddDomain
231 publication_uri={pubData.uri}
232 goBack={() => setState({ type: "default" })}
233 setDomain={(d) => setState({ type: "domainSettings", domain: d })}
234 />
235 ) : state.type === "domainSettings" ? (
236 <DomainSettings
237 verification={state.verification}
238 config={state.config}
239 domain={state.domain}
240 goBack={() => setState({ type: "default" })}
241 />
242 ) : (
243 <div className="flex flex-col gap-1 py-1">
244 {domains.map((d) => (
245 <React.Fragment key={d.domain}>
246 <Domain
247 domain={d.domain}
248 publication_uri={pubData.uri}
249 base_path={record.url.replace(/^https?:\/\//, "")}
250 setDomain={(v) => {
251 setState({
252 type: "domainSettings",
253 domain: d.domain,
254 verification: v?.verification,
255 config: v?.config,
256 });
257 }}
258 />
259 <hr className="border-border-light last:hidden" />
260 </React.Fragment>
261 ))}
262 <button
263 className="text-accent-contrast text-sm w-fit "
264 onClick={() => setState({ type: "addDomain" })}
265 type="button"
266 >
267 Add custom domain
268 </button>
269 </div>
270 )}
271 </div>
272 </div>
273 );
274}
275
276function AddDomain(props: {
277 publication_uri: string;
278 goBack: () => void;
279 setDomain: (d: string) => void;
280}) {
281 let [domain, setDomain] = useState("");
282 let smoker = useSmoker();
283
284 return (
285 <div className="w-full flex flex-col gap-0.5 py-1">
286 <label>
287 <p className="pl-0.5 text-tertiary italic text-sm">
288 Add a Custom Domain
289 </p>
290 <Input
291 className="w-full input-with-border"
292 placeholder="domain"
293 value={domain}
294 onChange={(e) => setDomain(e.currentTarget.value)}
295 />
296 </label>
297 <div className="flex flex-row justify-between text-sm pt-2">
298 <button className="text-accent-contrast" onClick={() => props.goBack()}>
299 Back
300 </button>
301 <button
302 className="place-self-end font-bold text-accent-contrast text-sm"
303 onClick={async (e) => {
304 let { error } = await addPublicationDomain(
305 domain,
306 props.publication_uri,
307 );
308 if (error) {
309 smoker({
310 error: true,
311 text:
312 error === "invalid_domain"
313 ? "Invalid domain! Use just the base domain"
314 : error === "domain_already_in_use"
315 ? "That domain is already in use!"
316 : "An unknown error occured",
317 position: {
318 y: e.clientY,
319 x: e.clientX - 5,
320 },
321 });
322 }
323
324 mutate("publication-data");
325 props.setDomain(domain);
326 }}
327 type="button"
328 >
329 Add Domain
330 </button>
331 </div>
332 </div>
333 );
334}
335
336// OKay so... You hit this button, it gives you a form. You type in the form, and then hit add. We create a record, and a the record link it to your publiction. Then we show you the stuff to set. )
337// We don't want to switch it, until it works.
338// There's a checkbox to say that this is hosted somewhere else
339
340function Domain(props: {
341 domain: string;
342 base_path: string;
343 publication_uri: string;
344 setDomain: (domain?: {
345 verification?: Verification[];
346 config?: GetDomainConfigResponseBody;
347 }) => void;
348}) {
349 let { data } = useSWR(props.domain, async (domain) => {
350 return await callRPC("get_domain_status", { domain });
351 });
352
353 let pending = data?.config?.misconfigured || data?.verification;
354
355 return (
356 <div className="text-sm text-secondary relative w-full ">
357 <div className="pr-8 truncate">{props.domain}</div>
358 <div className="absolute right-0 top-0 bottom-0 flex justify-end items-center w-4 ">
359 {pending ? (
360 <button
361 className="group/pending px-1 py-0.5 flex gap-1 items-center rounded-full hover:bg-accent-1 hover:text-accent-2 hover:outline-accent-1 border-transparent outline-solid outline-transparent selected-outline"
362 onClick={() => {
363 props.setDomain(data);
364 }}
365 >
366 <p className="group-hover/pending:block hidden w-max pl-1 font-bold">
367 pending
368 </p>
369 <LoadingTiny className="animate-spin text-accent-contrast group-hover/pending:text-accent-2 " />
370 </button>
371 ) : props.base_path === props.domain ? (
372 <div className="group/default-domain flex gap-1 items-center rounded-full bg-none w-max px-1 py-0.5 hover:bg-bg-page border border-transparent hover:border-border-light ">
373 <p className="group-hover/default-domain:block hidden w-max pl-1">
374 current default domain
375 </p>
376 <PinTiny className="text-accent-contrast shrink-0" />
377 </div>
378 ) : (
379 <button
380 type="button"
381 onClick={async () => {
382 await updatePublicationBasePath({
383 uri: props.publication_uri,
384 base_path: props.domain,
385 });
386 mutate("publication-data");
387 }}
388 className="group/domain flex gap-1 items-center rounded-full bg-none w-max font-bold px-1 py-0.5 hover:bg-accent-1 hover:text-accent-2 border-transparent outline-solid outline-transparent hover:outline-accent-1 selected-outline"
389 >
390 <p className="group-hover/domain:block hidden w-max pl-1">
391 set as default
392 </p>
393 <PinTiny className="text-secondary group-hover/domain:text-accent-2 shrink-0" />
394 </button>
395 )}
396 </div>
397 </div>
398 );
399}
400
401const DomainSettings = (props: {
402 domain: string;
403 config?: GetDomainConfigResponseBody;
404 goBack: () => void;
405 verification?: Verification[];
406}) => {
407 let { data, mutate } = useSWR(props.domain, async (domain) => {
408 return await callRPC("get_domain_status", { domain });
409 });
410 let isSubdomain = props.domain.split(".").length > 2;
411 if (!data) return;
412 let { config, verification } = data;
413 if (!config?.misconfigured && !verification)
414 return <div>This domain is verified!</div>;
415 return (
416 <div className="flex flex-col gap-[6px] text-sm text-primary">
417 <div>
418 To verify this domain, add the following record to your DNS provider for{" "}
419 <strong>{props.domain}</strong>.
420 </div>
421 <table className="border border-border-light rounded-md">
422 <thead>
423 <tr>
424 <th className="p-1 py-1 text-tertiary">Type</th>
425 <th className="p-1 py-1 text-tertiary">Name</th>
426 <th className="p-1 py-1 text-tertiary">Value</th>
427 </tr>
428 </thead>
429 <tbody>
430 {verification && (
431 <tr>
432 <td className="p-1 py-1">
433 <div>{verification[0].type}</div>
434 </td>
435 <td className="p-1 py-1">
436 <div style={{ wordBreak: "break-word" }}>
437 {verification[0].domain}
438 </div>
439 </td>
440 <td className="p-1 py-1">
441 <div style={{ wordBreak: "break-word" }}>
442 {verification?.[0].value}
443 </div>
444 </td>
445 </tr>
446 )}
447 {config &&
448 (isSubdomain ? (
449 <tr>
450 <td className="p-1 py-1">
451 <div>CNAME</div>
452 </td>
453 <td className="p-1 py-1">
454 <div style={{ wordBreak: "break-word" }}>
455 {props.domain.split(".").slice(0, -2).join(".")}
456 </div>
457 </td>
458 <td className="p-1 py-1">
459 <div style={{ wordBreak: "break-word" }}>
460 {
461 config?.recommendedCNAME.sort(
462 (a, b) => a.rank - b.rank,
463 )[0].value
464 }
465 </div>
466 </td>
467 </tr>
468 ) : (
469 <tr>
470 <td className="p-1 py-1">
471 <div>A</div>
472 </td>
473 <td className="p-1 py-1">
474 <div style={{ wordBreak: "break-word" }}>@</div>
475 </td>
476 <td className="p-1 py-1">
477 <div style={{ wordBreak: "break-word" }}>
478 {
479 config?.recommendedIPv4.sort((a, b) => a.rank - b.rank)[0]
480 .value[0]
481 }
482 </div>
483 </td>
484 </tr>
485 ))}
486 {config?.configuredBy === "CNAME" && config.recommendedCNAME[0] && (
487 <tr></tr>
488 )}
489 </tbody>
490 </table>
491 <div className="flex flex-row justify-between">
492 <button
493 className="text-accent-contrast w-fit"
494 onClick={() => props.goBack()}
495 >
496 Back
497 </button>
498 <VerifyButton verify={() => mutate()} />
499 </div>
500 </div>
501 );
502};
503
504const VerifyButton = (props: { verify: () => Promise<any> }) => {
505 let [loading, setLoading] = useState(false);
506 return (
507 <button
508 className="text-accent-contrast w-fit"
509 onClick={async (e) => {
510 e.preventDefault();
511 setLoading(true);
512 await props.verify();
513 setLoading(false);
514 }}
515 >
516 {loading ? <DotLoader /> : "verify"}
517 </button>
518 );
519};