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