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