g1t/apps/web/app/components/avatar-field.tsx

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