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>