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