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