a tool for shared writing and social publishing
at feature/thread-viewer 509 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 { 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};