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