Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-a5b0d9e2733e9e382' | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.