a tool for shared writing and social publishing
at update/thread-viewer 274 lines 8.9 kB view raw
1"use client"; 2import { callRPC } from "app/api/rpc/client"; 3import { createPublication } from "./createPublication"; 4import { ButtonPrimary } from "components/Buttons"; 5import { AddSmall } from "components/Icons/AddSmall"; 6import { useIdentityData } from "components/IdentityProvider"; 7import { Input, InputWithLabel } from "components/Input"; 8import { useRouter } from "next/navigation"; 9import { useState, useRef, useEffect } from "react"; 10import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 11import { theme } from "tailwind.config"; 12import { getBasePublicationURL, getPublicationURL } from "./getPublicationURL"; 13import { string } from "zod"; 14import { DotLoader } from "components/utils/DotLoader"; 15import { Checkbox } from "components/Checkbox"; 16import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 17 18type DomainState = 19 | { status: "empty" } 20 | { status: "valid" } 21 | { status: "invalid" } 22 | { status: "pending" } 23 | { status: "error"; message: string }; 24 25export const CreatePubForm = () => { 26 let [formState, setFormState] = useState<"normal" | "loading">("normal"); 27 let [nameValue, setNameValue] = useState(""); 28 let [descriptionValue, setDescriptionValue] = useState(""); 29 let [showInDiscover, setShowInDiscover] = useState(true); 30 let [logoFile, setLogoFile] = useState<File | null>(null); 31 let [logoPreview, setLogoPreview] = useState<string | null>(null); 32 let [domainValue, setDomainValue] = useState(""); 33 let [domainState, setDomainState] = useState<DomainState>({ 34 status: "empty", 35 }); 36 let [oauthError, setOauthError] = useState< 37 import("src/atproto-oauth").OAuthSessionError | null 38 >(null); 39 let fileInputRef = useRef<HTMLInputElement>(null); 40 41 let router = useRouter(); 42 return ( 43 <form 44 className="flex flex-col gap-3" 45 onSubmit={async (e) => { 46 if (formState !== "normal") return; 47 e.preventDefault(); 48 if (!subdomainValidator.safeParse(domainValue).success) return; 49 setFormState("loading"); 50 setOauthError(null); 51 let result = await createPublication({ 52 name: nameValue, 53 description: descriptionValue, 54 iconFile: logoFile, 55 subdomain: domainValue, 56 preferences: { 57 showInDiscover, 58 showComments: true, 59 showMentions: true, 60 showPrevNext: true, 61 }, 62 }); 63 64 if (!result.success) { 65 setFormState("normal"); 66 if (result.error && isOAuthSessionError(result.error)) { 67 setOauthError(result.error); 68 } 69 return; 70 } 71 72 // Show a spinner while this is happening! Maybe a progress bar? 73 setTimeout(() => { 74 setFormState("normal"); 75 if (result.publication) 76 router.push( 77 `${getBasePublicationURL(result.publication)}/dashboard`, 78 ); 79 }, 500); 80 }} 81 > 82 <div className="flex flex-col items-center mb-4 gap-2"> 83 <div className="text-center text-secondary flex flex-col "> 84 <h3 className="-mb-1">Logo</h3> 85 <p className="italic text-tertiary">(optional)</p> 86 </div> 87 <div 88 className="w-24 h-24 rounded-full border-2 border-dotted border-accent-1 flex items-center justify-center cursor-pointer hover:border-accent-contrast" 89 onClick={() => fileInputRef.current?.click()} 90 > 91 {logoPreview ? ( 92 <img 93 src={logoPreview} 94 alt="Logo preview" 95 className="w-full h-full rounded-full object-cover" 96 /> 97 ) : ( 98 <AddSmall className="text-accent-1" /> 99 )} 100 </div> 101 <input 102 type="file" 103 accept="image/*" 104 className="hidden" 105 ref={fileInputRef} 106 onChange={(e) => { 107 const file = e.target.files?.[0]; 108 if (file) { 109 setLogoFile(file); 110 const reader = new FileReader(); 111 reader.onload = (e) => { 112 setLogoPreview(e.target?.result as string); 113 }; 114 reader.readAsDataURL(file); 115 } 116 }} 117 /> 118 </div> 119 <InputWithLabel 120 type="text" 121 id="pubName" 122 label="Publication Name" 123 value={nameValue} 124 onChange={(e) => { 125 setNameValue(e.currentTarget.value); 126 }} 127 /> 128 129 <InputWithLabel 130 label="Description (optional)" 131 textarea 132 rows={3} 133 id="pubDescription" 134 value={descriptionValue} 135 onChange={(e) => { 136 setDescriptionValue(e.currentTarget.value); 137 }} 138 /> 139 <DomainInput 140 domain={domainValue} 141 setDomain={setDomainValue} 142 domainState={domainState} 143 setDomainState={setDomainState} 144 /> 145 <hr className="border-border-light" /> 146 <Checkbox 147 checked={showInDiscover} 148 onChange={(e) => setShowInDiscover(e.target.checked)} 149 > 150 <div className=" pt-0.5 flex flex-col text-sm text-tertiary "> 151 <p className="font-bold italic">Show In Discover</p> 152 <p className="text-sm text-tertiary font-normal"> 153 Your posts will appear on our{" "} 154 <a href="/discover" target="_blank"> 155 Discover 156 </a>{" "} 157 page. You can change this at any time! 158 </p> 159 </div> 160 </Checkbox> 161 <hr className="border-border-light" /> 162 163 <div className="flex flex-col gap-2"> 164 <div className="flex w-full justify-end"> 165 <ButtonPrimary 166 type="submit" 167 disabled={ 168 !nameValue || !domainValue || domainState.status !== "valid" 169 } 170 > 171 {formState === "loading" ? <DotLoader /> : "Create Publication!"} 172 </ButtonPrimary> 173 </div> 174 {oauthError && ( 175 <OAuthErrorMessage 176 error={oauthError} 177 className="text-right text-sm text-accent-1" 178 /> 179 )} 180 </div> 181 </form> 182 ); 183}; 184 185let subdomainValidator = string() 186 .min(3) 187 .max(63) 188 .regex(/^[a-z0-9-]+$/); 189function DomainInput(props: { 190 domain: string; 191 setDomain: (d: string) => void; 192 domainState: DomainState; 193 setDomainState: (s: DomainState) => void; 194}) { 195 useEffect(() => { 196 if (!props.domain) { 197 props.setDomainState({ status: "empty" }); 198 } else { 199 let valid = subdomainValidator.safeParse(props.domain); 200 if (!valid.success) { 201 let reason = valid.error.errors[0].code; 202 props.setDomainState({ 203 status: "error", 204 message: 205 reason === "too_small" 206 ? "Must be at least 3 characters long" 207 : reason === "invalid_string" 208 ? "Must contain only lowercase a-z, 0-9, and -" 209 : "", 210 }); 211 return; 212 } 213 props.setDomainState({ status: "pending" }); 214 } 215 }, [props.domain]); 216 217 useDebouncedEffect( 218 async () => { 219 if (!props.domain) return props.setDomainState({ status: "empty" }); 220 221 let valid = subdomainValidator.safeParse(props.domain); 222 if (!valid.success) { 223 return; 224 } 225 let status = await callRPC("get_leaflet_subdomain_status", { 226 domain: props.domain, 227 }); 228 if (status.error === "Not Found") 229 props.setDomainState({ status: "valid" }); 230 else props.setDomainState({ status: "invalid" }); 231 }, 232 500, 233 [props.domain], 234 ); 235 236 return ( 237 <div className="flex flex-col gap-1"> 238 <label className=" input-with-border flex flex-col text-sm text-tertiary font-bold italic leading-tight py-1! px-[6px]!"> 239 <div>Choose your domain</div> 240 <div className="flex flex-row items-center"> 241 <Input 242 minLength={3} 243 maxLength={63} 244 placeholder="domain" 245 className="appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 outline-hidden" 246 value={props.domain} 247 onChange={(e) => props.setDomain(e.currentTarget.value)} 248 /> 249 .leaflet.pub 250 </div> 251 </label> 252 <div 253 className={"text-sm italic "} 254 style={{ 255 fontWeight: props.domainState.status === "valid" ? "bold" : "normal", 256 color: 257 props.domainState.status === "valid" 258 ? theme.colors["accent-contrast"] 259 : theme.colors.tertiary, 260 }} 261 > 262 {props.domainState.status === "valid" 263 ? "Available!" 264 : props.domainState.status === "error" 265 ? props.domainState.message 266 : props.domainState.status === "invalid" 267 ? "Already Taken ):" 268 : props.domainState.status === "pending" 269 ? "Checking Availability..." 270 : "a-z, 0-9, and - only!"} 271 </div> 272 </div> 273 ); 274}