Skip to content
164 linesCodeBlameRaw
1import { Check } from "lucide-react";
2import { useEffect, useMemo, useState } from "react";
3import { Form, Link } from "react-router";
4
5import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
6
7import { browserTimeZone, timeZoneLabel, timeZoneNames, utcOffset } from "../lib/time-zone";
8import { SubmitButton, usePending } from "./ui";
9import { Button } from "./ui/button";
10import { Combobox } from "./ui/combobox";
11import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
12import { Input } from "./ui/input";
13import { Textarea } from "./ui/textarea";
14
15/**
16 * The Profile section of a person's settings: what everyone sees at
17 * `/u/<username>`. Posts `intent=profile`; identity checks every field.
18 */
19export function ProfileSection({
20 username,
21 profile,
22 error,
23 saved,
24}: {
25 username: string;
26 profile: Profile | null;
27 error?: string;
28 saved: boolean;
29}) {
30 const busy = usePending({ intent: "profile" });
31 const [bio, setBio] = useState(profile?.bio ?? "");
32 const [timezone, setTimezone] = useState(profile?.timezone ?? "");
33 // The browser's zone is only known once the page runs in it.
34 const [browserZone, setBrowserZone] = useState<string | null>(null);
35 useEffect(() => setBrowserZone(browserTimeZone()), []);
36 const zones = useMemo(() => {
37 const now = Date.now();
38 return [
39 { value: "", label: "Not shown" },
40 ...timeZoneNames(profile?.timezone).map((zone) => ({
41 value: zone,
42 label: timeZoneLabel(zone),
43 description: utcOffset(zone, now) ?? undefined,
44 keywords: [zone],
45 })),
46 ];
47 }, [profile?.timezone]);
48 return (
49 <section id="profile" className="scroll-mt-20">
50 <div className="flex flex-wrap items-baseline justify-between gap-2">
51 <h2 className="font-medium">Public profile</h2>
52 <Link to={`/u/${username}`} className="text-sm text-muted underline-offset-4 hover:text-fg hover:underline">
53 View your profile
54 </Link>
55 </div>
56 <p className="mt-1 text-sm text-muted">
57 Shown to everyone at <span className="font-mono text-fg">g1t.sh/u/{username}</span>. All of it is
58 optional; your email address is never shown.
59 </p>
60 <Form method="post" className="mt-4 grid gap-4 sm:grid-cols-2">
61 <input type="hidden" name="intent" value="profile" />
62 <Field>
63 <FieldLabel htmlFor="profile-name">Name</FieldLabel>
64 <Input
65 id="profile-name"
66 name="name"
67 defaultValue={profile?.name ?? ""}
68 maxLength={PROFILE_LIMITS.name}
69 placeholder={username}
70 />
71 </Field>
72 <Field>
73 <FieldLabel htmlFor="profile-pronouns">Pronouns</FieldLabel>
74 <Input
75 id="profile-pronouns"
76 name="pronouns"
77 defaultValue={profile?.pronouns ?? ""}
78 maxLength={PROFILE_LIMITS.pronouns}
79 placeholder="they/them"
80 />
81 </Field>
82 <Field className="sm:col-span-2">
83 <FieldLabel htmlFor="profile-bio">Bio</FieldLabel>
84 <Textarea
85 id="profile-bio"
86 name="bio"
87 rows={2}
88 value={bio}
89 onChange={(event) => setBio(event.currentTarget.value)}
90 maxLength={PROFILE_LIMITS.bio}
91 placeholder="What you work on, in a line or two."
92 />
93 <FieldDescription>
94 {bio.length}/{PROFILE_LIMITS.bio} characters. Also what a link to your profile says.
95 </FieldDescription>
96 </Field>
97 <Field>
98 <FieldLabel htmlFor="profile-location">Location</FieldLabel>
99 <Input
100 id="profile-location"
101 name="location"
102 defaultValue={profile?.location ?? ""}
103 maxLength={PROFILE_LIMITS.location}
104 placeholder="Portland, Oregon"
105 />
106 </Field>
107 <Field>
108 <FieldLabel htmlFor="profile-website">Website</FieldLabel>
109 <Input
110 id="profile-website"
111 name="website"
112 inputMode="url"
113 defaultValue={profile?.website ?? ""}
114 maxLength={PROFILE_LIMITS.website}
115 placeholder="https://example.com"
116 />
117 <FieldDescription>An https:// address.</FieldDescription>
118 </Field>
119 <Field>
120 <FieldLabel htmlFor="profile-timezone">Time zone</FieldLabel>
121 <Combobox
122 id="profile-timezone"
123 name="timezone"
124 value={timezone}
125 onValueChange={setTimezone}
126 options={zones}
127 placeholder="Not shown"
128 searchPlaceholder="Find a city or region"
129 emptyText="No time zone by that name."
130 />
131 <FieldDescription>
132 The card over your name shows your local time.
133 {browserZone && browserZone !== timezone && (
134 <>
135 {" "}
136 <Button
137 type="button"
138 onClick={() => setTimezone(browserZone)}
139 variant="link"
140 size="inline"
141 className="font-normal"
142 >
143 Use my browser's time zone ({timeZoneLabel(browserZone)})
144 </Button>
145 </>
146 )}
147 </FieldDescription>
148 </Field>
149 <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
150 <SubmitButton pending="Saving…" match={{ intent: "profile" }}>
151 Save profile
152 </SubmitButton>
153 {saved && !busy && !error && (
154 <span className="inline-flex items-center gap-1.5 text-sm text-muted">
155 <Check size={14} className="text-success" />
156 Saved
157 </span>
158 )}
159 <FieldError>{error}</FieldError>
160 </div>
161 </Form>
162 </section>
163 );
164}