flagon-io/g1t

public

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

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

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