g1t/apps/web/app/components/profile-form.tsx

115 lines4,038 bytesCodeBlame
1import { Check } from "lucide-react";
2import { useState } from "react";
3import { Form, Link } from "react-router";
4
5import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
6
7import { SubmitButton, usePending } from "./ui";
8import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
9import { Input } from "./ui/input";
10import { Textarea } from "./ui/textarea";
11
12/**
13 * The Profile section of a person's settings: what everyone sees at
14 * `/u/<username>`. Posts `intent=profile`; identity checks every field.
15 */
16export function ProfileSection({
17 username,
18 profile,
19 error,
20 saved,
21}: {
22 username: string;
23 profile: Profile | null;
24 error?: string;
25 saved: boolean;
26}) {
27 const busy = usePending({ intent: "profile" });
28 const [bio, setBio] = useState(profile?.bio ?? "");
29 return (
30 <section id="profile" className="scroll-mt-20">
31 <div className="flex flex-wrap items-baseline justify-between gap-2">
32 <h2 className="font-medium">Public profile</h2>
33 <Link to={`/u/${username}`} className="text-sm text-muted underline-offset-4 hover:text-fg hover:underline">
34 View your profile
35 </Link>
36 </div>
37 <p className="mt-1 text-sm text-muted">
38 Shown to everyone at <span className="font-mono text-fg">g1t.sh/u/{username}</span>. All of it is
39 optional; your email address is never shown.
40 </p>
41 <Form method="post" className="mt-4 grid gap-4 sm:grid-cols-2">
42 <input type="hidden" name="intent" value="profile" />
43 <Field>
44 <FieldLabel htmlFor="profile-name">Name</FieldLabel>
45 <Input
46 id="profile-name"
47 name="name"
48 defaultValue={profile?.name ?? ""}
49 maxLength={PROFILE_LIMITS.name}
50 placeholder={username}
51 />
52 </Field>
53 <Field>
54 <FieldLabel htmlFor="profile-pronouns">Pronouns</FieldLabel>
55 <Input
56 id="profile-pronouns"
57 name="pronouns"
58 defaultValue={profile?.pronouns ?? ""}
59 maxLength={PROFILE_LIMITS.pronouns}
60 placeholder="they/them"
61 />
62 </Field>
63 <Field className="sm:col-span-2">
64 <FieldLabel htmlFor="profile-bio">Bio</FieldLabel>
65 <Textarea
66 id="profile-bio"
67 name="bio"
68 rows={2}
69 value={bio}
70 onChange={(event) => setBio(event.currentTarget.value)}
71 maxLength={PROFILE_LIMITS.bio}
72 placeholder="What you work on, in a line or two."
73 />
74 <FieldDescription>
75 {bio.length}/{PROFILE_LIMITS.bio} characters. Also what a link to your profile says.
76 </FieldDescription>
77 </Field>
78 <Field>
79 <FieldLabel htmlFor="profile-location">Location</FieldLabel>
80 <Input
81 id="profile-location"
82 name="location"
83 defaultValue={profile?.location ?? ""}
84 maxLength={PROFILE_LIMITS.location}
85 placeholder="Portland, Oregon"
86 />
87 </Field>
88 <Field>
89 <FieldLabel htmlFor="profile-website">Website</FieldLabel>
90 <Input
91 id="profile-website"
92 name="website"
93 inputMode="url"
94 defaultValue={profile?.website ?? ""}
95 maxLength={PROFILE_LIMITS.website}
96 placeholder="https://example.com"
97 />
98 <FieldDescription>An https:// address.</FieldDescription>
99 </Field>
100 <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
101 <SubmitButton pending="Saving…" match={{ intent: "profile" }}>
102 Save profile
103 </SubmitButton>
104 {saved && !busy && !error && (
105 <span className="inline-flex items-center gap-1.5 text-sm text-muted">
106 <Check size={14} className="text-accent" />
107 Saved
108 </span>
109 )}
110 <FieldError>{error}</FieldError>
111 </div>
112 </Form>
113 </section>
114 );
115}