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.

75 linesCodeBlameRaw
1import { ImageUp, LoaderCircle, Trash2 } from "lucide-react";
2import { useRef } from "react";
3import { Form } from "react-router";
4
5import { AVATAR_ACCEPT } from "../lib/avatar-upload";
6import { ErrorText, SubmitButton, usePending } from "./ui";
7import { Avatar } from "./ui/avatar";
8
9/**
10 * Uploading an avatar, as GitHub's settings do: the one shown now, a
11 * button to pick a new image, which is sent as soon as it is picked, and
12 * a way back to the letter. Posts `intent=avatar` with the file in
13 * `avatar`, or `intent=remove-avatar`.
14 */
15export function AvatarField({
16 name,
17 image,
18 square,
19 error,
20 about,
21}: {
22 /** What the letter avatar is drawn from. */
23 name: string;
24 image?: string | null;
25 square?: boolean;
26 error?: string;
27 about: string;
28}) {
29 const form = useRef<HTMLFormElement>(null);
30 // Working until the new picture (or the letter) is the one shown.
31 const uploading = usePending({ intent: "avatar" });
32 const removing = usePending({ intent: "remove-avatar" });
33 const busy = uploading || removing;
34 return (
35 <div className="flex items-start gap-4">
36 <Avatar name={name} image={image} size={64} square={square} />
37 <div className="min-w-0 space-y-2">
38 <p className="text-sm text-muted">{about}</p>
39 <div className="flex flex-wrap items-center gap-2">
40 <Form method="post" encType="multipart/form-data" ref={form}>
41 <input type="hidden" name="intent" value="avatar" />
42 <label
43 className={`inline-flex cursor-pointer items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium ring-1 ring-line transition-colors hover:ring-line-strong has-focus-visible:ring-2 has-focus-visible:ring-accent ${
44 busy ? "pointer-events-none opacity-50" : ""
45 }`}
46 >
47 {uploading ? <LoaderCircle size={15} aria-hidden="true" className="animate-spin" /> : <ImageUp size={15} />}
48 {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
49 <input
50 type="file"
51 name="avatar"
52 accept={AVATAR_ACCEPT}
53 className="sr-only"
54 onChange={(event) => {
55 if (event.currentTarget.files?.length) form.current?.requestSubmit();
56 }}
57 />
58 </label>
59 </Form>
60 {image && (
61 <Form method="post">
62 <input type="hidden" name="intent" value="remove-avatar" />
63 <SubmitButton variant="outline" pending="Removing…" match={{ intent: "remove-avatar" }} disabled={uploading}>
64 <Trash2 size={15} />
65 Remove
66 </SubmitButton>
67 </Form>
68 )}
69 </div>
70 <p className="text-xs text-faint">PNG, JPEG, WebP or GIF, at most 1 MB. Square images look best.</p>
71 <ErrorText>{error}</ErrorText>
72 </div>
73 </div>
74 );
75}