Skip to content
103 linesCodeBlameRaw
1import { ImageUp, LoaderCircle, Trash2 } from "lucide-react";
2import { useRef } from "react";
3import { useFetcher } from "react-router";
4
5import type { ProductionImage } from "@g1t/contracts";
6
7import { PRODUCTION_IMAGE_ACCEPT } from "../lib/production-image";
8import { ErrorText, SubmitButton, usePending } from "./ui";
9import { Button } from "./ui/button";
10import { Card } from "./ui/card";
11import { SwitchCard } from "./ui/switch";
12
13/**
14 * A project's production image, in its settings: the one shown now, a
15 * button to pick a new one (sent as soon as it is picked), a way to remove
16 * it, and whether it is shown instead of a screenshot. Posts
17 * `intent=production-image` with the file in `image`,
18 * `intent=remove-production-image`, or `intent=prefer-production-image`
19 * with `preferred`.
20 */
21export function ProductionImageField({
22 image,
23 src,
24 screenshots,
25}: {
26 image: ProductionImage | null;
27 /** Where the image is served, when there is one. */
28 src: string | null;
29 /** Whether production gets a screenshot by itself: on g1t.page, or at an address elsewhere. */
30 screenshots: boolean;
31}) {
32 const upload = useFetcher<{ error?: string }>();
33 const prefer = useFetcher<{ error?: string }>();
34 const form = useRef<HTMLFormElement>(null);
35 const uploading = usePending({ intent: "production-image" }, upload);
36 const removing = usePending({ intent: "remove-production-image" }, upload);
37 const busy = uploading || removing;
38 // Shown as asked while it is being saved.
39 const preferred = prefer.formData ? prefer.formData.get("preferred") === "on" : (image?.preferred ?? false);
40 return (
41 <Card className="p-4">
42 <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
43 <div className="aspect-16/10 w-full shrink-0 overflow-hidden rounded-lg border border-line bg-bg sm:w-48">
44 {src ? (
45 <img src={src} alt="The uploaded image of production" width={1280} height={800} className="size-full object-cover object-top" />
46 ) : (
47 <div className="flex size-full items-center justify-center text-faint">
48 <ImageUp size={20} aria-hidden="true" />
49 </div>
50 )}
51 </div>
52 <div className="min-w-0 grow space-y-2">
53 <p className="text-sm text-muted">
54 {screenshots
55 ? "Shown on the overview while there is no screenshot of production, or instead of one."
56 : "Shown on the overview's production card. Give production's address, and a screenshot of it is taken by itself."}
57 </p>
58 <div className="flex flex-wrap items-center gap-2">
59 <upload.Form method="post" encType="multipart/form-data" ref={form}>
60 <input type="hidden" name="intent" value="production-image" />
61 <Button asChild variant="outline" className={busy ? "pointer-events-none opacity-50" : "cursor-pointer"}>
62 <label>
63 {uploading ? <LoaderCircle size={15} aria-hidden="true" className="animate-spin" /> : <ImageUp size={15} />}
64 {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
65 <input
66 type="file"
67 name="image"
68 accept={PRODUCTION_IMAGE_ACCEPT}
69 className="sr-only"
70 onChange={(event) => {
71 if (event.currentTarget.files?.length) form.current?.requestSubmit();
72 }}
73 />
74 </label>
75 </Button>
76 </upload.Form>
77 {image && (
78 <upload.Form method="post">
79 <input type="hidden" name="intent" value="remove-production-image" />
80 <SubmitButton fetcher={upload} variant="outline" pending="Removing…" match={{ intent: "remove-production-image" }} disabled={uploading}>
81 <Trash2 size={15} />
82 Remove
83 </SubmitButton>
84 </upload.Form>
85 )}
86 </div>
87 <p className="text-xs text-faint">PNG, JPEG or WebP, at most 5 MB. Wide images fit best, such as 1280 × 800.</p>
88 <ErrorText>{upload.data?.error ?? prefer.data?.error}</ErrorText>
89 </div>
90 </div>
91 {image && screenshots && (
92 <SwitchCard
93 className="mt-4"
94 title="Use the uploaded image instead of a screenshot"
95 checked={preferred}
96 onCheckedChange={(on) => prefer.submit({ intent: "prefer-production-image", preferred: on ? "on" : "" }, { method: "post" })}
97 >
98 No screenshot of production is taken while it is on.
99 </SwitchCard>
100 )}
101 </Card>
102 );
103}