a tool for shared writing and social publishing
1import { useState } from "react"; 2import { ButtonPrimary } from "components/Buttons"; 3 4import { useSmoker, useToaster } from "components/Toast"; 5import { Input, InputWithLabel } from "components/Input"; 6import useSWR from "swr"; 7import { useIdentityData } from "components/IdentityProvider"; 8import { addDomain } from "actions/domains/addDomain"; 9import { callRPC } from "app/api/rpc/client"; 10import { useLeafletDomains } from "components/PageSWRDataProvider"; 11import { usePublishLink } from "."; 12import { addDomainPath } from "actions/domains/addDomainPath"; 13import { useReplicache } from "src/replicache"; 14import { deleteDomain } from "actions/domains/deleteDomain"; 15import { AddTiny } from "components/Icons/AddTiny"; 16 17type DomainMenuState = 18 | { 19 state: "default"; 20 } 21 | { 22 state: "domain-settings"; 23 domain: string; 24 } 25 | { 26 state: "add-domain"; 27 } 28 | { 29 state: "has-domain"; 30 domain: string; 31 }; 32export function CustomDomainMenu(props: { 33 setShareMenuState: (s: "default") => void; 34}) { 35 let { data: domains } = useLeafletDomains(); 36 let [state, setState] = useState<DomainMenuState>( 37 domains?.[0] 38 ? { state: "has-domain", domain: domains[0].domain } 39 : { state: "default" }, 40 ); 41 switch (state.state) { 42 case "has-domain": 43 case "default": 44 return ( 45 <DomainOptions 46 setDomainMenuState={setState} 47 domainConnected={false} 48 setShareMenuState={props.setShareMenuState} 49 /> 50 ); 51 case "domain-settings": 52 return ( 53 <DomainSettings domain={state.domain} setDomainMenuState={setState} /> 54 ); 55 case "add-domain": 56 return <AddDomain setDomainMenuState={setState} />; 57 } 58} 59 60export const DomainOptions = (props: { 61 setShareMenuState: (s: "default") => void; 62 setDomainMenuState: (state: DomainMenuState) => void; 63 domainConnected: boolean; 64}) => { 65 let { data: domains, mutate: mutateDomains } = useLeafletDomains(); 66 let [selectedDomain, setSelectedDomain] = useState<string | undefined>( 67 domains?.[0]?.domain, 68 ); 69 let [selectedRoute, setSelectedRoute] = useState( 70 domains?.[0]?.route.slice(1) || "", 71 ); 72 let { identity } = useIdentityData(); 73 let { permission_token } = useReplicache(); 74 75 let toaster = useToaster(); 76 let smoker = useSmoker(); 77 let publishLink = usePublishLink(); 78 79 return ( 80 <div className="px-3 py-1 flex flex-col gap-3 max-w-full w-[600px]"> 81 <h3 className="text-secondary">Choose a Domain</h3> 82 <div className="flex flex-col gap-1 text-secondary"> 83 {identity?.custom_domains 84 .filter((d) => !d.publication_domains.length) 85 .map((domain) => { 86 return ( 87 <DomainOption 88 selectedRoute={selectedRoute} 89 setSelectedRoute={setSelectedRoute} 90 key={domain.domain} 91 domain={domain.domain} 92 checked={selectedDomain === domain.domain} 93 setChecked={setSelectedDomain} 94 setDomainMenuState={props.setDomainMenuState} 95 /> 96 ); 97 })} 98 <button 99 onMouseDown={() => { 100 props.setDomainMenuState({ state: "add-domain" }); 101 }} 102 className="text-accent-contrast flex gap-2 items-center px-1 py-0.5" 103 > 104 <AddTiny /> Add a New Domain 105 </button> 106 </div> 107 108 {/* ONLY SHOW IF A DOMAIN IS CURRENTLY CONNECTED */} 109 <div className="flex gap-3 items-center justify-end"> 110 {props.domainConnected && ( 111 <button 112 onMouseDown={() => { 113 props.setShareMenuState("default"); 114 toaster({ 115 content: ( 116 <div className="font-bold"> 117 Unpublished from custom domain! 118 </div> 119 ), 120 type: "error", 121 }); 122 }} 123 > 124 Unpublish 125 </button> 126 )} 127 128 <ButtonPrimary 129 id="publish-to-domain" 130 disabled={ 131 domains?.[0] 132 ? domains[0].domain === selectedDomain && 133 domains[0].route.slice(1) === selectedRoute 134 : !selectedDomain 135 } 136 onClick={async () => { 137 // let rect = document 138 // .getElementById("publish-to-domain") 139 // ?.getBoundingClientRect(); 140 // smoker({ 141 // error: true, 142 // text: "url already in use!", 143 // position: { 144 // x: rect ? rect.left : 0, 145 // y: rect ? rect.top + 26 : 0, 146 // }, 147 // }); 148 if (!selectedDomain || !publishLink) return; 149 await addDomainPath({ 150 domain: selectedDomain, 151 route: "/" + selectedRoute, 152 view_permission_token: publishLink, 153 edit_permission_token: permission_token.id, 154 }); 155 156 toaster({ 157 content: ( 158 <div className="font-bold"> 159 Published to custom domain!{" "} 160 <a 161 className="underline text-accent-2" 162 href={`https://${selectedDomain}/${selectedRoute}`} 163 target="_blank" 164 > 165 View 166 </a> 167 </div> 168 ), 169 type: "success", 170 }); 171 mutateDomains(); 172 props.setShareMenuState("default"); 173 }} 174 > 175 Publish! 176 </ButtonPrimary> 177 </div> 178 </div> 179 ); 180}; 181 182const DomainOption = (props: { 183 selectedRoute: string; 184 setSelectedRoute: (s: string) => void; 185 checked: boolean; 186 setChecked: (checked: string) => void; 187 domain: string; 188 setDomainMenuState: (state: DomainMenuState) => void; 189}) => { 190 let [value, setValue] = useState(""); 191 let { data } = useSWR(props.domain, async (domain) => { 192 return await callRPC("get_domain_status", { domain }); 193 }); 194 let pending = data?.config?.misconfigured || data?.error; 195 return ( 196 <label htmlFor={props.domain}> 197 <input 198 type="radio" 199 name={props.domain} 200 id={props.domain} 201 value={props.domain} 202 checked={props.checked} 203 className="hidden appearance-none" 204 onChange={() => { 205 if (pending) return; 206 props.setChecked(props.domain); 207 }} 208 /> 209 <div 210 className={` 211 px-[6px] py-1 212 flex 213 border rounded-md 214 ${ 215 pending 216 ? "border-border-light text-secondary justify-between gap-2 items-center " 217 : !props.checked 218 ? "flex-wrap border-border-light" 219 : "flex-wrap border-accent-1 bg-accent-1 text-accent-2 font-bold" 220 } `} 221 > 222 <div className={`w-max truncate ${pending && "animate-pulse"}`}> 223 {props.domain} 224 </div> 225 {props.checked && ( 226 <div className="flex gap-0 w-full"> 227 <span 228 className="font-normal" 229 style={value === "" ? { opacity: "0.5" } : {}} 230 > 231 / 232 </span> 233 234 <Input 235 type="text" 236 autoFocus 237 className="appearance-none focus:outline-hidden font-normal text-accent-2 w-full bg-transparent placeholder:text-accent-2 placeholder:opacity-50" 238 placeholder="add-optional-path" 239 onChange={(e) => props.setSelectedRoute(e.target.value)} 240 value={props.selectedRoute} 241 /> 242 </div> 243 )} 244 {pending && ( 245 <button 246 className="text-accent-contrast text-sm" 247 onMouseDown={() => { 248 props.setDomainMenuState({ 249 state: "domain-settings", 250 domain: props.domain, 251 }); 252 }} 253 > 254 pending 255 </button> 256 )} 257 </div> 258 </label> 259 ); 260}; 261 262export const AddDomain = (props: { 263 setDomainMenuState: (state: DomainMenuState) => void; 264}) => { 265 let [value, setValue] = useState(""); 266 let { mutate } = useIdentityData(); 267 let smoker = useSmoker(); 268 return ( 269 <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 270 <div> 271 <h3 className="text-secondary">Add a New Domain</h3> 272 <div className="text-xs italic text-secondary"> 273 Don't include the protocol or path, just the base domain name for now 274 </div> 275 </div> 276 277 <Input 278 className="input-with-border text-primary" 279 placeholder="www.example.com" 280 value={value} 281 onChange={(e) => setValue(e.target.value)} 282 /> 283 284 <ButtonPrimary 285 disabled={!value} 286 className="place-self-end mt-2" 287 onMouseDown={async (e) => { 288 // call the vercel api, set the thing... 289 let { error } = await addDomain(value); 290 if (error) { 291 smoker({ 292 error: true, 293 text: 294 error === "invalid_domain" 295 ? "Invalid domain! Use just the base domain" 296 : error === "domain_already_in_use" 297 ? "That domain is already in use!" 298 : "An unknown error occured", 299 position: { 300 y: e.clientY, 301 x: e.clientX - 5, 302 }, 303 }); 304 return; 305 } 306 mutate(); 307 props.setDomainMenuState({ state: "domain-settings", domain: value }); 308 }} 309 > 310 Verify Domain 311 </ButtonPrimary> 312 </div> 313 ); 314}; 315 316const DomainSettings = (props: { 317 domain: string; 318 setDomainMenuState: (s: DomainMenuState) => void; 319}) => { 320 let isSubdomain = props.domain.split(".").length > 2; 321 return ( 322 <div className="flex flex-col gap-1 px-3 py-1 max-w-full w-[600px]"> 323 <h3 className="text-secondary">Verify Domain</h3> 324 325 <div className="text-secondary text-sm flex flex-col gap-3"> 326 <div className="flex flex-col gap-[6px]"> 327 <div> 328 To verify this domain, add the following record to your DNS provider 329 for <strong>{props.domain}</strong>. 330 </div> 331 332 {isSubdomain ? ( 333 <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 334 <div className="flex flex-col "> 335 <div className="text-tertiary">Type</div> 336 <div>CNAME</div> 337 </div> 338 <div className="flex flex-col"> 339 <div className="text-tertiary">Name</div> 340 <div style={{ wordBreak: "break-word" }}> 341 {props.domain.split(".").slice(0, -2).join(".")} 342 </div> 343 </div> 344 <div className="flex flex-col"> 345 <div className="text-tertiary">Value</div> 346 <div style={{ wordBreak: "break-word" }}> 347 cname.vercel-dns.com 348 </div> 349 </div> 350 </div> 351 ) : ( 352 <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 353 <div className="flex flex-col "> 354 <div className="text-tertiary">Type</div> 355 <div>A</div> 356 </div> 357 <div className="flex flex-col"> 358 <div className="text-tertiary">Name</div> 359 <div>@</div> 360 </div> 361 <div className="flex flex-col"> 362 <div className="text-tertiary">Value</div> 363 <div>76.76.21.21</div> 364 </div> 365 </div> 366 )} 367 </div> 368 <div> 369 Once you do this, the status may be pending for up to a few hours. 370 </div> 371 <div>Check back later to see if verification was successful.</div> 372 </div> 373 374 <div className="flex gap-3 justify-between items-center mt-2"> 375 <button 376 className="text-accent-contrast font-bold " 377 onMouseDown={async () => { 378 await deleteDomain({ domain: props.domain }); 379 props.setDomainMenuState({ state: "default" }); 380 }} 381 > 382 Delete Domain 383 </button> 384 <ButtonPrimary 385 onMouseDown={() => { 386 props.setDomainMenuState({ state: "default" }); 387 }} 388 > 389 Back to Domains 390 </ButtonPrimary> 391 </div> 392 </div> 393 ); 394};