PreviewView mounts its own arrow handlers via useHotkeys. The Timeline
also installed a window-level keydown listener for grid arrow nav, with
no viewMode check, so in preview mode BOTH handlers fired on every
arrow press and raced to call setActivePhoto. The grid handler walks
photoRows (grid cells) while preview walks the visible-order array,
and whichever store update landed last won, making preview nav land on
the wrong photo.
Telltale: Shift+arrow worked because PreviewView's plain useHotkeys
('left'/'right') doesn't match Shift+arrow, so only Timeline fired and
its visual-grid path got the right neighbor.
Fix: early-return Timeline's keyboard effect when viewMode !== 'grid'.
The listener stays attached to viewMode in the dep array so it
re-engages instantly on closePreview.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
react-hotkeys-hook can fire a stale closure when the callback dependency
array changes between renders, causing arrow nav to read an old photos
array (e.g. the empty initial render before visiblePhotoIds was applied)
and land on the wrong photo or no-op entirely.
Move the latest photos / activePhotoId into a navRef updated on every
render. The goPrev / goNext callbacks become stable (their useCallback
deps shrink to just setActivePhoto) and read the freshest values from
the ref at fire time. useHotkeys no longer has to re-bind on every
render — the handlers can capture the ref once.
The visible-order array still drives navigation; this just removes the
re-bind race that was making it look like nav was ignoring it.
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 visible photo order was published only via a passive
useEffect on Timeline, which had a timing race: arrow nav in preview
could read a stale or empty sequence and fall back to the raw API
order, breaking visual order navigation in tag mode and after
filter changes.
Fix: openPreview now accepts an optional visibleSequence parameter,
and Timeline's onDoubleClick passes the freshly-computed flat
sequence directly. The store action adopts that sequence as the
authoritative visiblePhotoIds for the preview session, falling back
to the most-recently-published one for paths that don't have a click
site (e.g. the global Space hotkey).
The Timeline still publishes via useEffect for the Space-hotkey
fallback path, but the click path no longer depends on it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add whitespace-nowrap to the hints pill container plus the action and
selection-count spans so labels like "Pick → heap" and "1 selected"
no longer break across rows. The pill is an absolute overlay with no
width constraint, so growing horizontally is fine.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- KeyboardHints: switch from fixed positioning to absolute, mounted
inside the main content column. The column is now relative-positioned
so the hints overlay centers against the timeline area instead of the
raw viewport (which was off-center because of the sidebars).
- AppFooter: tiny "Built with hubris • <YEAR in roman>" pinned to the
bottom-right corner of the main column. Year is computed at render
time and converted via a small toRoman helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace #4f98a3 (desaturated teal) with #3b6ed8 (deep royal blue) as
the app's primary accent. Affects every text-primary, bg-primary,
ring-primary, border-primary class — selection rings, active sidebar
rows, the active filter pill background, the loupe info button when
open, etc.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User-visible string change only — TopBar header + browser tab title
+ logo alt text. Container names, internal package names, and
directories keep their existing identifiers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move the Clear-all button inside the pills flex container so it shares
the cluster's gap and reads as the rightmost item of the filter group
instead of floating between filters and search. Drop the bordered
pill styling for a flat text-button (underline on hover) so it doesn't
look like another active filter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous layout was [search] [centered pills] [clear-all]. Flip to
[pills left-aligned] [clear-all] [search right]. Pills get a flex-1
slot on the left so they fill the available space and overflow-x-
auto kicks in when they don't fit. Clear-all only renders when any
filter is active and sits between the pills and the search input.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- HeapConvertDialog: switch the target picker from sourceFolders.list
(top-level source roots only) to useFolderTreeQuery, flattened
depth-first into a list with depth info. Each option is indented
with non-breaking spaces so nested subfolders read as a tree in
the native dropdown. Backend already accepts any Folder id, so no
server change needed.
- photoStore.setVisiblePhotoIds: short-circuit when the new id list
matches the existing one element-for-element. Avoids feedback loops
if a publisher fires from an effect on a render where the contents
haven't actually changed (which was triggering React error #185).
- Timeline: pull setVisiblePhotoIds via a focused selector instead of
the wholesale destructure so the publisher subscription doesn't
re-render Timeline on unrelated photo store changes.
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>
Rated and Tags sections still benefit from their respective pill —
Rating because the user can refine the section's ratingMin >= 1 to a
higher floor, Tags because they can intersect the tag-grouped view
with a specific tag id list. Flag in Discarded is the only pill where
the section locks the only useful value, so it stays hidden there.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each section's preset locks one filter dimension that defines the
section: Rated → ratingMin, Discarded → flag, Tags → groupBy=tag.
Showing the matching pill in the toolbar while you're inside that
section is either redundant (it's already on) or actively breaks the
view (toggling it would either become a no-op or filter the section
into one bucket).
Hide the corresponding pill in each section: Rating in Rated, Flag in
Discarded, Tags in Tags. The user navigates away to a different
section to change the locked dimension.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FilterPill: fixed h-7 + py-0 on the button so neither the X clear icon
nor the chevron can stretch the pill vertically when active state
swaps them in. The chevron is now wrapped in the same h-4 w-4 slot as
the clear X so swapping doesn't change footprint horizontally either.
- FilterBar: fixed h-11 on the bar itself so any future per-pill drift
can't grow the row.
- Clear-all: wrapped in a fixed w-56 right slot that mirrors the search
input on the left. The pill cluster sits in the centered flex-1
middle slot, so it stays perfectly centered whether or not Clear-all
is rendered. The button itself is right-aligned within the slot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drop the leftover Vite default and point the favicon + apple-touch-icon
at a copy of the existing muli-logo.png served from /public. Also
trim the page title to "Mulita" and add a dark theme-color meta tag
so mobile browsers paint the chrome to match the app.
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>
The heap row used to fan out three small icon buttons (set active, convert
to folder, delete) on hover, which crowded the row and didn't leave room
for new actions. Collapse the destructive / occasional ones into a kebab
menu and add the missing operations.
- Right-aligned action cluster: active indicator → count badge → target
toggle (when not active) → kebab menu, all flex-shrink-0 so the name
truncates first.
- Kebab menu items: Rename, Duplicate, Move to folder…, Delete. Outside
click and Escape close the popover; the trigger has aria-haspopup +
aria-expanded. Delete still confirms via window.confirm.
- Inline rename: double-click a heap row OR pick Rename from the menu
to edit the name in place. Enter commits, Escape cancels. Mirrors the
folder rename pattern in LeftSidebar.
- backend: new POST /heaps/{id}/duplicate creates a copy with the same
membership ("{name} (copy)") via INSERT...SELECT on heap_photos.
Never marks the new heap as active so duplicating doesn't quietly
steal the user's T-key destination.
- api.ts: heaps.duplicate wrapper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CORS allowed-origins list, host port mappings, log level, container
timezone, and worker concurrency are now all driven by environment
variables with sane defaults. Same-origin access through the nginx
proxy keeps working with no config; direct cross-origin backend
access can be locked down via ALLOWED_ORIGINS.
- backend/config: ALLOWED_ORIGINS env (comma-separated, "*" for any)
exposed via settings.cors_origins. LOG_LEVEL too.
- backend/main: build the CORS middleware from settings.cors_origins,
auto-disable allow_credentials when origins is wildcard (CORS spec
forbids credentials + "*").
- docker-compose: parameterize FRONTEND_PORT, BACKEND_PORT, REDIS_PORT,
CELERYD_CONCURRENCY, LOG_LEVEL, and TZ via ${VAR:-default} so each
has a working fallback if the .env entry is missing.
- .env.example: new template documenting every knob with examples.
- .env: pruned to only the values that diverge from .env.example;
removed dead VITE_API_URL.
- README: configuration knobs table + "accessing from another machine"
section explaining the same-origin proxy story.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- api.ts: switch baseURL from http://localhost:8001/api/v1 to relative
/api/v1. Both nginx (prod) and vite (dev) already proxy /api/ to the
backend, so requests become same-origin and the app works from any
host (LAN IP, reverse proxy, another machine) with no CORS dance.
- backend CORS: open to "*" as a fallback for the rare direct-hit case;
the normal flow is same-origin via the proxy and never touches CORS.
- App layout: move FilterBar and DiscardActionBar inside the main
content column (right of the left sidebar) so the filter row no
longer bleeds across the sidebar.
- FilterBar: justify-center the pills so they sit centered above the
timeline. Clear-all uses ml-2 instead of ml-auto.
- KeyboardHints: convert to a floating, glassy pill pinned bottom-
center (fixed positioning + backdrop-blur + ring) instead of a flat
toolbar row. Removed from the column layout — now mounted as an
overlay sibling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The bulk action panel previously covered rating, color, flag, and pick
but had no way to apply tags across a multi-photo selection — the only
path was to tag photos one at a time via the single-photo PhotoInfoPanel.
Add it.
- backend: extend the existing /photos/bulk action endpoint with
add_tags and remove_tags actions. add_tags is idempotent (computes
the new (photo_id, tag_id) pair set against existing rows and inserts
only the missing ones); remove_tags is a single DELETE WHERE IN.
- api.ts: bulkAddTags / bulkRemoveTags wrappers.
- RightSidebar: new BulkTagsEditor below the bulk flag row. Filters /
searches the existing tag list, lets the user click any chip to apply
it to the whole selection or X to remove it. Typing a name with no
exact match shows a "Create and apply" button that creates the tag
via tagsApi.create and immediately attaches it to every selected
photo. All three mutations invalidate both the photo and tag caches
so the FilterBar tag count + sidebar Tags section stay fresh.
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>
- backend/photos: whitelist sortable columns instead of getattr(Photo, sort).
Previously any client-supplied string was passed to SQLAlchemy, exposing
every Photo attribute (filepath, file_hash, etc.) as a sort target.
- App: move the auto-show-right-sidebar logic out of the render body and
into a useEffect. The previous version called setState during render,
causing extra re-render passes the audit caught.
- types/photo: add added_at and tighten folder_id from optional to nullable.
Drops a (photo as any).added_at cast in Timeline.
- constants/colorLabels: extract a single COLOR_LABEL_OPTIONS used by
FilterBar, RightSidebar, and PhotoInfoPanel. filterStore re-exports the
ColorLabel type so existing imports keep working.
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>
Discarded photos now look discarded in the grid (50% opacity + grayscale)
with a red trash badge in the corner instead of a bare icon. The discard
action bar gains a "Delete N" button that permanently deletes only the
current selection, complementing the existing "Empty discard pile".
Backend: new DELETE /discard endpoint accepting {photo_ids: [...]} that
permanently removes only listed photos. Skips ids that aren't in the
discard pile so it can never bypass the soft-delete safety net.
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>
The FilterBar uses overflow-x-auto for horizontal scroll, which forces
overflow-y to auto as well — that was clipping the absolutely-positioned
pill popovers below the bar. Render the popover into document.body via a
portal with fixed coordinates derived from getBoundingClientRect(), and
clamp the left edge so right-most pills don't push the popover off-screen.
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>
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>
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>
The sidebar showed Juno=7 and sub=blank because the scanner's
folder.photo_count bookkeeping is broken end-to-end:
for root, dirs, files in os.walk(folder_path):
folder = await get_or_create_folder(...)
...
processed_files += 1 # global counter
# AFTER the loop:
folder.last_scanned = datetime.utcnow()
folder.photo_count = processed_files # only the LAST folder
processed_files is the running total across the whole walk, not
per-folder; and the assignment runs once after the loop, only on
whichever folder os.walk happened to visit last. Result: that folder
gets the grand total, every other folder gets nothing (or stale).
Rather than fix the scanner's bookkeeping (which has leaked into
two production scans already), the tree endpoint now computes
counts on demand from the photos table:
- One GROUP BY per source root: photo.folder_id → COUNT, excluding
discarded
- Each node starts with its DIRECT count
- A post-order walk accumulates descendants so every node reports
recursive count — i.e. clicking the row gives you that number of
photos because the photos query also expands descendants
The stored Folder.photo_count column is now unused by the API. A
future cleanup could drop it from the model entirely.
Verified on the dev DB: Library=7 (4 direct + Juno=2 + sub=1),
Juno=2, sub=1.
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>
Diagnosis: every backend restart was dispatching watch_folders.delay()
unconditionally. watch_folders is an infinite-loop celery task
(for changes in watch(*paths)). With CELERYD_CONCURRENCY=4 and several
restarts during dev, all four worker slots ended up pinned by stale
watch_folders instances, leaving zero workers free for scan_folder.
The result: clicking "Scan all folders" successfully queued a task
that then sat in the queue forever, the new /photos/sub folder was
never walked, and the user's newly added photo never appeared.
The watcher was only opportunistically useful and the user already
triggers scans manually. Disabling it removes the foot-gun. Re-
enabling needs:
- a Redis lock so only one watcher runs at a time
- or a dedicated long-running container with concurrency=1
- or a celery beat schedule with a singleton flag
Until then, manual scans work. Cleared the backlog by wiping the
redis broker volume so the stale watch_folders tasks are gone.
Verified: post-fix, scan_folder runs in 0.12s and reports
"Processed 7/7 files. Errors: 0", picking up the previously missing
/photos/sub/Samuel_Colman... file.
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>
The previous /heaps/{id}/convert dropped photos directly into a chosen
source root, which is rarely what you want — Lightroom-style behaviour
is "make a folder named after the collection inside the library".
Now the dialog lets you do that.
Backend
- HeapConvertBody gains an optional subfolder_name field. Path
separators and dot-segments are rejected. When set, the handler
joins it onto the resolved parent_dir, mkdir's it if missing, and
uses the resulting path as the move/copy destination. Otherwise
the parent_dir itself is used (unchanged behaviour).
- The Folder DB row for the destination is created via the existing
scanner get_or_create_folder helper so dedupe + path normalization
stay consistent across the codebase.
- The target source root id is propagated through both the source-
root and folder branches so the new Folder row is correctly
parented when subfolder_name is set on a folder target too.
Frontend
- HeapConvertDialog grows a "Subfolder name" input that prefills
with the heap name when the dialog opens. Trimmed empty value
drops directly into the parent. A live hint below the input
shows exactly which path will be created (or that the parent
will be used).
- api.ts heaps.convert() signature accepts an optional
subfolder_name field; the dialog sends it via mutationFn.
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>
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>
Two related polish items.
1. Drop dead TopBar buttons
- Removed the hamburger menu (Tab already toggles the sidebar),
the grid/list view-mode toggle (only Grid was ever
implemented), and the FolderOpen / Upload / Settings action
icons (no features behind them).
- TopBar is now: logo + active heap pill | search | filter
toggle. Removed the now-unused Grid/List/Menu/FolderOpen/
Upload/Settings icon imports and the dead viewMode local
state.
2. Wire live scan progress
- The frontend ScanProgress widget was already polling
/api/v1/library/scan/status, but the worker never wrote the
Redis keys that endpoint reads — it only updated celery's
internal task state. So the progress UI was permanently idle.
- Worker now writes scan:active / scan:current_folder /
scan:processed_files / scan:total_files / scan:errors at
every meaningful step. _get_redis() returns None on failure
so a Redis outage degrades gracefully (scan still runs,
progress just doesn't show).
- Pre-walk computes total_files upfront — without it the
progress bar jumped every time os.walk discovered a new
subfolder because the running total was being updated as it
went.
- Errors are RPUSHed to a capped list (MAX_ERROR_ENTRIES=50)
so a noisy scan can't blow up Redis.
- finally: clause guarantees scan:active flips to false even
on a crash, so the UI never sticks at "scanning" forever.
- scan_all_source_roots clears scan:errors and resets counters
before queuing the per-root tasks, so each top-level scan
starts with a clean slate.
Two latent bugs caught and fixed in passing:
- watch_folders was still reading settings.source_roots which
no longer exists since we moved source roots to the DB. Now
it loads them from the DB via a synchronous one-shot async
wrapper at task startup.
- _scan_all_source_roots_async was missing entirely after the
last refactor — defined inline now, reads active source
roots from the DB and dispatches scan_folder per row.
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>
Cleans up the maze of overlapping ways folders entered the app, plus
removes the dead trash plumbing left over from the soft-discard
refactor.
Setup model (now)
- ONE env var: PHOTO_DIRS in .env, set to the host path of your
library. Compose mounts that at /photos. That's the entire setup.
- On first boot, the backend auto-creates a SourceRoot row named
"Library" pointing at /photos so the user sees their photos
immediately without configuring anything.
- Source roots and discard live in the database; mulita.yml only
carries operational settings (thumbnails, scanner, performance).
- The "Add Source Folder" dialog is now a directory browser
restricted server-side to /photos and any existing source root —
the user clicks through actual mounted directories instead of
typing container paths they can't possibly know.
Backend
- New services/scanner.bootstrap_default_source_root(): if no
SourceRoot rows exist and /photos is mounted, create one. Wired
into the lifespan handler before cleanup + initial scan.
- New GET /library/browse?path= returning the immediate child
directories of `path`, validated to live under one of the allowed
roots (default mount + every active SourceRoot). Hidden entries
are filtered. Children are tagged with is_existing_root so the UI
can show an "Added" badge. Returns parent path for up-nav, or
null when at the top of the allowed scope.
- scan_all_source_roots now reads from the DB instead of the YAML
config so DB-managed source roots are honoured by initial scan.
- Dropped the placeholder source_roots block from mulita.yml — the
paths /photos/main and /photos/iphone never existed and just
produced startup warnings.
- Dropped TrashSettings, settings.trash, settings.source_roots,
and the SourceRoot pydantic model from config.py. Soft discard
has owned this for a while; it was dead code.
Compose
- Single ${PHOTO_DIRS:-./photos}:/photos:rw mount in both backend
and worker.
- Removed the hardcoded ~/Pictures:/host/Pictures:rw mount — the
PHOTO_DIRS variable is the single source of truth now.
- Removed the trash_data named volume + mounts (no consumers).
- backend/Dockerfile no longer creates /data/trash; it now creates
/data/proxies (which the proxy endpoint actually uses).
Frontend
- AddSourceFolderDialog rewritten as a directory tree picker:
loads /library/browse on open, lets the user navigate up via a
ChevronUp button or down by clicking subfolders, shows the
current path inline, and adds whatever directory is currently
shown. Existing source roots are tagged "Added" so the user
knows what's already registered. Errors from the backend (e.g.
trying to navigate outside the allowed scope) surface inline.
- New library.browse() helper + BrowseChild / BrowseResponse types
in services/api.ts.
Docs
- README Quick Start rewritten around the single PHOTO_DIRS env
var, with macOS/Linux/Windows examples.
- New "How mounted folders and source folders relate" section that
spells out the two-layer model (mount = visibility, source root
= scanning) so the most common confusion is addressed up front.
- Added a "Read-only libraries" subsection that lists exactly which
endpoints fail under :ro.
- "Configuration" section reframed: source roots are managed by the
UI/API now, mulita.yml is operational settings only.
- .env file now has examples for the common host paths.
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>