Skip to content
79 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 ? (
48 <LoaderCircle size={15} aria-hidden="true" className="animate-spin" />
49 ) : (
50 <ImageUp size={15} />
51 )}
52 {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
53 <input
54 type="file"
55 name="avatar"
56 accept={AVATAR_ACCEPT}
57 className="sr-only"
58 onChange={(event) => {
59 if (event.currentTarget.files?.length) form.current?.requestSubmit();
60 }}
61 />
62 </label>
63 </Form>
64 {image && (
65 <Form method="post">
66 <input type="hidden" name="intent" value="remove-avatar" />
67 <SubmitButton variant="outline" pending="Removing…" match={{ intent: "remove-avatar" }} disabled={uploading}>
68 <Trash2 size={15} />
69 Remove
70 </SubmitButton>
71 </Form>
72 )}
73 </div>
74 <p className="text-xs text-faint">PNG, JPEG, WebP or GIF, at most 1 MB. Square images look best.</p>
75 <ErrorText>{error}</ErrorText>
76 </div>
77 </div>
78 );
79}