Replace page-number walking with cursor chaining in usePhotosQuery.
Each response includes a next_cursor that seeks directly to the next
slice via an indexed range scan — O(1) regardless of depth instead of
OFFSET-based skipping that degrades on large libraries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.
Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup
Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rolls the standalone "Date issues" toggle back into FlagFilter as a
third value ('any' | 'discarded' | 'date_warning') so the date-warning
control lives in the same popover as Discarded, where operators expect
all flag-style filters. Drops the redundant dateWarning boolean and
its URL param.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Timeline-based grouped views (tags, colors, rated) with
dedicated card-grid components that drill into Timeline detail views
on click/Enter. Adds shared useCardGridNav hook for arrow-key
navigation across all four card grids (tags, colors, rated, people).
- TagsView, ColorsView, RatedView: card grid → inline Timeline detail
- PeopleView: migrated to same pattern (Timeline replaces custom grid)
- Tags endpoint: fall back to first associated photo for representative
- Filter store: add ratingMax for exact rating filtering in RatedView
- Timeline: remove tag/rating/color grouping; skip date headers when
groupBy != 'date' so detail views render flat grids
- SettingsDialog: bump z-index above Leaflet map layers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add search API client (POST /photos/search) and useSearchQuery hook
for hybrid FTS + semantic search with RRF ranking
- Extend Tag type with kind, source, representative_photo_id fields
- Add tags.merge() API method
- Update useTagsQuery to accept optional kind filter
- Add People section to sidebar (face clusters from GET /tags?kind=face_cluster)
- Sidebar Tags count now shows user tags only; People shows face clusters
The existing GET /photos?q= flow is preserved for browsing; the new
search hook activates when the search box has a non-empty query.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Discard yanks photos from the grid optimistically (cache strip + active
cursor advance) instead of waiting for the mutation round-trip; wired
through the X hotkey, RightSidebar bulk discard, LeftSidebar discard
drop, and DiscardActionBar restore/delete.
- usePhotosQuery resolves on the first 500-photo page and streams the
remaining pages into the cache in the background, so the first
thumbnails paint immediately on large libraries.
- Closing preview restores the photo it was opened on (snapshot ref in
PreviewView, written directly to the store) and Timeline scrolls that
row back into view. Escape is handled on the dialog with
stopPropagation so Timeline's window-level Esc handler doesn't wipe
the restored selection.
- Preview overlay bumped to z-[1000] so it covers Leaflet map tiles,
and the right sidebar no longer collapses during preview — both fix
visible layout shifts on close.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LIBRARY_STATS_QUERY_KEY was never invalidated by the bulk/single
mutation paths in RightSidebar and PhotoInfoPanel, so the All Photos
/ Rated / Discarded / Tags badges only updated on reload. Add it to
both shared invalidation helpers and the inline updateMutation, and
correct the stale docstring on useLibraryStatsQuery.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.
Detection
- New phash + duplicate_group_id columns on Photo, added via an
idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
decoded frame just before the destructive thumbnail loop. Falls back
silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
predated the column, reading the existing thumb_large rather than
re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
(threshold 6), persists duplicate_group_id, and maintains is_duplicate
as derived state so existing badges/counts keep working. Chained
after scan_all_source_roots with a 60s countdown.
API
- GET /library/duplicates/groups returns all groups with members,
bucketed in Python from one query. Each group has a reason ("exact"
iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.
Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
the timeline when the user is in the duplicates section. Each section
shows a "Keep best, discard N" button that picks the highest-pixel
copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
this" (Crown icon, top-right) to override the auto-pick. The header
annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
with ↑/↓ jumping by the measured column count and scrollIntoView on
every move. Timeline's keyboard handler now early-returns in the
duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
they don't collide visually with the cyan selection ring around a
selected cell. Dimensions chip moved to bottom-left to free both
right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
and exposes both backfill + re-detect actions, sharing a query cache
with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two unrelated bugs surfaced together because the symptom looked
similar ("missing photos in the grid"):
usePhotosQuery only ever fetched page 1 with per_page=500, so any
filter matching more than 500 photos silently truncated. With a
13k-photo library that meant the Timeline only showed ~3.8% of
matches and folders with many descendants looked broken. Walks all
pages now (capped at 200 = 100k photos as a sanity bound), keying
the React Query cache on the full filter set as before.
Timeline grid was leaving an unused horizontal strip on the right.
Two issues in the column math:
- off-by-one: floor((W - 2P) / (T + G)) double-counts gaps. With N
columns there are only N-1 inter-cell gaps, so the correct form
is floor((W - 2P + G) / (T + G)). Reclaims a column whenever the
remainder almost fits.
- the floor remainder was discarded instead of distributed back
into the cells. Treat THUMBNAIL_SIZE as a minimum and stretch
each cell to (available - (cols-1)*gap) / cols so the row fills
the container.
Also swap the resize listener for a ResizeObserver on the scroll
container so the grid re-flows when the sidebar collapses (window
resize alone misses that).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two related bugs around visible vs API order.
1. Multi-select range selection (Shift+Click, Shift+Arrow):
- The previous selectRange walked the API photos array and only
ADDED to the existing selection, never replacing or shrinking. So
Shift+clicking to the left often "did nothing" (already-selected
ids skipped) and the selection never matched the user's intended
range.
- Replace with a store action that walks visiblePhotoIds (the visual
row-major sequence Timeline already publishes), de-dupes ids
(tag-grouped views can repeat photos), and REPLACES the selection.
- Track the range anchor as rangeStartId (a photo id) instead of an
index so it survives filter changes and works correctly when API
index != visual position.
- Drop the now-redundant lastSelectedIndex / globalIndex plumbing
from selectPhoto / togglePhotoSelection — call sites simplify to
pass just the photo id.
2. Preview navigation after pressing Space:
- The Space hotkey path called openPreview(id) without a sequence
and relied on the store's fallback to whatever Timeline most
recently published. Make it explicit: read visiblePhotoIds from
the store snapshot at fire time and pass it through. Same effect
in the happy case but eliminates any subtle publisher timing
question.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the preview view walked the raw API photos array for arrow
navigation and the filmstrip. In tag-grouped mode (and any future
layout where the visible grid order diverges from the API sort) that
diverged from the order the user actually saw — they'd hit ← / → and
land on a photo that wasn't adjacent in the grid.
Fix: Timeline publishes its flat visible-order id sequence into the
photo store as visiblePhotoIds whenever its layout items change
(including duplicates from tag buckets, which is what the user wants
in tag mode — landing on a photo's second appearance in the next
bucket is the right behavior). PreviewView resolves that sequence
back to Photo objects via the rawPhotos map and uses the result for
both arrow nav and the filmstrip. Falls back to the raw photos list
when the sequence isn't populated yet.
Also clean up the lingering hardcoded http://localhost:8001 in
usePhotosQuery — switched to the shared axios instance with the
relative /api/v1 baseURL so the hook works cross-machine through the
nginx / vite proxy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FilterPill: drop the inline value text from the active state. Pills
now stay the same width whether or not a filter is set; the popover
is the canonical place to read the value, and the title attribute
surfaces it on hover.
- TopBar: remove the search input — search lives in the filter bar now.
- FilterBar: add a search input on the left, with the pill cluster
centered between it and a flex-shrink-0 Clear-all on the right.
- LeftSidebar / HeapsPanel: count badges use a fixed-width slot
(h-5 min-w-[24px], tabular-nums) so counts line up in the same
visual column across rows. Empty rows reserve the slot.
- LeftSidebar: pull section counts (All Photos, Rated, Duplicates,
Discarded) from a new useLibraryStatsQuery hook backed by the
expanded /library/stats endpoint. Tags count was already wired.
- backend/library: stats endpoint returns per-section counts that
match the filter the sidebar applies on click.
- Stats invalidation hooked into the standard photo-mutation paths.
- RightSidebar header: h-12 to match TopBar height.
- Timeline sticky date overlay: only show once the natural in-grid
header has scrolled OUT of the viewport. Avoids the duplicate-label
flash when both labels would be visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- backend/photos: collapse the per-tag subquery loop in the tag filter
into a single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the
cost is independent of how many tags the user is filtering on.
- useFilterUrlSync: type the parseUrl return value as
Partial<FilterState> & { currentSection?: string } so the section field
doesn't need an (out as any) cast.
- Timeline sticky header: bump opacity, padding, and border so it reads
more clearly against the underlying grid.
- FilterPill clear: convert the nested <button> (invalid HTML — buttons
cannot nest) to a span with role=button + keyboard handler, with a
larger hit area.
- RightSidebar: add aria-label to the close-X buttons so screen readers
announce them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a global last-action stack with toast-based "Undo" buttons and a
Cmd/Ctrl+Z hotkey for the destructive photo operations.
Reversible:
- X (discard) → bulkRestore
- U (restore) → bulkDiscard
- Drag-onto-Discarded → bulkRestore
- Drag-onto-folder (move) → move back to per-photo source folders. The
source folder ids are snapshotted from the photos cache before the
move runs, then grouped so multi-source moves restore correctly.
- Restore button in the discard action bar → bulkDiscard
Toast gains an optional action button (label + onClick); toasts with an
action stay visible longer so the user has time to click. The undo
store caps at 20 entries; failed undo re-pushes the entry so the user
can try again.
Not reversible (call out, document later): rating, color label, copy,
permanent delete from trash, tag changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract the single-photo body of RightSidebar into a reusable PhotoInfoPanel
(rating / color / flag / filename / title / notes / tags / EXIF) and mount
it inside PreviewView as a toggleable right-side overlay so the user can
rate, tag, and read EXIF without leaving the loupe.
- New PhotoInfoPanel: self-contained, owns its own queries and mutations,
takes a single photoId. darkTheme prop reserved for future use.
- RightSidebar: thinned down — delegates the single-select case to
PhotoInfoPanel, keeps its own slim bulk-action panel for multi-select.
- PreviewView: I toggles the panel; new top-right Info button mirrors it.
- useKeyboardShortcuts: gate the global I (right-sidebar toggle) to grid
mode so it doesn't double-fire alongside the preview-scoped handler.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge the toggleable multi-line FilterBar and the separate ActiveFilterChips
strip into a single always-visible row of pills. Each filter category is a
pill that opens a small popover with its underlying control; when active, the
pill shows its current value inline (so the chips strip is redundant).
- New FilterPill primitive: outside-click + Escape to close, optional inline
X to clear without opening the popover.
- FilterBar rebuilt out of pills for Date/Type/Rating/Color/Flag/Tags/Sort,
with a Clear-all pill on the right when any filter is active.
- Drop filterBarOpen from filterStore, the SlidersHorizontal toggle from
TopBar, the \\ shortcut from useKeyboardShortcuts, and the matching hint
from KeyboardHints — the bar is always visible now.
- Delete ActiveFilterChips; its information lives inside the pills.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Culling actions used to operate on a single photo (the activePhotoId)
even when many were selected — pressing 5 with ten thumbnails high-
lighted only rated one. Same for the RightSidebar buttons, which
weren't even visible in multi-select mode. Lightroom semantics: every
culling action applies to the whole selection.
Fix
- Three new bulk helpers in services/api.ts:
photos.bulkSetRating(ids, rating)
photos.bulkSetColor(ids, color | null)
(existing photos.bulkDiscard / bulkRestore reused for X / U)
All matching the backend BulkAction { ids, action, value } shape
the /photos/bulk endpoint already accepts.
useKeyboardShortcuts
- New cullTargets() helper: selectedPhotos if non-empty, else
activePhotoId in a singleton, else empty.
- updateActive() now branches on cullTargets().length:
1 → existing PATCH /photos/{id} path (single-photo).
2+ → fans out to the right bulk endpoint per field. rating goes
to bulkSetRating, color_label to bulkSetColor, is_discarded
to bulkDiscard / bulkRestore.
- 1-5 / 0 / X / U / 6-9 shortcuts now Just Work on multi-select
without further changes — they all funnel through updateActive.
RightSidebar
- Restructured the Quick Actions block: filename / title / notes are
hidden in multi-select (they only make sense for one photo); but
rating / color / flag controls are now always visible when at
least one photo is selected. A small "Rating, color, and flag
apply to all N selected" hint shows in multi mode.
- New applyRating / setColor / applyDiscard helpers fan out to the
bulk endpoints when selectedPhotos.length > 1, otherwise hit the
per-photo PATCH path. The displayed value still reflects the
active photo (last clicked) so the user has a visual anchor —
matches Lightroom's "focused vs selected" model.
- Pick/Heap-toggle button is now selection-aware too: heapMutation
takes ids[], the click handler reads selectedPhotos, and the
add-vs-remove decision uses "every selected is a member" exactly
like the P keyboard shortcut. Optimistic membership cache update
also flips the basket badge across all selected thumbnails
instantly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filters were global — switching from "Discarded" to a folder kept the
discarded flag, switching from a heap to All Photos kept the heap
filter, etc. Confusing because the user couldn't tell what state any
section would be in until they got there.
Now each "section" remembers its own filter state independently. The
in-memory map is keyed by section id ('all-photos', 'rated',
'discarded', 'duplicates', 'tags', 'folder-{id}', 'heap-{id}'), and
navigating saves the current section's state under its id and
restores the destination's. Sections you've never visited start with
their intrinsic preset on top of INITIAL_FILTERS.
filterStore additions
- currentSection: string (default 'all-photos')
- sectionFilters: Record<sectionId, FilterState> — in-memory snapshots
- sectionPresets: Record<sectionId, Partial<FilterState>> — the
intrinsic filter that defines each section, used by clearAll
- navigateToSection(id, presetOverrides):
1. snapshot the current FilterState slice into sectionFilters[
currentSection]
2. record presetOverrides in sectionPresets[id]
3. set currentSection = id
4. load sectionFilters[id] if a saved snapshot exists, otherwise
apply presetOverrides on top of INITIAL_FILTERS
- clearAll: now resets the CURRENT section to its preset rather than
jumping to all-photos. The user explicitly clicks All Photos to
navigate.
- snapshotFilters() helper extracts the FilterState slice cleanly so
control fields (filterBarOpen, the maps themselves) don't leak
into per-section state.
URL sync
- writeUrl serialises currentSection as ?section=… (omitted for the
default 'all-photos').
- parseUrl reads it back into currentSection on hydrate. Per-section
memory is in-memory only; reload restores the current view but
not the other sections' saved states (acceptable for MVP).
LeftSidebar
- applyLibraryNode now dispatches navigateToSection per node, with
the appropriate preset:
all-photos → {}
rated → { ratingMin: 1 }
discarded → { flag: 'discarded' }
duplicates → { duplicates: true }
tags → { groupBy: 'tag' }
folder-X → { folderId: X }
- isItemActive collapses to a single check against currentSection
for both library nodes and folder rows. Dropped the old
selectedItem local state and the per-field active probes; they
were doing the same job in a more fragile way.
HeapsPanel
- Heap row click → navigateToSection(`heap-${id}`, { heapId: id })
- isFiltered uses currentSection instead of filterStore.heapId
- Deleting the currently-viewed heap navigates back to all-photos
via navigateToSection (was setFilterHeapId(null), which now lives
in the section model).
User flow:
1. Click Discarded → seeing discarded photos.
2. Open FilterBar, set Rating ≥ 3 — discarded section now has rating.
3. Click Library "Library" folder → no rating filter, just library
contents.
4. Open FilterBar, set media type Photo only — folder section now
has that.
5. Click Discarded again → restored to discarded + rating ≥ 3.
6. Click Library folder again → restored to library + photo only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reworks the Tags sidebar entry from an expandable list of tags into a
single leaf entry. Clicking it switches the timeline grouping mode to
"tag" — every tag becomes a sticky-headered group, with an "Untagged"
group at the bottom for photos with no tags. A photo with N tags
appears in N groups. Existing filters and sort still apply within each
group.
Backend
- list_photos eagerly loads Photo.tags via selectinload to avoid an
N+1 round-trip.
- Each photo in the list response now carries a `tags: [{id, name,
color}]` array. The route stops using PhotoListResponse strict
validation (returns a plain dict with the same shape plus the new
field) so we don't have to extend the pydantic schema.
Frontend
- Photo TS type gains an optional tags field plus a PhotoTagSummary
alias.
- filterStore: new groupBy: 'date' | 'tag' field, default 'date',
with setGroupBy + URL sync via ?group=tag. clearAll resets it.
- usePhotosQuery threads groupBy through filtersToParams (it's
client-side only but kept in the params for cache key
consistency).
- LeftSidebar Tags entry is now a leaf node (no children), shows the
total tag photo count as the badge, and is highlighted when
groupBy === 'tag'. Click → setGroupBy('tag') without touching
other filters. Selecting "All Photos" resets groupBy back to
'date' via clearAll.
- Timeline.buildItems gets a third "tag" branch:
- Iterates photos × tags into per-tag buckets
- Photos with no tags go into an "Untagged" bucket
- Tag groups sorted alphabetically; Untagged pinned to the end
- Headers + rows pushed in the same shape the date branch uses,
so the existing sticky-header overlay works for free
- Selection state is by photo id, so a photo appearing in multiple
groups stays consistently selected/highlighted across instances.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Folders section in the LeftSidebar previously rendered the flat
list of source roots — actual subdirectories were invisible. Now it
shows the full nested tree, click any node to filter, drop targets
work at every depth.
Backend
- New GET /folders/tree returning a list of root nodes (one per
active SourceRoot). Each node is { id, name, path, photo_count,
children: [...] } with children sorted alphabetically at every
level. Walks Folder rows whose source_root_id matches and whose
path is at or beneath the source root, then attaches them by
parent path so partial scans don't break the tree.
- The source root's display label is overlaid on the root folder
node so the top-level entry reads as "Library" instead of
"/photos".
- list_photos folder_id filter now does descendant matching: when
a Folder id is given, it includes the folder itself and every
Folder whose path is a sep-prefixed descendant. Matches the
Lightroom mental model: clicking "Library" or any parent folder
shows everything beneath it. The existing source-root-id branch
is unchanged.
Frontend
- New types/api.ts FolderTreeNode interface and sourceFolders.tree()
helper.
- New hooks/useFolderTreeQuery.ts with a 30s staleTime and a
findFolderInTree() walker for id-based name lookups.
- LeftSidebar drops the flat foldersData list and uses the tree
query. folderNodeToTreeItem recursively maps backend nodes into
the existing TreeItem shape; renderTreeItem already knew how to
recurse into children, so the tree just works at any depth.
Drop targets, drag-to-move, drag-to-copy, double-click rename,
and active-state highlighting all carry over to nested folders.
- The renameMutation now also invalidates ['folders', 'tree'] so a
source-root rename refreshes the tree label immediately.
- ActiveFilterChips switches to the tree query and uses the new
findFolderInTree walker so the chip label resolves correctly for
sub-folder filters too — not just top-level source roots.
- The "Scan all folders" button visibility now keys off the tree
length instead of the flat folders length.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two related polish items.
1. Heap convert to folder
Closes a long-standing TODO from spec §6.10.
- Backend: POST /heaps/{id}/convert with body
{ target_id, mode: 'move'|'copy', delete_heap: bool }
target_id resolves either as a Folder id or a SourceRoot id (same
convention as /photos/move). For each member photo, dispatches
either shutil.move + photo.folder_id update, or shutil.copy2 +
a new is_duplicate=true Photo row with all metadata copied. Name
collisions on copy use the same " (copy N)" suffix scheme as
/photos/copy. The heap row is optionally deleted on success.
Per-photo failures are collected into the response instead of
aborting the batch.
- Frontend: new HeapConvertDialog with a target-folder dropdown
(currently from sourceFolders.list, sub-folder picking is a
follow-up), move/copy radio, and a "delete heap" checkbox.
HeapsPanel rows get a hover FolderOutput button that opens it.
Toast on success names the verb + count and notes whether the
heap was deleted; invalidates heaps + photos + folders queries.
2. Surface exact-duplicate detection
The scanner already sets Photo.is_duplicate=true when a SHA-256
match is found, but nothing surfaced it. Now:
- Backend list_photos accepts an optional is_duplicate query
param so the frontend can filter duplicates-only views.
- filterStore gains a duplicates: boolean field with setter, URL
sync (?duplicates=true), filtersToParams entry, and a
hasActiveFilters check.
- LeftSidebar gets a new "Duplicates" library node (Copy icon)
that clearAllFilters() + setDuplicates(true). isItemActive
follows the filter so the highlight stays in sync after
external filter changes.
- PhotoThumbnail renders a small dark badge with the Copy icon
bottom-right when photo.is_duplicate. Sits next to the existing
basket / discard badges so the user can spot duplicates at a
glance.
- Photo TS type adds is_duplicate.
Perceptual-hash duplicate detection (re-encoded / resized matches)
is intentionally a follow-up — needs an imagehash dep, a phash
column, a backfill job, and similarity-search endpoint with
hamming-distance grouping. This commit only surfaces what the
scanner already finds via byte-level SHA-256 comparison.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Tag model and photo_tags join table were already in place; this
fills in the rest — full backend CRUD, per-photo add/remove, list-
endpoint filtering, and a Tags section in the RightSidebar with
autocomplete-create.
Backend
- routers/tags.py rewritten from a 27-line stub:
GET /tags — list with photo counts
POST /tags — create (idempotent on name)
PATCH /tags/{id} — rename / recolor
DELETE /tags/{id} — delete (FK cascades photo_tags)
- routers/photos.py:
POST /photos/{id}/tags — add tag ids (idempotent)
DELETE /photos/{id}/tags/{tag_id} — remove
GET /photos/{id} — now returns a `tags` list alongside
the existing PhotoResponse fields
(fetched via the photo_tags join)
- list_photos applies the existing tag_ids query param: comma-
separated, AND semantics, one IN-subquery per id since SQLite
has no native set-contains-all.
Frontend
- New hooks/useTagsQuery.ts.
- services/api.ts: Tag interface, full tags client (list/create/
update/delete), addToPhoto/removeFromPhoto helpers.
- filterStore: tagIds: string[] field, setTagIds, toggleTagId,
hasActiveFilters update, filtersToParams sends tag_ids comma list.
- useFilterUrlSync round-trips ?tag_ids=… so tag-filtered views
are bookmarkable.
- usePhotosQuery threads tagIds through.
- RightSidebar gains a new Tags section using a TagsEditor
component:
- shows existing tag chips with X to remove
- autocomplete input that matches the user's typing against
existing tag names
- shows an inline "+ Create '<name>'" affordance when there's
no exact match
- Enter creates and attaches in one shot; Esc clears the input
- existing colour values render as a tinted chip background
- FilterBar gets a Tags group (only rendered when there's at
least one tag) with toggleable chips per tag.
- ActiveFilterChips shows "Tag: <name>" chips for each active
tag id, looking up names lazily from the tags query.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tried it, didn't add value beyond what the date-grouped timeline
already gives. The grouped, sticky-headered timeline (which kicks
in by default whenever sortBy is taken_at) is the better
affordance for date navigation — duplicating that as a sidebar
drilldown was just clutter.
Removes the full stack:
- LeftSidebar: by-date tree node, byDateChildren computation,
date-year-/date-month- handlers in applyLibraryNode, isItemActive
branches that matched a date-range filter, the now-unused
setDateFrom/setDateTo/filterDateFrom/filterDateTo selectors, and
the Calendar icon import.
- frontend/src/hooks/useDateBucketsQuery.ts deleted entirely.
- api.ts: library.dateBuckets helper and DateBucketYear/Month types.
- backend/app/routers/library.py: GET /library/date_buckets endpoint
and its strftime aggregation query.
The dateFrom/dateTo filter state stays in filterStore — the
FilterBar still uses it for the "Date" range inputs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The "By Date" library node was decorative. Now it's a real
hierarchical navigator: expand to see year buckets (with photo
counts), expand a year to see its months, click any year or
month to filter the timeline to that date range.
Backend
- New GET /library/date_buckets aggregates non-discarded photos
by year+month from Photo.taken_at via SQLite strftime, returning
[{ year, count, months: [{ month, count }] }] sorted newest-
first. NULL taken_at rows are excluded.
Frontend
- New library.dateBuckets() helper + DateBucketYear / Month types.
- New hooks/useDateBucketsQuery.ts with a 60s staleTime.
- LeftSidebar builds the By Date subtree dynamically from the
query: each year is a tree node with month children. Year nodes
use the Calendar icon, months render as their full English name.
- applyLibraryNode handles two new id prefixes:
'date-year-{year}' → setDateFrom YYYY-01-01, setDateTo YYYY-12-31
'date-month-{Y}-{M}'→ setDateFrom YYYY-MM-01, setDateTo YYYY-MM-LL
where LL is the last day of the month (computed via Date trick
new Date(year, month, 0).getDate() — uses month-day=0 to roll
back into the previous month's last day).
- isItemActive recognises when the current dateFrom/dateTo matches
a year or month node so the sidebar selection highlight stays
in sync with the filter store (also when filters are set
externally via the filter bar or URL hydration).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Folders in the LeftSidebar were decorative — clicking one did
nothing. Now they actually filter the timeline.
filterStore: new folderId field, setFolderId, hasActiveFilters check,
filtersToParams sends folder_id to the backend (the param was already
declared and applied server-side, just nothing was setting it).
useFilterUrlSync round-trips ?folder_id= so the filter persists in
the URL. usePhotosQuery threads it through.
LeftSidebar:
- Clicking a folder row calls clearAllFilters() then setFolderId(id)
so the user lands cleanly on that folder.
- Library virtual nodes (All Photos, Rated, Discarded) clear the
folder filter as part of their normal action.
- The active-row visual highlight is now derived from the filter
store: a folder row is selected when filterStore.folderId matches
it, "All Photos" is selected when no folder is set. Keeps the
sidebar in sync if filters change externally (URL hydrate, the
ActiveFilterChips X button, FilterBar Clear all).
ActiveFilterChips: shows "Folder: {name}" and "Heap: {name}" chips,
looking up the names from the folders / heaps queries (lazy-enabled
only when the corresponding filter is set). Clicking the X clears
the filter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two related changes:
1. Sorting controls
- filterStore gains sortBy (taken_at | added_at | filename | file_size
| rating) and sortOrder (asc | desc), defaults taken_at desc.
- filtersToParams sends sort + order to the backend list endpoint.
- usePhotosQuery drops the hardcoded sort/order and reads from the
store.
- useFilterUrlSync round-trips ?sort= and ?order= so the choice
persists in the URL.
- FilterBar gets a Sort group with a field <select> and an asc/desc
toggle button (ArrowDown / ArrowUp icons).
2. Date-grouped timeline (Google Photos style)
- When sorted by a date field (taken_at or added_at), the Timeline
now groups photos by month label ("April 2026") with a small
header row between groups.
- Refactored the virtualizer items from "rows of photos" to a flat
mixed array of header | row items, with per-item heights via the
virtualizer's estimateSize callback. Headers are 36px, photo rows
are THUMBNAIL_SIZE + GAP.
- buildItems() walks photos in order, breaks groups when the month
label changes, and chunks each group into rows of `columns` cells.
Photos with no taken_at fall back to "Unknown date".
- For non-date sorts (filename / file_size / rating) the timeline
reverts to a single un-headered stream — grouping by month
wouldn't be meaningful.
- Range selection and arrow-key nav still operate on the flat
photos array, so grouping is purely a visual layer.
- Also fixes a small bug: photo nav arrow-key handler now ignores
events fired while focus is in an INPUT or TEXTAREA.
Sticky header overlay (the header that stays at the top while you
scroll past photos in its group) is intentionally deferred — inline
headers already give the visual grouping; the sticky behaviour is
polish for a follow-up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P already toggled membership in the active heap (remove if every
selected photo is a member, otherwise add the missing ones), but the
mutation only invalidate-then-refetched the heap-photo-ids cache on
success. Pressing P twice in quick succession could read the stale
cache and mis-toggle.
Both heap-toggle mutations (useKeyboardShortcuts P shortcut and the
RightSidebar Pick button) now do an optimistic update in onMutate:
- Read the current ['heap-photo-ids', heapId] cache
- Add or remove the affected ids in a Set
- Write the new array back via setQueryData
- Roll back from the captured `previous` on error
- Re-sync via invalidateQueries in onSettled (heap counts in particular
still need server truth)
Result: the basket affordance flips the moment you press P, and a
quick second press always reads the new state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pick and add-to-heap were two ways of saying "I want to keep this
one". Merging them: P now toggles the selection's membership in the
active heap. The is_picked flag goes away (orphaned in the DB the
same way is_trashed was).
Backend
- Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and
from the photos list filter param.
- Drop the is_picked Column from the Photo model (DB column stays
on legacy installs but is no longer read or written).
- Drop the bulk action 'pick' branch.
- New GET /heaps/{id}/photo_ids returns just the flat string list.
Used by the frontend for fast client-side membership lookups
without fetching full photo records.
Frontend
- New hooks/useActiveHeapMembersQuery.ts → returns
{ activeHeap, memberIds: Set<string> }. Subscribes once at the
Timeline level and passes a derived isInActiveHeap bool down to
each PhotoThumbnail (avoids hundreds of thumbnails subscribing
to the same query).
- PhotoThumbnail: replaces the old check-icon Pick affordance with
a clear basket badge in the bottom-right corner — a small filled
pick-colour pill containing a ShoppingBasket icon — visible only
when the photo belongs to the active heap.
- P shortcut (useKeyboardShortcuts) now toggles membership: if every
selected photo is already a member, it removes them; otherwise it
adds the missing ones. T binding removed (P fully replaces it).
- RightSidebar Pick button is now a Pick / Picked toggle bound to
the active heap. Disabled with a hint when no heap is active.
Shows the heap name in its title attr.
- filterStore drops 'picked' and 'unflagged' from FlagFilter.
FilterBar's flag dropdown is now just Any / Discarded.
- LeftSidebar drops the "Flagged" virtual node (it just set
flag=picked, which no longer exists).
- KeyboardHints: P → "Pick → heap".
- Photo TS type drops is_picked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the spec §6.10 heaps concept: named photo collections with a
single "active" target for fast keyboard adds. Uses a basket icon
(ShoppingBasket) to visually distinguish heaps from folders.
Backend (routers/heaps.py)
- Replaces the 27-line stub with full CRUD: list (with photo counts
via a single LEFT JOIN), create, patch (rename + set active), delete.
- Add/remove photos endpoints with idempotent semantics: re-adding an
existing member is a no-op, removing a non-member is a no-op.
- Setting is_active=true on one heap clears the flag on every other
heap in a single UPDATE so we maintain the single-active invariant.
- routers/photos.py list endpoint now applies the heap_id filter via
IN-subquery against heap_photos (it was a declared param but had
no filter logic).
Frontend
- New hooks/useHeapsQuery.ts and useFilterUrlSync wires heap_id as
another URL-persisted filter; usePhotosQuery threads it through.
- New components/heaps/HeapsPanel.tsx replaces the LeftSidebar Heaps
stub. Shows the basket icon, photo counts, lets you create heaps
inline, click to filter the timeline, set active via the target
icon, and delete heaps.
- TopBar shows an "active heap" pill (basket + name) so the user
always knows where the next T-press will land.
- KeyboardHints adds T → Add to heap.
T shortcut (useKeyboardShortcuts)
- Reads the active heap from the heaps query cache and the selection
from the photo store at fire time. Adds the selected photos (or the
active photo if nothing is selected) via POST /heaps/{id}/photos.
- Toasts:
- "Added to {heap}: N photos (M already present)" on success
- "No active heap" hint when none is set
- "Nothing selected" hint when there's no selection or active photo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
E was a Lightroom holdover and overlapped with the natural rating /
flag culling shortcuts. Space is the only binding now (double-click on
a thumbnail still works). Hints pill updated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User-facing labels and code now use "discard" (verb) and "Discarded"
(state/view label) instead of "trash" / "Trashed". The DB column names
stay (is_trashed / trashed_at) so no migration is required — only the
SQLAlchemy attribute names are renamed via Column('old_name', ...).
Backend
- Photo model: is_discarded / discarded_at attributes (DB columns
unchanged).
- PhotoBase / PhotoResponse / PhotoUpdate schemas use the new field
names.
- Photos list endpoint: is_discarded query param, filter logic.
- DELETE /photos/{id} now sets is_discarded; success message updated.
- Bulk action 'trash' renamed to 'discard'.
- backend/app/routers/trash.py renamed to discard.py with renamed
functions and route prefix /api/v1/discard.
- main.py imports and mounts the discard router.
- tasks/scan.py marks missing files as is_discarded.
Frontend
- Photo TS type: is_discarded.
- PhotoThumbnail: shows the trash-can icon when is_discarded.
- RightSidebar: button label "Discard"; mutation field name; local
variable rename.
- TopBar: discardPhotosMutation and "Discard" button; toast text
"Discarded".
- LeftSidebar: virtual node id 'discarded' / label "Discarded".
- FilterBar / filterStore / useFilterUrlSync: FlagFilter enum value
'trashed' → 'discarded'; backend param key is_discarded.
- KeyboardHints: X label "Discard".
- useKeyboardShortcuts: PhotoUpdate field rename, X handler.
- api.ts: /trash routes → /discard, trash export → discard,
bulkUpdate trash field → discard.
Out of scope (intentional): the docker-compose trash_data volume,
backend/Dockerfile mkdir /data/trash, config.py TrashSettings, and
the spec doc — all unused since soft-discard, and renaming them is
churn for no benefit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The loupe view is now called "preview" everywhere — file paths, type
names, store actions, and the contextual hint pill. There's a single
preview action bound to E and Space (Enter is gone); double-click on a
thumbnail still works. Both shortcuts toggle: open from grid, close
from preview.
This commit also folds in the fix for the "preview shows nothing" bug
the user just hit:
- Extract usePhotosQuery into frontend/src/hooks/usePhotosQuery.ts so
Timeline, PreviewView, and App.tsx all share one query — and one
cache entry. Previously PreviewView and App.tsx looked the cache up
by ['photos'], but the Timeline query key gained the filter params
(['photos', filterParams]) when the filter bar shipped, so the
lookup returned undefined and the preview rendered "No photo to
display". App.tsx's getFirstPhotoId callback had the same bug.
- Harden PreviewImage: render the <img> immediately and overlay the
spinner with absolute positioning, instead of toggling opacity-0 →
opacity-100 on load. The previous opacity-toggle could leave the
image stuck invisible if the load event raced with a key change.
- Add { preventDefault: true } to every useHotkeys call so single
letter shortcuts (1-5, P, X, U) no longer leak into Firefox quick-
find, and Cmd/Ctrl+F no longer triggers the browser find toolbar.
Files renamed:
components/loupe/LoupeView.tsx -> components/preview/PreviewView.tsx
components/loupe/LoupeImage.tsx -> components/preview/PreviewImage.tsx
components/loupe/LoupeFilmstrip.tsx -> components/preview/PreviewFilmstrip.tsx
components/loupe/loupeSrc.ts -> components/preview/previewSrc.ts
Symbol renames: openLoupe→openPreview, closeLoupe→closePreview, the
viewMode 'loupe' tag → 'preview', and all the LoupeXxx component and
helper exports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Photo model previously had two near-identical "negative culling"
states: is_rejected (a flag) and is_trashed (a flag plus a file move).
Lightroom users typically use one or the other, never both, and the
file-move semantics of the old trash made it harder to undo. Merging
into a single soft is_trashed flag — file stays on disk, restore is a
flag flip, permanent deletion still happens via DELETE /trash/empty.
Backend
- Drop is_rejected from PhotoBase, PhotoResponse, PhotoUpdate, the
list endpoint filter, and the bulk-action 'reject' branch.
- Add is_trashed to PhotoUpdate so the PATCH path can set it.
- Drop is_rejected Column declaration from the SQLAlchemy model. The
legacy DB column may persist on existing installs but is no longer
read or written; SQLAlchemy ignores extra columns.
- Rewrite DELETE /photos/{id} as a soft trash: just sets is_trashed=
true and trashed_at=now, no shutil.move. Permanent deletion still
goes through the trash router.
Frontend
- Photo TS type drops is_rejected, gains is_trashed.
- X keyboard shortcut now sets is_trashed=true (was is_rejected); U
clears both is_picked and is_trashed.
- RightSidebar Reject button → Trash button (Trash2 icon).
- PhotoThumbnail flag overlay shows Trash2 icon for trashed photos
instead of an X for rejected.
- KeyboardHints relabels X from "Reject" to "Trash".
- filterStore FlagFilter renames 'rejected' → 'trashed'; the params
builder now sends is_trashed=true for the trashed filter (the list
endpoint defaults to hiding trashed photos otherwise).
- FilterBar dropdown / URL sync allow-list updated accordingly.
No data migration: existing rejected photos remain as-is (flag stale)
and effectively become unflagged in the new model. Re-trash from the
UI to bring them into the new state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the spec §6.7 filter bar to the top of the timeline with:
- Date range (native date inputs)
- Media type chips (Photo / Video / RAW / HEIC, multi-select)
- Min star rating (click to set, click again to clear)
- Color label dots (red/orange/yellow/green/blue/purple, single)
- Flag (any / picked / rejected / unflagged)
- Clear-all button
Active filters surface as removable chips below the bar so they're
visible whether the bar is collapsed or open. The TopBar search input
is now wired to the same filter store with a 300ms debounce, and shows
a clear button when populated.
Filter state is the source of truth in a Zustand store and round-trips
through the URL via history.replaceState — bookmarkable and shareable
per spec §6.7. Hydrate happens once on mount; subsequent store changes
write back to ?q=&date_from=&… without navigation.
Timeline reads filter state, builds the backend params via
filtersToParams, and includes them in the React Query key so the cache
invalidates on every filter change. Also fixes a latent bug: Timeline
was sending limit=1000&offset=0, which the backend silently ignores —
swapped to page=1&per_page=500 with explicit sort=taken_at&order=desc.
New keyboard shortcuts:
- \\ toggles the filter bar
- / focuses the TopBar search input
- Cmd/Ctrl+F same as /
Filter button in the TopBar now lights up when filters are active or
the bar is open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the console.log stubs in useKeyboardShortcuts with real
PATCH /photos/{id} mutations against the active photo, so 1-5 / 0 / P /
X / U / 6-9 actually rate, flag, and color-label photos. Mutations
invalidate both the photo detail query and the timeline list query, so
the RightSidebar and grid update immediately.
Shortcuts now work in BOTH grid and loupe modes (the previous
{ enabled: isGrid } gate is removed) so the user can cull while
browsing in the loupe — the Lightroom workflow.
Color labels 6-9 are wired to red/orange/yellow/green per spec §6.4.
Active photo id is read fresh via usePhotoStore.getState() inside each
handler, so we don't re-bind hotkeys on every selection change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the Lightroom-style full-screen single-photo viewer (spec §6.11).
Open with E / Enter / double-click; navigate with arrow keys; Esc returns
to grid. Filmstrip at the bottom auto-scrolls the active cell into view.
Display:
- Stills source from /photos/{id}/proxy so RAW/HEIC are decoded server-
side; large thumbnail is the onError fallback only.
- Videos render in a <video controls> sourced from /original.
- Continuous wheel zoom (1×–8×, ~15% per tick) with click-drag pan when
zoomed past fit. Z toggles between fit and natural-resolution
(computed from naturalWidth / clientWidth); a second Z snaps back.
- Live percentage indicator in the bottom-center while zoomed.
Polish:
- Neighbor preloading via new Image() when currentIndex changes so arrow
nav feels instant (skips videos).
- Focus trap with role=dialog, aria-modal, focus-on-mount, restore-on-
unmount, and Tab cycling among focusable children.
Plumbing:
- New canonical Photo TS interface in types/photo.ts; removes the three
duplicated definitions in PhotoThumbnail/Timeline/photoStore.
- photoStore gains viewMode + openLoupe/closeLoupe.
- useKeyboardShortcuts wires E/Enter/G to open/close, gates rating and
flag stubs with { enabled: viewMode === 'grid' } so they're inert in
loupe.
- App.tsx mounts <LoupeView/> as a z-40 overlay covering TopBar, gates
the auto-open right sidebar effect on viewMode === 'grid' so leaving
loupe doesn't fight the user's prior sidebar state.
- PhotoThumbnail gains an onDoubleClick prop wired to openLoupe.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>