Adds a `colored` field to /library/stats counting non-discarded
photos with a color_label set, mirroring how `rated` is exposed.
The Colors sidebar entry now shows the live count and refreshes
through the existing LIBRARY_STATS_QUERY_KEY invalidations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a Colors entry under Views that buckets photos in canonical
R-O-Y-G-B-P order (plus an Uncolored tail), mirroring the rated/tags
grouping pattern. Surfaces the color label on each thumbnail as a
small swatch chip preceding the rating badge in the BL corner so
labels are visible everywhere, not just inside the new view. Also
types color_label on the Photo interface — the backend already
returned it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mirrors the tags view: rated section now buckets photos 5★→1★ with
an "Unrated" tail group, instead of a flat ratingMin=1 stream.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch tree and heap rows from py-0.5 to a fixed h-[24px] + leading-none
so the hover-only kebab buttons can't stretch the row vertically as the
pointer crosses them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Compact the left/right sidebars and the photo info panel: shrink panel
widths, drop header height, switch top-level tree groups and metadata
sections to small uppercase eyebrow labels, and tighten row padding,
icon sizes, and count badges throughout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New ActiveHeapCard pinned just below the Library header in the left
sidebar. Renders null when no heap is active. When one is, shows the
heap name + member count + a fan of the last 5 member thumbnails
(newest front-and-center, older members rotated ±6°/±18px outward).
Clicking the header navigates to the heap via the same
navigateToSection pattern HeapsPanel uses.
- Stack animates with framer-motion (previously pinned in
package.json but unused). New picks spring-slide into the front of
the stack by subscribing to the ['heap-photo-ids', heapId] cache
that the existing pick mutation already updates optimistically —
no new event wiring. Unpicks run the exit transition and the
remaining cards re-fan.
- Toasts are now subtle: glassy bg-surface/80 + backdrop-blur, thin
2px left accent bar in the type color instead of a full tinted
fill, smaller icons and text, tighter padding, truncation on
overflow so they stay a single compact row. The colored alert
block that was competing with the rest of the UI is gone.
- Card lives at the top of the sidebar specifically so the bottom-left
toast stack can never occlude it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Parallelize the six celery inspect.*() calls in /library/maintenance/
worker-status via asyncio.gather + to_thread, and drop per-call
timeout from 1.0s to 0.5s. Endpoint goes from ~6.1s to ~0.54s — it
was the sole bottleneck on opening the Settings dialog.
- SettingsDialog now fetches through React Query with enabled:isOpen,
so reopening shows cached data instantly while a background refetch
updates. Worker polling moved to refetchInterval. Loading spinners
only show when there's no cached data yet, so background refetches
don't keep them spinning.
- Move the Settings entry point from the TopBar to a pinned row at the
bottom of the LeftSidebar so it sits alongside the other library
controls. TopBar no longer takes onOpenSettings.
- Remove the "Scan all folders" bottom action from LeftSidebar — the
same control already lives in Settings → Library → Re-scan.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Right sidebar stays open by default and shows an empty state when
nothing is selected, instead of auto-hiding on deselect.
- Both sidebars now have a collapse button in their header and an
expand button in the TopBar that only appears when collapsed, so
each panel has a discoverable affordance in either state.
- Arrow-key navigation auto-scrolls the destination row into view
with a ~35% peek margin, cueing the user that there's more content
in the scroll direction.
- Fix: the width sentinel's measurement effect never installed its
ResizeObserver when Timeline first rendered the loading state (ref
was null, empty-dep effect didn't re-run), so containerWidth stuck
at 0 and the grid fell back to 4 columns × 200px forever. Switched
to a callback ref that attaches the observer the moment the
sentinel actually mounts.
- KeyboardHints surface the Tab (library) and I (info) shortcuts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous count-alignment fix used invisible group-hover:visible for
hover-only buttons, but invisible still reserves layout space. Folder
rows had a permanent kebab slot that non-folder rows didn't, and active
heap rows had a Target indicator before the count — both shifted their
counts left of the rest. The result was visually misaligned counts.
- LeftSidebar folder kebab + HeapsPanel kebab/set-active: switch to
hidden group-hover:block so the slot occupies zero width in the
resting state. Counts now sit at the same right edge across folder,
non-folder, and heap rows.
- HeapsPanel: drop the standalone Target indicator from active heap
rows. Active state is signaled by the bold name (font-semibold)
already, and removing the indicator lets the heap count column line
up with everything else.
- Both kebab wrappers also use hidden group-hover:block on the wrapper
div so the menu trigger truly takes 0 width when not hovered.
On hover the kebab appears to the right of the count and pushes it
slightly left, as the user requested ("on hover we can push them to
make space for the burger").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The left sidebar can now create, rename, and delete folders. Each
operation is mirrored to disk through the backend.
Backend (folders router):
- POST /folders { name, parent_id } — create a sub-folder under an
existing Folder row, mkdir on disk, insert the row, return it. Names
are validated (no separators, no traversal).
- PATCH /folders/{id} extended — still does the display-only rename for
SourceRoot ids, but for Folder ids it now actually moves the directory
on disk and rewrites every descendant Folder.path + Photo.filepath
that lived under the old prefix in a single transaction. Refuses to
rename the source-root mount itself.
- DELETE /folders/{id}?mode=discard|permanent —
discard: set is_discarded on every photo whose filepath lives under
this folder. The folder, descendants, and on-disk dir are
left intact. Recoverable from the discard pile.
permanent: unlink each file, remove rows, rmtree the directory.
- Refuses to delete the source-root mount in either mode.
Frontend:
- New DeleteFolderDialog: two-card mode picker (Move to discard pile /
Permanently delete) with destructive accent on the latter. Esc and
backdrop click cancel.
- LeftSidebar: hover-revealed kebab menu on every folder row with
New sub-folder, Rename, and Delete folder… Inline create input
appears below the parent row when "New sub-folder" is picked.
All mutations invalidate ['folders'], ['photos'], and the library
stats query so the sidebar counts stay live.
- api.ts: sourceFolders.create + sourceFolders.delete wrappers.
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>
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>
The "Views" header + MoreHorizontal kebab were vestigial — the Views/Folders
group rows already label themselves, and the kebab was a no-op. Swap the
group icons (Layers2 for Views, HardDrive for Folders) so the visual
hierarchy stays clear without the header.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Timeline arrow keys moved by currentIndex ± columns in the FLAT
photos array, but with date / tag grouping the rendered grid has
half-full last rows for each group, so flat-index nav routinely
landed in the wrong cell — and tag grouping (where one photo can
appear in multiple groups) made it incoherent.
Fix: navigate the actual visual grid the user sees.
- New photoRows = items.filter(type='row') in visual order. The
buildItems pipeline already chunks photos into row items of
[1..columns] cells per group; this is exactly the rendered layout.
- findActiveCell() walks photoRows looking for the activePhotoId
and returns its (rowIndex, colIndex), or null if it isn't on
screen. First-occurrence wins, which matches user intuition in
the tag-grouped view.
- New move(dr, dc) helper:
Left/Right: walk col, wrap across row boundaries (so going Right
off the end of a half-full row jumps to the next group's first
row). Clamps at the very first/last cell.
Up/Down: change row, then clamp the column to the destination
row's actual width — moving down into a 2-cell row from col 3
lands on col 1, not nothing.
- The four arrow handlers all funnel through move(); shift-arrow
still calls selectRange with the destination cell's globalIndex
so range selection works the same as a shift-click on that cell.
- Headers are skipped automatically because they were never in
photoRows. Edge cells, end-of-group, single-row groups, and
tag-repeated photos all behave consistently.
Pulled activePhotoId out of usePhotoStore (was already in the store
but the Timeline component wasn't reading it). Effect deps updated
to invalidate the listener whenever the visible grid changes.
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>
Two related sidebar UX bugs.
1. Parent folders weren't clickable
renderTreeItem's onClick called toggleExpanded(item.id) for any
row with children — so a parent folder only expanded/collapsed,
never applied its filter. Restructured: folder rows always call
applyLibraryNode (which the photos endpoint already expands to
include descendants), and the chevron remains a separate
stopPropagation button for expansion. Other group headers
(Library, Folders, Tags) still toggle expansion on row click
since they have no associated filter.
Result: clicking any folder at any depth filters the timeline
to that folder + every descendant, matching the Lightroom
model the user expects.
2. New files not appearing after Scan all folders
scanLibraryMutation.onSettled invalidated ['photos'] when the
trigger returned, but POST /library/scan just queues the celery
task and returns immediately. By the time the worker finishes
walking the directory and inserting new rows, the photos query
has already refetched (with no new data) and is sitting on a
30-second staleTime — so newly-indexed photos stayed invisible
until the next manual refetch.
Fix: ScanProgress already polls /library/scan/status. Track the
previous is_scanning value via a ref; when it transitions from
true → false, invalidate ['photos'], ['folders'], ['folders',
'tree'], ['heaps'], and ['tags']. That's the actual moment new
data is available, regardless of how the scan was triggered
(button, watcher, startup).
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>
Adds an expandable Tags node alongside All Photos / Rated /
Duplicates / Discarded. The children are populated dynamically from
useTagsQuery — one row per tag, showing the tag name and its photo
count badge. Click a tag row to filter the timeline to just that
tag (single-tag), with the active highlight following the filter
store.
Multi-tag filtering still lives in the FilterBar; the sidebar entry
is the quick "show me everything in this tag" affordance.
Implementation
- New 'tags' library tree node with children: allTags.map(...)
- 'tag-{id}' click handler in applyLibraryNode → clearAll() +
setTagIds([id])
- isItemActive recognises a tag row as selected only when the
filter store has exactly that single tag id, so combining it with
multi-tag filter mode in the FilterBar doesn't leave a stale
highlight.
- Tags section is collapsed by default like other library nodes; no
effect when there are no tags yet (children list is empty).
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>
Three small phase-11 follow-ups in one commit since they all touch the
same surface area.
1. Watcher source-root resolution
The watch_folders task previously called scan_folder.delay(parent_dir)
when files arrived, with no source_root_id. scan_folder would then
auto-create a fresh SourceRoot for that arbitrary subdir, polluting
the source_root list. Now the watcher loads (path, id) pairs at
startup, defines find_source_root_for() that walks the parent chain,
and dispatches with the resolved id. Events under no known root are
logged at debug and ignored instead of creating stale rows.
2. Folder rename via UI
- Backend: PATCH /folders/{id} accepts { name } and updates the
SourceRoot display label only. The on-disk path is controlled by
the docker mount and intentionally not editable from the UI.
- Frontend: double-click a folder row in the LeftSidebar to start
editing; Enter or blur commits, Esc reverts. New renamingId /
renameDraft local state and a renameMutation that invalidates
['folders']. The click handler ignores clicks while the row is
in edit mode so it doesn't navigate.
- api.ts: new sourceFolders.rename(id, name) helper.
3. Bulk copy via Alt-drag onto folder
- Backend: new POST /photos/copy that mirrors /photos/move but uses
shutil.copy2 and creates fresh Photo rows with is_duplicate=true.
Name collisions are resolved by appending " (copy)", " (copy 2)",
etc., up to 100 tries before erroring. Same target_id resolution
as /move (folder id or source root id).
- Frontend: photos.copy(ids, targetId) helper. LeftSidebar's
handleDrop now takes a `copy` flag derived from e.altKey on the
drop event; folder targets dispatch copyDropMutation when held,
moveDropMutation otherwise. The drop-effect cursor flips to
'copy' on dragover when Alt is pressed so the user gets visual
confirmation. Discard target ignores the modifier.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The frontend AddSourceFolderDialog let users register source roots
from inside the app, but with the bootstrap auto-creating one for
the /photos mount on first boot, the dialog was redundant in the
common case and confusing in every other (users had to know which
container path corresponded to their host directory). Going
config-driven matches Plex/Photoprism/Immich and matches the
mental model "the docker mount IS the library".
Frontend
- Deleted components/dialogs/AddSourceFolderDialog.tsx entirely.
- LeftSidebar drops the "+ Add Source Folder" button + bottom-bar
layout, the addFolderMutation, the dead Plus action button on
the (no-longer-existing) folders/heaps tree headers, and the
Plus icon import.
- api.ts: removed sourceFolders.add(), library.browse(), and the
BrowseChild / BrowseResponse types. The remaining sourceFolders
surface is read-only (list + manual scan).
- LeftSidebar bottom strip is now just the "Scan all folders"
button when there's at least one source root.
Backend
- Dropped POST /folders (no consumers) along with FolderCreate /
FolderResponse pydantic models. The folders router header now
documents the config-driven approach.
- Dropped GET /library/browse (no consumers). Removed the unused
os/HTTPException/SourceRoot imports it brought in.
- cleanup_data_integrity now also walks the source roots and logs
a warning for any whose path is missing on disk. Doesn't auto-
delete (a missing path could be a temporarily unmounted drive)
but surfaces enough hint to fix it. Returns the count in the
summary dict alongside merged-duplicates.
Docs
- README "How libraries are managed" section rewritten to spell
out that mounts ARE source roots, edit .env + restart, no UI for
managing source roots. New "Changing or adding libraries"
section walks through the typical edit-restart loop including
the optional volume-nuke for a clean slate.
- "Adding more libraries" subsection covers multi-mount via
edited compose with a note that auto-registration is roadmap.
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>
Bulk-move via drag-and-drop. Drop a photo (or multi-selection) on
any folder row in the LeftSidebar and the files move on disk +
photo.folder_id updates atomically.
Backend
- New POST /photos/move accepting { photo_ids, target_id }. The
target_id can be either a Folder id OR a SourceRoot id (the
sidebar exposes source roots today, so the same drag target
needs to resolve either).
- Resolves source roots to their on-disk path and looks up / creates
the canonical Folder row via the existing scan get_or_create_folder
helper, so dedupe + path normalization stay consistent with the
scanner.
- Per-photo loop with shutil.move; per-file failures (target name
collision, missing source, OS error) are collected into a
structured `errors` array and don't abort the batch.
- Skips photos that are already in the target folder so re-drops
are a no-op.
Frontend
- New photos.move(ids, targetId) helper in api.ts.
- LeftSidebar grows a moveDropMutation alongside the existing
discard one. handleDrop dispatches by id prefix:
'discarded' → discard, 'folder-{id}' → move.
- Folder rows now report acceptsDrop and get the same drag-over
highlight as heap drops, in primary tint instead of reject.
- onSuccess invalidates both the photos query and the folders
query so the new folder counts in the sidebar refresh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same drag pattern as the heap drop, but the target is the Discarded
library node. The dropped photos go to is_discarded=true via a
single bulk request.
Backend: the /photos/bulk endpoint already had a 'discard' action
branch; the missing piece was a frontend client that sent the right
shape. The previous photos.bulkUpdate sent
{ photo_ids, discard: true } against a backend that wanted
{ ids, action } — silently broken since day one. Replaced with two
narrow helpers that match BulkAction exactly: photos.bulkDiscard(ids)
and photos.bulkRestore(ids).
Frontend: LeftSidebar grows a small dnd state machine — dropTargetId
for the hovered row, isDropTarget(id) for which library nodes accept
drops, handleDrop(id, ids) for the dispatch. Today only the
'discarded' node is wired; folder rows for bulk move come next.
Drop highlight uses the reject ring/tint to match the destructive
nature of the action. Toast confirms; photos query is invalidated
so the timeline immediately drops them.
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>
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>
Adds the destructive-action loop the discard concept needed:
- Click "Discarded" in the left sidebar → activates the discarded
filter; the timeline reloads showing discarded photos.
- DiscardActionBar appears at the top of the timeline only when the
discarded filter is active. Shows the count, a Restore button (when
photos are selected), and an Empty discard pile button.
- Empty action goes through a ConfirmDialog (new tiny reusable modal,
same overlay pattern as AddSourceFolderDialog).
- Restore goes through POST /api/v1/discard/restore.
- DELETE /api/v1/discard/empty now actually os.unlink()s the files
from disk in addition to removing the DB rows. Per-file failures
are logged and reported in the response so a single permission
error doesn't abort the batch.
Other library nodes wired in passing:
- "All Photos" → clearAll()
- "Rated" → setRatingMin(1)
- "Flagged" → setFlag('picked')
- "Discarded" → setFlag('discarded')
- "By Date" left unwired (needs a date-grouping UI)
Single-photo restore via the U keyboard shortcut already worked from
an earlier round, no change needed.
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>