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