About six weeks ago I started building a local web app to help me triage my photo archive — something like 10,000+ shots accumulated across a Leica Q3, Q2 Monochrom, and Hasselblad X2D. The idea was simple: scan a directory, run some technical analysis (blur, exposure, sharpness), hit the Anthropic API for AI critique, and give me a fast triage interface to work through the backlog. What followed was 29 sessions of building, breaking, debugging, and occasionally staring at a crash screen wondering what I’d done.

This post documents the development arc through screenshots — matched to actual dev log entries. No stock imagery, no mockups. Everything here is from real sessions, real bugs, and real code.


Session 2–3 · March 22 · The First Scan

The first real test: point the app at an actual photo directory and let it run. It found 8,169 files across three camera types, extracted EXIF data on every one, and started the technical quality check — sharpness, exposure, blur detection — in the background. The scan progress bar across the top was new that session. So was the category sidebar (EXCEPTIONAL / STRONG / AVERAGE / WEAK / TRASH), which populated as the analyzer worked through the queue.

First full scan running — 8,169 photos, 18% analyzed, 3 camera types detected. The sidebar categories and scan progress bar are both new this session.

Session 7 · March 22 · Triage Workflow: Move to Trash

One of the earliest triage features was the ability to trash a photo from within the app — not just mark it, but actually move it to .Trashes on whatever volume it lives on. This required volume-aware routing: files on an external USB drive should go to /Volumes/PRO-G40/.Trashes/[uid]/, not to ~/.Trash. The confirmation modal below asks before committing the move and shows the full destination path so there’s no ambiguity about where the file is going.

“Move to Trash?” confirmation showing the exact destination path — /Volumes/PRO-G40/.Trashes/. Volume-aware routing meant the app had to detect which drive a file lived on before deciding where to send it.

Sessions 15–17 · March 26 · AI Scoring + Content Tags

The critique pipeline was working. Each photo was getting an AI score (0–100), a category (EXCEPTIONAL down to TRASH), a written critique, and a set of content tags extracted from what the model could see in the image. The detail view here shows a score of 88 and an EXCEPTIONAL rating, with tags like urban, street, reflection alongside EXIF data and a button to open directly in Lightroom. This was the first time the full data pipeline — scan → technical analysis → AI critique → tag extraction — was running end to end on real photos.

Detail view showing a score of 88 EXCEPTIONAL, AI-generated content tags, full EXIF data, and the Open in Lightroom button. The full pipeline — scan, technical analysis, AI scoring, tag extraction — is running on real archive photos for the first time.

Session 10 (renumbered) · March 29 · The Pending Analysis View

The main gallery only showed photos that had already been analyzed. But what about the ones still waiting? A new Pending view made the analysis queue visible — photos that had been scanned but hadn’t yet been sent through the AI. Initially it loaded without the sidebar; a second fix in the same session added it back. Both screenshots are from the same 6:09 PM timestamp — about nine minutes apart — showing the before and after of that sidebar fix.

Pending view loads — but the sidebar is missing.
Nine minutes later — sidebar fixed, “Pending (76)” button visible.

March 29 · Bug: Stale Records + Native Browser Dialogs

Two bugs showed up back-to-back in the same session. First: the gallery was showing broken thumbnails for files that had been deleted from disk — “File not found” entries that cluttered the view and couldn’t be dismissed without triggering a purge. Second (and more annoying): clicking “Purge” triggered a native browser confirm() dialog — a JavaScript popup that blocked the entire UI until the user clicked OK. Both bugs are visible in the screenshots: the broken grid on the left, and the blunt browser confirm dialog that needed to be dismissed before anything else could happen.

The fix replaced native confirm() / alert() dialogs throughout the app with a proper in-app toast and modal system. The grid cleanup required a dedicated purge-stale-records endpoint that matched database entries against actual files on disk.

“File not found” thumbnails filling the grid — stale database records pointing to deleted files. A native browser confirm() is blocking the UI with “Removed 1 record.”
Same grid state — a second confirm() popup: “nothing to purge.” Native dialogs make it impossible to interact with the rest of the UI while they’re open.

Sessions 11–12 · March 29 / March 31 · The Scan Modal Gets a Browse Button

The Scan Directory modal originally had just a text field — you typed in a path manually. That’s fine if you know your exact mount path, but for navigating to a deeply nested archive folder it’s impractical. Sessions 11 and 12 added a Browse button that opens a native macOS file picker dialog. The before-and-after shows the old text-only UI (late March 29) and the new modal with Browse and a real-time progress bar (March 31).

Before — text field only. You had to know and type the exact directory path.
After — Browse button opens native macOS file picker. Scanning a GFX100RF folder with progress bar.

Sessions 11–12 · March 31 · Pending View Gets Real Thumbnails

Early versions of the Pending view listed photos as text rows — just filenames. The March 31 session added actual photo thumbnails to the queue, making it possible to make gut-level triage decisions before committing to a full AI analysis. The sidebar also gained a new Saved Edits (12) button tracking photos with user feedback on record.

Pending view with real photo thumbnails — each waiting-for-analysis photo is now visible before any AI processing. “Saved Edits (12)” appears in the sidebar for the first time.

Session 19 · April 5 · The Drive Migration Wizard

Moving a photo archive to a new drive is an operation that can silently break everything in a catalog-based app — every stored file path becomes wrong the moment the drive mounts at a different path. Session 19 built a 4-step Migration Wizard: Select source and destination, Copy files, Verify a sample of copied files against their originals with image comparison, and finally Update catalog to remap all stored paths.

