a tool for shared writing and social publishing
at update/reader 518 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 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};