| import { useEffect, useRef, useState } from "react"; |
| import { api, ApiError } from "../api"; |
| import type { BreedEstimateResult, PhotoSearchMatch, PhotoSearchResult } from "../types"; |
| import { estimatedBreeds } from "./MatchCard"; |
| import { prepareImage } from "../lib/prepareImage"; |
| import BreedResults from "./BreedResults"; |
| import ComparePhotosModal, { photosFromUrls } from "./ComparePhotosModal"; |
| import SearchProgress, { MIN_PROGRESS_MS } from "./SearchProgress"; |
| import TabButton from "./TabButton"; |
| import { BreedNote, MatchingNote } from "./ToolNotes"; |
|
|
| type Mode = "match" | "breed"; |
| type Pool = "found" | "lost"; |
|
|
| interface PickedImage { |
| file: File; |
| |
| url: string | null; |
| } |
|
|
| const MAX_IMAGES = 6; |
|
|
| |
| function describeRequestError(err: unknown): string { |
| if (err instanceof ApiError) { |
| |
| if (err.status === 400) return err.message || "That image couldn't be read."; |
| if (err.status === 413) return "That image is too large to upload. Try a smaller one."; |
| if (err.status === 429) return "Too many requests just now. Give it a few seconds and retry."; |
| if (err.status >= 500) return "The server had a problem running the model. Please try again."; |
| return err.message; |
| } |
| |
| return "The upload didn't complete. Check your connection and try again."; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export default function PhotoTools({ |
| lockedPool, |
| haystackHint, |
| }: { lockedPool?: Pool; haystackHint?: number } = {}) { |
| const [images, setImages] = useState<PickedImage[]>([]); |
| const BREED_COUNT = 10; |
| const [pool, setPool] = useState<Pool>(lockedPool ?? "found"); |
| const [dragging, setDragging] = useState(false); |
| const [preparing, setPreparing] = useState(false); |
| const [tab, setTab] = useState<Mode>("match"); |
| const [running, setRunning] = useState<Mode | null>(null); |
| const [error, setError] = useState<string | null>(null); |
| const [matchData, setMatchData] = useState<PhotoSearchResult | null>(null); |
| const [breedData, setBreedData] = useState<BreedEstimateResult | null>(null); |
| const [active, setActive] = useState<PhotoSearchMatch | null>(null); |
|
|
| |
| const imagesRef = useRef(images); |
| imagesRef.current = images; |
| useEffect( |
| () => () => imagesRef.current.forEach((i) => i.url && URL.revokeObjectURL(i.url)), |
| [] |
| ); |
|
|
| |
| |
| |
| |
| async function addImages(list: FileList | File[] | null) { |
| if (!list) return; |
| const incoming = Array.from(list); |
| if (incoming.length === 0) return; |
| setError(null); |
| setPreparing(true); |
| try { |
| const room = MAX_IMAGES - images.length; |
| const picked: PickedImage[] = []; |
| let fellBack = 0; |
| for (const file of incoming.slice(0, room)) { |
| try { |
| const prepared = await prepareImage(file); |
| picked.push({ file: prepared, url: URL.createObjectURL(prepared) }); |
| } catch { |
| |
| |
| picked.push({ file, url: null }); |
| fellBack++; |
| } |
| } |
| if (picked.length > 0) { |
| setImages((prev) => [...prev, ...picked]); |
| setMatchData(null); |
| setBreedData(null); |
| } |
| if (fellBack > 0) { |
| setError( |
| `${fellBack === 1 ? "One image" : `${fellBack} images`} couldn't be previewed in this ` + |
| "browser, but will still be searched. If it fails, try a JPEG or PNG." |
| ); |
| } |
| } finally { |
| setPreparing(false); |
| } |
| } |
|
|
| function removeImage(idx: number) { |
| setImages((prev) => { |
| const gone = prev[idx].url; |
| if (gone) URL.revokeObjectURL(gone); |
| return prev.filter((_, i) => i !== idx); |
| }); |
| setMatchData(null); |
| setBreedData(null); |
| } |
|
|
| async function run(mode: Mode) { |
| if (images.length === 0) return; |
| setRunning(mode); |
| setError(null); |
| const start = Date.now(); |
| try { |
| if (mode === "match") { |
| const d = await api.searchByPhoto( |
| images.map((i) => i.file), |
| undefined, |
| pool |
| ); |
| const wait = MIN_PROGRESS_MS - (Date.now() - start); |
| if (wait > 0) await new Promise((r) => setTimeout(r, wait)); |
| setMatchData(d); |
| } else { |
| const d = await api.estimateBreed( |
| images.map((i) => i.file), |
| BREED_COUNT |
| ); |
| const wait = MIN_PROGRESS_MS - (Date.now() - start); |
| if (wait > 0) await new Promise((r) => setTimeout(r, wait)); |
| setBreedData(d); |
| } |
| } catch (err) { |
| setError(describeRequestError(err)); |
| } finally { |
| setRunning(null); |
| } |
| } |
|
|
| const busy = running !== null || preparing; |
| const hasImages = images.length > 0; |
|
|
| return ( |
| <section className="card mt-4"> |
| <h2 className="text-xl font-display font-semibold">Try it with an image</h2> |
| <p className="text-sm text-gray-600 mt-1"> |
| Upload up to 6 images of a dog, and search for look-alikes in the found-dog database or |
| estimate its breed. Pictures from more angles help the matching process. Use clear photos of |
| a single dog. Both models only know dogs, giving them anything else will return a meaningless |
| answer. |
| </p> |
| |
| <input |
| id="pt-file" |
| type="file" |
| accept="image/*" |
| multiple |
| className="sr-only" |
| onChange={(e) => addImages(e.target.files)} |
| /> |
| |
| {!hasImages ? ( |
| // Big dropzone before anything is picked. |
| <label |
| htmlFor="pt-file" |
| onDragOver={(e) => { |
| e.preventDefault(); |
| setDragging(true); |
| }} |
| onDragLeave={() => setDragging(false)} |
| onDrop={(e) => { |
| e.preventDefault(); |
| setDragging(false); |
| addImages(e.dataTransfer.files); |
| }} |
| className={`mt-5 block w-full cursor-pointer rounded-xl border-2 border-dashed px-6 py-14 text-center transition ${ |
| dragging |
| ? "border-leaf-500 bg-leaf-50" |
| : "border-brand-400 bg-gray-50 hover:border-leaf-400 hover:bg-leaf-50/50" |
| }`} |
| > |
| <p className="text-lg font-semibold text-gray-800">Upload images of your dog</p> |
| <p className="mt-1 text-sm text-gray-500">Click to choose up to 6, or drag them here</p> |
| <p className="mt-2 text-xs text-gray-400">Nothing will be saved to the database.</p> |
| </label> |
| ) : ( |
| // Thumbnail grid + an add-more tile once at least one image is picked. |
| <div className="mt-5"> |
| <div className="flex flex-wrap justify-center gap-3"> |
| {images.map((img, i) => ( |
| <div |
| key={`${img.file.name}-${i}`} |
| className="relative h-32 w-32 sm:h-44 sm:w-44 overflow-hidden rounded-lg border border-gray-200 bg-gray-100 shadow-sm" |
| > |
| {img.url ? ( |
| <img |
| src={img.url} |
| alt={`Your dog, image ${i + 1}`} |
| className="h-full w-full object-cover" |
| /> |
| ) : ( |
| <div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center"> |
| <span className="text-xs font-medium text-gray-500">No preview</span> |
| <span className="text-[10px] text-gray-400 break-all">{img.file.name}</span> |
| </div> |
| )} |
| {running ? ( |
| <> |
| <div className="absolute inset-0 bg-leaf-500/10" aria-hidden /> |
| <div |
| className="scan-sweep absolute inset-x-0 h-0.5 bg-leaf-400 shadow-[0_0_10px_2px] shadow-leaf-400/80" |
| aria-hidden |
| /> |
| </> |
| ) : ( |
| <button |
| type="button" |
| onClick={() => removeImage(i)} |
| aria-label={`Remove image ${i + 1}`} |
| className="absolute top-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-sm leading-none text-white hover:bg-black/80" |
| > |
| × |
| </button> |
| )} |
| </div> |
| ))} |
| {images.length < MAX_IMAGES && !running && ( |
| <label |
| htmlFor="pt-file" |
| onDragOver={(e) => { |
| e.preventDefault(); |
| setDragging(true); |
| }} |
| onDragLeave={() => setDragging(false)} |
| onDrop={(e) => { |
| e.preventDefault(); |
| setDragging(false); |
| addImages(e.dataTransfer.files); |
| }} |
| className={`flex h-32 w-32 sm:h-44 sm:w-44 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed text-center transition ${ |
| dragging |
| ? "border-leaf-500 bg-leaf-50" |
| : "border-brand-400 bg-gray-50 hover:border-leaf-400 hover:bg-leaf-50/50" |
| }`} |
| > |
| <span className="text-3xl leading-none text-gray-400">+</span> |
| <span className="mt-1.5 px-2 text-xs text-gray-500">Add more</span> |
| </label> |
| )} |
| </div> |
| <p className="mt-3 text-center text-xs text-gray-400"> |
| {preparing |
| ? "Preparing images…" |
| : `${images.length} of ${MAX_IMAGES} images · nothing will be saved to the database`} |
| </p> |
| </div> |
| )} |
|
|
| {} |
| <div className="mt-6"> |
| <div |
| role="tablist" |
| aria-label="Image tools" |
| className="flex justify-center gap-1 border-b border-gray-200" |
| > |
| <TabButton |
| active={tab === "match"} |
| onClick={() => { |
| setTab("match"); |
| setError(null); |
| }} |
| > |
| Image matching |
| </TabButton> |
| <TabButton |
| active={tab === "breed"} |
| onClick={() => { |
| setTab("breed"); |
| setError(null); |
| }} |
| > |
| Breed estimation |
| </TabButton> |
| </div> |
|
|
| <div className="pt-4"> |
| {tab === "match" ? ( |
| <div> |
| {!lockedPool && ( |
| <div className="text-center"> |
| <span className="label block mb-1">What are you searching?</span> |
| <div className="inline-flex rounded-md border border-gray-200 overflow-hidden"> |
| <button |
| type="button" |
| className={`px-3 py-2 text-sm ${ |
| pool === "found" ? "bg-brand-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50" |
| }`} |
| aria-pressed={pool === "found"} |
| onClick={() => setPool("found")} |
| > |
| I lost my dog |
| </button> |
| <button |
| type="button" |
| className={`px-3 py-2 text-sm border-l border-gray-200 ${ |
| pool === "lost" ? "bg-brand-500 text-white" : "bg-white text-gray-600 hover:bg-gray-50" |
| }`} |
| aria-pressed={pool === "lost"} |
| onClick={() => setPool("lost")} |
| > |
| I found a dog |
| </button> |
| </div> |
| <p className="text-xs text-gray-500 mt-1"> |
| {pool === "found" |
| ? "Searches found & unclaimed dogs in the system for a look-alike." |
| : "Searches currently-lost registered dogs to help find the owner."} |
| </p> |
| </div> |
| )} |
| <button |
| type="button" |
| className={`btn-primary mx-auto block px-8 py-3 text-base ${lockedPool ? "" : "mt-4"}`} |
| disabled={!hasImages || busy} |
| onClick={() => run("match")} |
| > |
| {running === "match" ? "Searching…" : "Find matches"} |
| </button> |
| <MatchingNote /> |
| {running === "match" && ( |
| <div className="mt-4"> |
| <SearchProgress mode="match" count={haystackHint} /> |
| </div> |
| )} |
| {matchData && <MatchResults data={matchData} onOpen={setActive} />} |
| </div> |
| ) : ( |
| <div> |
| <button |
| type="button" |
| className="btn-primary mx-auto block px-8 py-3 text-base" |
| disabled={!hasImages || busy} |
| onClick={() => run("breed")} |
| > |
| {running === "breed" ? "Estimating…" : "Estimate breed"} |
| </button> |
| <BreedNote /> |
| {images.length > 1 && ( |
| <p className="text-xs text-gray-400 mt-3 text-center"> |
| Averaged across all {images.length} images, since a single photo can easily flip the |
| top breed. |
| </p> |
| )} |
| {running === "breed" && ( |
| <div className="mt-4"> |
| <SearchProgress mode="breed" /> |
| </div> |
| )} |
| {breedData && <BreedResults data={breedData} />} |
| </div> |
| )} |
| </div> |
| </div> |
|
|
| {error && <p className="text-red-600 text-sm mt-3">{error}</p>} |
|
|
| {active && ( |
| <ComparePhotosModal |
| match={active} |
| queryPhotos={photosFromUrls(images.map((i) => i.url).filter((u): u is string => !!u))} |
| queryLabel={`Your image${images.length > 1 ? "s" : ""}`} |
| onClose={() => setActive(null)} |
| /> |
| )} |
| </section> |
| ); |
| } |
|
|
| function pct(score: number): string { |
| return `${(score * 100).toFixed(1)}%`; |
| } |
|
|
| function MatchResults({ |
| data, |
| onOpen, |
| }: { |
| data: PhotoSearchResult; |
| onOpen: (m: PhotoSearchMatch) => void; |
| }) { |
| const noun = data.pool === "lost" ? "lost dog" : "found dog"; |
| return ( |
| <div className="mt-6"> |
| <h3 className="font-semibold mb-2">Closest {noun}s</h3> |
| {data.results.length === 0 ? ( |
| <p className="text-gray-500 text-sm"> |
| No {noun}s to compare against yet. {" "} |
| {data.pool === "lost" |
| ? "Report the dog as found so we keep watching for the owner." |
| : "Report your dog as lost so we keep watching for new matches."} |
| </p> |
| ) : ( |
| <> |
| <p className="text-xs text-gray-500 mb-2"> |
| Closest of {data.candidate_count} {noun}(s) |
| {data.zip ? ` near ${data.zip}` : ""}. Tap a card to see all its images. The scores measure |
| image similarity only, so always confirm with a person. |
| </p> |
| <div className="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> |
| {/* Ten, to match the "top 10" figure quoted under the button. */} |
| {data.results.slice(0, 10).map((m) => ( |
| <ResultCard key={`${m.dog.kind}-${m.dog.id}`} match={m} onOpen={() => onOpen(m)} /> |
| ))} |
| </div> |
| </> |
| )} |
| </div> |
| ); |
| } |
|
|
| function ResultCard({ match, onOpen }: { match: PhotoSearchMatch; onOpen: () => void }) { |
| const { dog } = match; |
| const thumb = match.photos[0]?.url || dog.thumb_url || "/paw-placeholder.svg"; |
| return ( |
| <button className="card text-left hover:ring-2 hover:ring-brand-300" onClick={onOpen}> |
| <div className="relative"> |
| <img src={thumb} alt={dog.name} className="h-40 w-full rounded-md object-contain bg-gray-100" /> |
| <span className="badge absolute top-2 left-2 bg-brand-600 text-white">{pct(match.score)} match</span> |
| {dog.picture_count > 1 && ( |
| <span className="badge absolute top-2 right-2 bg-black/60 text-white"> |
| {dog.picture_count} images |
| </span> |
| )} |
| </div> |
| <div className="mt-2"> |
| <h3 className="font-semibold truncate" title={dog.name}> |
| {dog.name} |
| </h3> |
| <p className="text-xs text-gray-500 truncate">{estimatedBreeds(dog.predicted_breeds)}</p> |
| <p className="text-xs text-gray-400 mt-0.5"> |
| {dog.zip ? `ZIP ${dog.zip}` : "no ZIP"} |
| {match.distance_miles != null ? ` · ${match.distance_miles} mi away` : ""} |
| </p> |
| </div> |
| </button> |
| ); |
| } |
|
|