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