a tool for shared writing and social publishing

Feature/pub custom domains (#137)

* add unstyled domains section to update pub form

* mutate local data on custom domain updates

* added custom domain styling

* slight updates to style to try and make thiings look more clickable

* add vercel verification step to domains

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
ea45cce1 7f2b548e

+465 -70
+31 -17
actions/domains/addDomain.ts
··· 4 4 5 5 import { Database } from "supabase/database.types"; 6 6 import { createServerClient } from "@supabase/ssr"; 7 + import { getIdentityData } from "actions/getIdentityData"; 7 8 8 9 const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 9 10 const vercel = new Vercel({ ··· 17 18 ); 18 19 19 20 export async function addDomain(domain: string) { 20 - let auth_token = (await cookies()).get("auth_token")?.value; 21 - if (!auth_token) return {}; 22 - let { data: auth_data } = await supabase 23 - .from("email_auth_tokens") 24 - .select( 25 - `*, 26 - identities( 27 - *, 28 - custom_domains(*) 29 - )`, 30 - ) 31 - .eq("id", auth_token) 32 - .eq("confirmed", true) 33 - .single(); 34 - if (!auth_data || !auth_data.email) return {}; 21 + let identity = await getIdentityData(); 22 + if (!identity || !identity.email) return {}; 35 23 if ( 36 24 domain.includes("leaflet.pub") && 37 25 ![ ··· 39 27 "brendan@hyperlink.academy", 40 28 "jared@hyperlink.academy", 41 29 "brendan.schlagel@gmail.com", 42 - ].includes(auth_data.email) 30 + ].includes(identity.email) 43 31 ) 44 32 return {}; 33 + return await createDomain(domain, identity.email); 34 + } 45 35 36 + export async function addPublicationDomain( 37 + domain: string, 38 + publication_uri: string, 39 + ) { 40 + let identity = await getIdentityData(); 41 + if (!identity || !identity.atp_did) return {}; 42 + let { data: publication } = await supabase 43 + .from("publications") 44 + .select("*") 45 + .eq("uri", publication_uri) 46 + .single(); 47 + 48 + if (publication?.identity_did !== identity.atp_did) return {}; 49 + let { error } = await createDomain(domain, null); 50 + if (error) return { error }; 51 + await supabase.from("publication_domains").insert({ 52 + publication: publication_uri, 53 + identity: identity.atp_did, 54 + domain, 55 + }); 56 + return {}; 57 + } 58 + 59 + async function createDomain(domain: string, email: string | null) { 46 60 try { 47 61 await vercel.projects.addProjectDomain({ 48 62 idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", ··· 71 85 72 86 await supabase.from("custom_domains").insert({ 73 87 domain, 74 - identity: auth_data.email, 88 + identity: email, 75 89 confirmed: false, 76 90 }); 77 91 return {};
+17 -2
app/api/rpc/[command]/domain_routes.ts
··· 20 20 teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 21 21 }), 22 22 ]); 23 - return { status, config }; 23 + return { config }; 24 24 } catch (e) { 25 - console.log(e); 26 25 let errorResponse = e as NextApiResponse; 26 + if (errorResponse.statusCode === 403) { 27 + try { 28 + let verification = await vercel.projects.getProjectDomain({ 29 + idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 30 + teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 31 + domain, 32 + }); 33 + if (!verification.verification) return {}; 34 + return { 35 + error: "Verification_needed", 36 + verification: verification.verification, 37 + } as const; 38 + } catch (e) { 39 + return { error: true }; 40 + } 41 + } 27 42 if (errorResponse.statusCode === 404) 28 43 return { error: "Not Found" } as const; 29 44 return { error: true };
+1
app/api/rpc/[command]/get_publication_data.ts
··· 20 20 .select( 21 21 `*, 22 22 documents_in_publications(documents(*)), 23 + publication_domains(*), 23 24 leaflets_in_publications(*, 24 25 permission_tokens(*, 25 26 permission_token_rights(*),
-2
app/lish/[did]/[publication]/dashboard/Actions.tsx
··· 101 101 } 102 102 103 103 function PublicationSettingsButton(props: { publication: string }) { 104 - let router = useRouter(); 105 - 106 104 return ( 107 105 <Popover 108 106 asChild
+318 -40
app/lish/createPub/UpdatePubForm.tsx
··· 1 1 "use client"; 2 2 import { callRPC } from "app/api/rpc/client"; 3 - import { createPublication } from "./createPublication"; 4 3 import { ButtonPrimary } from "components/Buttons"; 5 - import { AddSmall } from "components/Icons/AddSmall"; 6 - import { InputWithLabel } from "components/Input"; 7 - import { useState, useRef, useEffect } from "react"; 8 - import { updatePublication } from "./updatePublication"; 4 + import { Input } from "components/Input"; 5 + import React, { useState, useRef, useEffect } from "react"; 6 + import { updatePublicationBasePath } from "./updatePublication"; 9 7 import { usePublicationData } from "../[did]/[publication]/dashboard/PublicationSWRProvider"; 10 8 import { PubLeafletPublication } from "lexicons/api"; 11 - import { mutate } from "swr"; 9 + import useSWR, { mutate } from "swr"; 12 10 import { AddTiny } from "components/Icons/AddTiny"; 13 11 import { DotLoader } from "components/utils/DotLoader"; 14 - import { useToaster } from "components/Toast"; 12 + import { useSmoker, useToaster } from "components/Toast"; 13 + import { addPublicationDomain } from "actions/domains/addDomain"; 14 + import { LoadingTiny } from "components/Icons/LoadingTiny"; 15 + import { PinTiny } from "components/Icons/PinTiny"; 16 + import { Verification } from "@vercel/sdk/esm/models/getprojectdomainop"; 15 17 16 18 export const EditPubForm = () => { 17 19 let pubData = usePublicationData(); ··· 43 45 if (!pubData) return; 44 46 e.preventDefault(); 45 47 setFormState("loading"); 46 - let data = await updatePublication({ 47 - uri: pubData.uri, 48 - name: nameValue, 49 - description: descriptionValue, 50 - iconFile: iconFile, 51 - }); 52 48 toast({ type: "success", content: "Updated!" }); 53 49 setFormState("normal"); 54 50 mutate("publication-data"); 55 51 }} 56 52 > 57 53 <div className="flex items-center justify-between gap-2 "> 58 - <div className="text-center text-secondary flex flex-col "> 59 - <p className=" font-bold text-secondary"> 60 - Logo{" "} 61 - <span className="italic text-tertiary font-normal">(optional)</span> 62 - </p> 63 - </div> 54 + <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 55 + Logo <span className="font-normal">(optional)</span> 56 + </p> 64 57 <div 65 58 className={`w-8 h-8 rounded-full flex items-center justify-center cursor-pointer ${iconPreview ? "border border-border-light hover:outline-border" : "border border-dotted border-accent-contrast hover:outline-accent-contrast"} selected-outline`} 66 59 onClick={() => fileInputRef.current?.click()} ··· 93 86 }} 94 87 /> 95 88 </div> 96 - <InputWithLabel 97 - type="text" 98 - id="pubName" 99 - label="Publication Name" 100 - value={nameValue} 101 - onChange={(e) => { 102 - setNameValue(e.currentTarget.value); 103 - }} 104 - /> 105 - 106 - <InputWithLabel 107 - label="Description (optional)" 108 - textarea 109 - rows={3} 110 - id="pubDescription" 111 - value={descriptionValue} 112 - onChange={(e) => { 113 - setDescriptionValue(e.currentTarget.value); 114 - }} 115 - /> 89 + <label> 90 + <p className="pl-0.5 pb-0.5 text-tertiary italic text-sm font-bold"> 91 + Publication Name 92 + </p> 93 + <Input 94 + className="input-with-border w-full " 95 + type="text" 96 + id="pubName" 97 + value={nameValue} 98 + onChange={(e) => { 99 + setNameValue(e.currentTarget.value); 100 + }} 101 + /> 102 + </label> 103 + <label> 104 + <p className="text-tertiary italic text-sm font-bold pl-0.5 pb-0.5"> 105 + Description <span className="font-normal">(optional)</span> 106 + </p> 107 + <Input 108 + textarea 109 + className="input-with-border w-full " 110 + rows={3} 111 + id="pubDescription" 112 + value={descriptionValue} 113 + onChange={(e) => { 114 + setDescriptionValue(e.currentTarget.value); 115 + }} 116 + /> 117 + </label> 116 118 119 + <CustomDomainForm /> 117 120 <ButtonPrimary className="place-self-end" type="submit"> 118 - {formState === "loading" ? <DotLoader /> : "Update Publication"} 121 + {formState === "loading" ? <DotLoader /> : "Update!"} 119 122 </ButtonPrimary> 120 123 </form> 121 124 ); 122 125 }; 126 + 127 + export function CustomDomainForm() { 128 + let pubData = usePublicationData(); 129 + if (!pubData) return null; 130 + let record = pubData?.record as PubLeafletPublication.Record; 131 + let [state, setState] = useState< 132 + | { type: "default" } 133 + | { type: "addDomain" } 134 + | { type: "domainSettings"; domain: string; verification?: Verification[] } 135 + >({ type: "default" }); 136 + let domains = pubData?.publication_domains || []; 137 + 138 + return ( 139 + <div className="flex flex-col gap-0.5"> 140 + <p className="text-tertiary italic text-sm font-bold"> 141 + Publication Domain{domains.length > 1 && "s"} 142 + </p> 143 + 144 + <div className="opaque-container px-[6px] py-1"> 145 + {state.type === "addDomain" ? ( 146 + <AddDomain 147 + publication_uri={pubData.uri} 148 + goBack={() => setState({ type: "default" })} 149 + /> 150 + ) : state.type === "domainSettings" ? ( 151 + <DomainSettings 152 + verification={state.verification} 153 + domain={state.domain} 154 + goBack={() => setState({ type: "default" })} 155 + /> 156 + ) : ( 157 + <div className="flex flex-col gap-1 py-1"> 158 + {domains.map((d) => ( 159 + <React.Fragment key={d.domain}> 160 + <Domain 161 + domain={d.domain} 162 + publication_uri={pubData.uri} 163 + base_path={record.base_path || ""} 164 + setDomain={(v) => { 165 + setState({ 166 + type: "domainSettings", 167 + domain: d.domain, 168 + verification: v, 169 + }); 170 + }} 171 + /> 172 + <hr className="border-border-light last:hidden" /> 173 + </React.Fragment> 174 + ))} 175 + <button 176 + className="text-accent-contrast text-sm w-fit " 177 + onClick={() => setState({ type: "addDomain" })} 178 + type="button" 179 + > 180 + Add custom domain 181 + </button> 182 + </div> 183 + )} 184 + </div> 185 + </div> 186 + ); 187 + } 188 + 189 + function AddDomain(props: { publication_uri: string; goBack: () => void }) { 190 + let [domain, setDomain] = useState(""); 191 + let smoker = useSmoker(); 192 + 193 + return ( 194 + <div className="w-full flex flex-col gap-0.5 py-1"> 195 + <label> 196 + <p className="pl-0.5 text-tertiary italic text-sm"> 197 + Add a Custom Domain 198 + </p> 199 + <Input 200 + className="w-full input-with-border" 201 + placeholder="domain" 202 + value={domain} 203 + onChange={(e) => setDomain(e.currentTarget.value)} 204 + /> 205 + </label> 206 + <div className="flex flex-row justify-between text-sm pt-2"> 207 + <button className="text-accent-contrast" onClick={() => props.goBack()}> 208 + Back 209 + </button> 210 + <button 211 + className="place-self-end font-bold text-accent-contrast text-sm" 212 + onClick={async (e) => { 213 + let { error } = await addPublicationDomain( 214 + domain, 215 + props.publication_uri, 216 + ); 217 + if (error) { 218 + smoker({ 219 + error: true, 220 + text: 221 + error === "invalid_domain" 222 + ? "Invalid domain! Use just the base domain" 223 + : error === "domain_already_in_use" 224 + ? "That domain is already in use!" 225 + : "An unknown error occured", 226 + position: { 227 + y: e.clientY, 228 + x: e.clientX - 5, 229 + }, 230 + }); 231 + } 232 + 233 + mutate("publication-data"); 234 + props.goBack(); 235 + }} 236 + type="button" 237 + > 238 + Add Domain 239 + </button> 240 + </div> 241 + </div> 242 + ); 243 + } 244 + 245 + // OKay so... You hit this button, it gives you a form. You type in the form, and then hit add. We create a record, and a the record link it to your publiction. Then we show you the stuff to set. ) 246 + // We don't want to switch it, until it works. 247 + // There's a checkbox to say that this is hosted somewhere else 248 + 249 + function Domain(props: { 250 + domain: string; 251 + base_path: string; 252 + publication_uri: string; 253 + setDomain: (v?: Verification[]) => void; 254 + }) { 255 + let { data } = useSWR(props.domain, async (domain) => { 256 + return await callRPC("get_domain_status", { domain }); 257 + }); 258 + 259 + let pending = data?.config?.misconfigured || data?.error; 260 + console.log(props.domain, data); 261 + 262 + return ( 263 + <div className="text-sm text-secondary relative"> 264 + {props.domain} 265 + <div className="absolute right-0 top-0 bottom-0 flex justify-end items-center w-4 "> 266 + {pending ? ( 267 + <button 268 + className="group/pending px-1 py-0.5 flex gap-1 items-center rounded-full hover:bg-accent-1 hover:text-accent-2 hover:outline-accent-1 border-transparent outline outline-transparent selected-outline" 269 + onClick={() => { 270 + if (data?.error === "Verification_needed") { 271 + props.setDomain(data.verification); 272 + } else { 273 + props.setDomain(); 274 + } 275 + }} 276 + > 277 + <p className="group-hover/pending:block hidden w-max pl-1 font-bold"> 278 + pending 279 + </p> 280 + <LoadingTiny className="animate-spin text-accent-contrast group-hover/pending:text-accent-2 " /> 281 + </button> 282 + ) : props.base_path === props.domain ? ( 283 + <div className="group/default-domain flex gap-1 items-center rounded-full bg-none w-max px-1 py-0.5 hover:bg-bg-page border border-transparent hover:border-border-light hover:text-secondary"> 284 + <p className="group-hover/default-domain:block hidden w-max pl-1"> 285 + current default domain 286 + </p> 287 + <PinTiny className="text-accent-contrast group-hover/default-domain:text-border shrink-0" /> 288 + </div> 289 + ) : ( 290 + <button 291 + type="button" 292 + onClick={() => { 293 + updatePublicationBasePath({ 294 + uri: props.publication_uri, 295 + base_path: props.domain, 296 + }); 297 + mutate("publication-data"); 298 + }} 299 + className="group/domain flex gap-1 items-center rounded-full bg-none w-max font-bold px-1 py-0.5 hover:bg-accent-1 hover:text-accent-2 border-transparent outline outline-transparent hover:outline-accent-1 selected-outline" 300 + > 301 + <p className="group-hover/domain:block hidden w-max pl-1"> 302 + set as default 303 + </p> 304 + <PinTiny className="text-accent-contrast group-hover/domain:text-accent-2 shrink-0" /> 305 + </button> 306 + )} 307 + </div> 308 + </div> 309 + ); 310 + } 311 + 312 + const DomainSettings = (props: { 313 + domain: string; 314 + goBack: () => void; 315 + verification?: Verification[]; 316 + }) => { 317 + let isSubdomain = props.domain.split(".").length > 2; 318 + if (props.verification) 319 + return ( 320 + <div className="flex flex-col gap-[6px] text-sm"> 321 + <div>{props.domain} is in use on a Vercel account.</div> 322 + <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 323 + <div className="flex flex-col "> 324 + <div className="text-tertiary">Type</div> 325 + <div>{props.verification[0].type}</div> 326 + </div> 327 + <div className="flex flex-col"> 328 + <div className="text-tertiary">Name</div> 329 + <div style={{ wordBreak: "break-word" }}> 330 + {props.verification[0].domain} 331 + </div> 332 + </div> 333 + <div className="flex flex-col"> 334 + <div className="text-tertiary">Value</div> 335 + <div style={{ wordBreak: "break-word" }}> 336 + {props.verification?.[0].value} 337 + </div> 338 + </div> 339 + </div> 340 + <div> 341 + <button 342 + className="text-accent-contrast w-fit" 343 + onClick={() => props.goBack()} 344 + > 345 + Back 346 + </button> 347 + </div> 348 + <button className="text-accent-contrast w-fit">verify</button> 349 + </div> 350 + ); 351 + 352 + return ( 353 + <div className="flex flex-col gap-[6px] text-sm"> 354 + <div> 355 + To verify this domain, add the following record to your DNS provider for{" "} 356 + <strong>{props.domain}</strong>. 357 + </div> 358 + 359 + {isSubdomain ? ( 360 + <div className="flex gap-3 p-1 border border-border-light rounded-md py-1"> 361 + <div className="flex flex-col "> 362 + <div className="text-tertiary">Type</div> 363 + <div>CNAME</div> 364 + </div> 365 + <div className="flex flex-col"> 366 + <div className="text-tertiary">Name</div> 367 + <div style={{ wordBreak: "break-word" }}> 368 + {props.domain.split(".").slice(0, -2).join(".")} 369 + </div> 370 + </div> 371 + <div className="flex flex-col"> 372 + <div className="text-tertiary">Value</div> 373 + <div style={{ wordBreak: "break-word" }}>cname.vercel-dns.com</div> 374 + </div> 375 + </div> 376 + ) : ( 377 + <div className="flex gap-3 px-2 py-1 border border-border-light rounded-md "> 378 + <div className="flex flex-col "> 379 + <div className="text-tertiary">Type</div> 380 + <div>A</div> 381 + </div> 382 + <div className="flex flex-col"> 383 + <div className="text-tertiary">Name</div> 384 + <div>@</div> 385 + </div> 386 + <div className="flex flex-col"> 387 + <div className="text-tertiary">Value</div> 388 + <div>76.76.21.21</div> 389 + </div> 390 + </div> 391 + )} 392 + <button 393 + className="text-accent-contrast w-fit" 394 + onClick={() => props.goBack()} 395 + > 396 + Back 397 + </button> 398 + </div> 399 + ); 400 + };
+49
app/lish/createPub/updatePublication.ts
··· 81 81 82 82 return { success: true, publication }; 83 83 } 84 + 85 + export async function updatePublicationBasePath({ 86 + uri, 87 + base_path, 88 + }: { 89 + uri: string; 90 + base_path: string; 91 + }) { 92 + const oauthClient = await createOauthClient(); 93 + let identity = await getIdentityData(); 94 + if (!identity || !identity.atp_did) return; 95 + 96 + let credentialSession = await oauthClient.restore(identity.atp_did); 97 + let agent = new AtpBaseClient( 98 + credentialSession.fetchHandler.bind(credentialSession), 99 + ); 100 + let { data: existingPub } = await supabaseServerClient 101 + .from("publications") 102 + .select("*") 103 + .eq("uri", uri) 104 + .single(); 105 + if (!existingPub || existingPub.identity_did !== identity.atp_did) return; 106 + let aturi = new AtUri(existingPub.uri); 107 + 108 + let record: PubLeafletPublication.Record = { 109 + ...(existingPub.record as PubLeafletPublication.Record), 110 + base_path, 111 + }; 112 + 113 + let result = await agent.com.atproto.repo.putRecord({ 114 + repo: credentialSession.did!, 115 + rkey: aturi.rkey, 116 + record, 117 + collection: record.$type, 118 + validate: false, 119 + }); 120 + 121 + //optimistically write to our db! 122 + let { data: publication, error } = await supabaseServerClient 123 + .from("publications") 124 + .update({ 125 + name: record.name, 126 + record: record as Json, 127 + }) 128 + .eq("uri", uri) 129 + .select() 130 + .single(); 131 + return { success: true, publication }; 132 + }
+19
components/Icons/LoadingTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const LoadingTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M8 0.955078C8.55217 0.95521 9 1.40287 9 1.95508C8.99987 2.50717 8.55209 2.95495 8 2.95508C5.21383 2.95521 2.95521 5.21383 2.95508 8C2.95521 10.7862 5.21383 13.0448 8 13.0449C10.7862 13.0448 13.0448 10.7862 13.0449 8C13.0451 7.44799 13.4929 7.00026 14.0449 7C14.597 7.00013 15.0448 7.44791 15.0449 8C15.0448 11.8907 11.8907 15.0448 8 15.0449C4.10926 15.0448 0.95521 11.8907 0.955078 8C0.95521 4.10926 4.10926 0.95521 8 0.955078ZM12.5713 1.06836C13.9189 1.06836 15.0116 2.16025 15.0117 3.50781C15.0117 4.85549 13.919 5.94824 12.5713 5.94824C11.2237 5.94811 10.1318 4.85541 10.1318 3.50781C10.132 2.16033 11.2238 1.06849 12.5713 1.06836Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+19
components/Icons/PinTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const PinTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M9.80059 0.808095C10.3726 0.337443 11.159 0.388783 11.7889 0.585907C12.502 0.80922 13.2604 1.28089 13.9292 1.92731C14.5996 2.57534 15.0833 3.30774 15.3233 4.00865C15.5537 4.68198 15.6022 5.48224 15.13 6.08482L15.0546 6.17473C14.8724 6.36939 14.6645 6.47368 14.4996 6.52816C14.4062 6.55897 14.3223 6.57551 14.2588 6.58397C14.2445 6.58585 14.2301 6.58585 14.2175 6.58707L13.2409 6.78652L11.7 8.38008C11.7156 8.43405 11.7334 8.4921 11.7496 8.55266C11.8347 8.87029 11.9382 9.32495 11.945 9.72251C11.9474 9.86414 11.9527 10.0713 11.9284 10.2651C11.9052 10.4501 11.8422 10.7511 11.6091 10.9926L10.0889 12.5655C9.41418 13.2633 8.40065 13.304 7.50533 13.0646C6.83085 12.8842 6.12716 12.5242 5.46429 12.0229L3.15456 14.4401C3.06174 14.5353 2.95481 14.6161 2.8435 14.6737L1.32848 15.457C1.05666 15.5974 0.786446 15.5902 0.631944 15.4395C0.497051 15.3076 0.470725 15.085 0.55547 14.8473L0.598874 14.745L1.34295 13.2114C1.39773 13.0986 1.47532 12.9884 1.56824 12.8931L3.90587 10.447C3.4947 9.88524 3.19074 9.29691 3.01711 8.72628C2.74737 7.83953 2.75381 6.82532 3.42842 6.12719L4.94861 4.5543C5.18181 4.31353 5.47964 4.24077 5.66374 4.2112C5.85665 4.18032 6.06462 4.17744 6.2063 4.17503L6.51219 4.1864C6.82365 4.21173 7.14202 4.27539 7.38235 4.33108C7.44329 4.34522 7.50161 4.36077 7.55596 4.37448L9.09682 2.77989L9.26423 1.78469C9.26558 1.77005 9.26609 1.75324 9.26837 1.73612C9.27656 1.67524 9.2923 1.59733 9.32004 1.5098C9.37631 1.33262 9.48516 1.11337 9.68898 0.909372L9.80059 0.808095ZM6.48119 4.89327C6.26663 4.69488 5.93137 4.70764 5.73298 4.9222C5.19843 5.50066 5.27323 6.34523 5.5449 7.06038C5.82971 7.80962 6.39179 8.60883 7.14466 9.30501C7.71243 9.82996 8.5313 10.3668 9.25287 10.5069L9.35931 10.5162C9.6051 10.5136 9.82395 10.3389 9.87293 10.0883C9.92845 9.80165 9.74102 9.52408 9.45439 9.46829L9.27974 9.42178C8.8523 9.28358 8.30336 8.93612 7.8629 8.52889C7.20389 7.91958 6.7503 7.25156 6.53493 6.68524C6.30686 6.08518 6.39611 5.76399 6.51013 5.64044C6.70841 5.42591 6.69562 5.09165 6.48119 4.89327ZM13.1861 2.54117C12.4055 1.7777 11.5507 1.38532 11.2774 1.66481C11.0045 1.94453 11.4159 2.78971 12.1961 3.5529C12.9765 4.31617 13.8313 4.70838 14.1048 4.42925C14.3773 4.14932 13.9663 3.30421 13.1861 2.54117Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+11 -9
components/Input.tsx
··· 3 3 import { onMouseDown } from "src/utils/iosInputMouseDown"; 4 4 import { isIOS } from "src/utils/isDevice"; 5 5 6 - export function Input( 7 - props: React.DetailedHTMLProps< 8 - React.InputHTMLAttributes<HTMLInputElement>, 9 - HTMLInputElement 10 - >, 11 - ) { 6 + export const Input = ( 7 + props: { 8 + textarea?: boolean; 9 + } & JSX.IntrinsicElements["input"] & 10 + JSX.IntrinsicElements["textarea"], 11 + ) => { 12 + let { textarea, ...inputProps } = props; 12 13 let ref = useRef<HTMLInputElement>(null); 13 14 useEffect(() => { 14 15 if (!isIOS()) return; ··· 17 18 } 18 19 }, [props.autoFocus]); 19 20 21 + if (textarea) return <textarea {...inputProps} />; 20 22 return ( 21 23 <input 22 - {...props} 24 + {...inputProps} 23 25 autoFocus={isIOS() ? false : props.autoFocus} 24 26 ref={ref} 25 27 onMouseDown={onMouseDown} 26 28 /> 27 29 ); 28 - } 30 + }; 29 31 30 32 export const focusElement = (el?: HTMLInputElement | null) => { 31 33 if (!isIOS()) { ··· 71 73 let { label, textarea, ...inputProps } = props; 72 74 let style = `appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className} outline-none resize-none`; 73 75 return ( 74 - <label className=" input-with-border flex flex-col text-sm text-tertiary font-bold italic leading-tight !py-1 !px-[6px]"> 76 + <label className=" input-with-border flex flex-col gap-[1px] text-sm text-tertiary font-bold italic leading-tight !py-1 !px-[6px]"> 75 77 {props.label} 76 78 {textarea ? ( 77 79 <textarea {...inputProps} className={style} />