| 1 | import { ImageUp, LoaderCircle, Trash2 } from "lucide-react"; |
| 2 | import { useRef } from "react"; |
| 3 | import { useFetcher } from "react-router"; |
| 4 | |
| 5 | import type { ProductionImage } from "@g1t/contracts"; |
| 6 | |
| 7 | import { PRODUCTION_IMAGE_ACCEPT } from "../lib/production-image"; |
| 8 | import { ErrorText, SubmitButton, usePending } from "./ui"; |
| 9 | import { Button } from "./ui/button"; |
| 10 | import { Card } from "./ui/card"; |
| 11 | import { 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 | */ |
| 21 | export 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 | } |