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