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