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