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