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