Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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