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