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