Step 3 sampled 15 Hasselblad JPEGs and compared them pixel-level to their source counterparts. Step 4 did a find-and-replace on the path prefix across the entire database — PRO-G40 out, Crucial X10 in.

Step 3: Verify — 15 sampled Hasselblad JPEGs, all ✓ matching their source files.
The verification overlay — SOURCE on the left, DESTINATION on the right.
Step 4 hardened — the update form names the FujifilmX100v directory specifically as the path to remap.
Step 4 complete — PRO-G40 path remapped to Crucial X10 across the catalog.

Session 19 · April 5 · The Migration Crash

Not everything in Session 19 went cleanly. The migration wizard hit a 500 Internal Server Error mid-run — caught by the red banner in the screenshot below. Root cause: the UPDATE SQL referenced two columns, thumbnail_path and raw_pair_path, that don’t exist in the database schema. They never did. They were written from memory rather than from reading the actual schema, and SQLite doesn’t catch this kind of error gracefully during a batch UPDATE — it just crashes.

The fix was a 30-second schema read before writing any SQL. This session also surfaced a second silent bug: saved_copies.path was being skipped during the path update, which would have made all edited copies permanently unreachable post-migration with no error shown. Both bugs came from writing against assumptions instead of against observed code.

“Migration failed: Internal Server Error” — the red banner mid-migration. The UPDATE SQL referenced columns that don’t exist in the schema. A 30-second schema read before writing would have caught it.

Session 19 · April 5 · Post-Migration: 10,000+ Photos on a New Drive

Once the migration bugs were fixed, the full catalog loaded correctly on the new Crucial X10 drive — 10,027 shots, genre filter sidebar intact, all categories preserved. The detail view and feedback panel also survived the path remap cleanly.

Full catalog post-migration — 10,027 shots, B&W photos visible, genre filter sidebar on the left.
Detail view on the Crucial X10 — correct path, editable tags, “+ add” for new tag entry.
The full feedback panel — “YOUR ASSESSMENT” section with score override, category dropdown (including EDIT LATER), and free-text notes field.

Session 20 · April 6 · Flickr Import: 16,000 Photos Break the Filters

The app had been tested on a few thousand photos. Session 20 scanned an entire Flickr backup: 16,319 files from /flickrbackup. Total catalog size jumped to 20,000+. The scan itself completed fine — 16,318/16,319 detected. What broke was the sidebar filter counts. With that many records, the NOT TRIAGED count read as 29 when the actual number was 11,319. The bug was a pagination math error that only surfaced at this scale.

Flickr backup scan in progress — 16,318 of 16,319 files detected from /flickrbackup.
Filter counts broken — “Not triaged (29)” when 11,319 photos were actually unanalyzed. Pagination math failing at Flickr-import scale.

Sessions 21–22 · April 6 · Camera Filter Pills + Bulk Assign

With 20,000+ photos from 30+ different cameras, browsing by category alone wasn’t enough. Session 21 added camera filter pills to the sidebar — one pill per detected camera model — and fixed the filter count pagination. Session 22 added a bulk assign bar: select a camera from the filter pills, then assign a category to every unanalyzed photo from that device at once. The screenshot shows the full 20,915-shot catalog with both features working, the camera pills expanded in the sidebar and the bulk assign bar active at the bottom.

20,915 shots — camera filter pills in the sidebar (30+ devices), bulk assign bar at the bottom of the grid. Select a camera, assign a category to all its unanalyzed photos in one click.

Sessions 22–23 · April 6 · Duplicate Detection

Cross-directory duplicate detection landed in Session 22 — using EXIF timestamp + camera model as the primary signal, plus perceptual hashing (pHash) for cross-directory variant matching. When a duplicate pair is found, a modal shows both files side by side: this photo and its sibling, with file type, directory path, category, and score for each. The decision buttons let you trash one, trash the other, or keep both. The example below is L1000612 — the same shot from a Leica, one JPEG and one DNG, same timestamp, both scored as STRONG.

Duplicate review modal — L1000612.JPG (score 82.0, Strong) next to L1000612.DNG (score 84.0, Strong). Same EXIF timestamp + camera. Three options: Trash This One, Trash Sibling, Keep Both.

Sessions 23–28 · April 7 · Scanning a New Drive with RAW+JPEG Pair Detection

Sessions 23–28 covered Hasselblad multi-RAW pairing (.fff + .3fr triplets), perceptual hash cross-directory variant detection, and the DUP view for persistent duplicate dismissal. The April 7 screenshot shows the scan modal mid-run on /Volumes/PRO-G40/Autre shoot — 5,472 of 5,473 files found, with “Detecting RAW+JPEG pairs…” in the progress bar. That pairing step had been one of the harder problems: Hasselblad produces three files per shot, and the app needed to group them correctly before cataloging any of them.

Scan in progress on /Volumes/PRO-G40/Autre shoot — 5,472/5,473 files detected, actively detecting RAW+JPEG pairs. Hasselblad .fff+.3fr triplet grouping lands in this session range.

Where It Stands

Twenty-nine sessions in, the app does a lot: scan directories across multiple drives, run technical quality analysis, send photos through the Anthropic API for scored critique and content tagging, triage via a keyboard-driven gallery, move files to trash with volume-aware routing, detect and resolve duplicates, migrate an archive to a new drive without losing catalog data, and handle a 20,000-photo library without falling over. The proof-of-concept is working. The next question is whether it turns into something public.

Leave a Reply

Trending

Discover more from Sample The Web

Subscribe now to keep reading and get access to the full archive.

Continue reading