Skip to content

Add mobile photo capture with client-side image downscaling - #249

Open
canni wants to merge 5 commits into
sassanix:mainfrom
canni:feat/mobile-photo-capture
Open

Add mobile photo capture with client-side image downscaling#249
canni wants to merge 5 commits into
sassanix:mainfrom
canni:feat/mobile-photo-capture

Conversation

@canni

@canni canni commented Jul 19, 2026

Copy link
Copy Markdown

I run Warracker on my home server and it replaced my own warranty tracker app, but the one thing that kept bugging me was adding receipts from the phone. Right after a purchase you want to snap the receipt and be done, instead the flow was: open camera app, take photo, remember to resize it (phone photos are 8-15MB, easy to hit MAX_UPLOAD_MB), upload through the file picker, then go delete the receipt from the gallery. This PR fixes that whole path.

What it does:

  • adds a "Take Photo" button next to Choose Photo (add form + both edit modals, index and status). It's a hidden <input accept="image/*" capture="environment">, so on mobile the camera opens directly and the photo never lands in the gallery
  • client-side downscaling of product photos before upload: max 2048px on the longest edge, JPEG q0.85. EXIF orientation is handled, transparency gets flattened on white, animated GIFs pass through untouched, and if the result would be larger than the original the original wins. Any decode error just falls back to the original file, downscaling never blocks an upload
  • applies to gallery picks too, not only camera capture, so large desktop uploads shrink as well

Implementation notes:

  • everything hooks into the change event and swaps the file back into the named input via DataTransfer, so both submit paths (add form FormData, edit save reading files[0]) pick it up without touching any submit code. The swap re-fires change with the new File, there's a comment at both dispatch sites since listeners see two events per selection
  • the button is capability gated, if the browser has no DataTransfer constructor it's hidden instead of failing after the user took a photo. Labels are keyboard operable
  • warranties.take_photo added to all 20 locale files
  • cache busting follows the repo convention (?v= bump in both html files + sw.js precache + CACHE_NAME)

Testing: this exact branch (well, the same commits on a 1.0.2 base) has been running on my own instance for a few days with real phone use, and I have a Playwright harness that drives both the add and edit flow headless against a docker compose stack, asserting the photo actually lands in the DB downscaled. Happy to share the harness script if you want it in the repo.

Known limitation: if someone picks a file the browser can't decode (e.g. HEIC on a non-Safari desktop) it passes through unconverted and the backend rejects the extension, same as today. In practice camera captures are always JPEG so this doesn't bite on the main path.

Your contributing notes say to discuss in an issue first, sorry for jumping straight to code, this started as a patch for my own instance and grew into something shareable. Happy to split or rework whatever doesn't fit, e.g. if you'd rather have this in the frontend/js component layer I can move it there.

canni added 5 commits July 18, 2026 17:18
Root cause: the camera change handler assigned cameraInput.files to the named
input directly - both inputs then shared ONE FileList object, and the
cameraInput.value = '' reset on the next line emptied that shared list, so the
photo was gone before submit. Copying through a new DataTransfer decouples the
two inputs while keeping the synchronous no-async-gap population.
- capability-gate the Take Photo button when DataTransfer is unavailable
  (hide it rather than fail silently after the user takes a photo)
- normalize empty-MIME camera captures to image/jpeg so previews render
- pass animated GIFs through untouched instead of flattening to JPEG
- make camera labels keyboard-operable (tabindex + Enter/Space)
- dedupe the DataTransfer copy into trySetFiles; single camera-input reset
- drop the redundant generation counter (files[0] identity check subsumes it)
  and the duplicated listener-level MIME check
- mark pass-through files in the WeakSet to skip re-decoding on re-fired events
- free the full-res bitmap before JPEG encode and release the canvas store
- document the double-fire change-event contract at both dispatch sites
- index.html: Choose Photo icon fa-camera -> fa-upload (match status.html)
- bump asset version to 20260719002
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant