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