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