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