Pulled the hubris/Roman-year line out of the TopBar and into a new
Footer component rendered below the Settings button in the left
sidebar bottom panel, where it reads as a quiet attribution rather
than competing with the title plate up top.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FilterBar: new Date pill hosts from/to inputs; calendar stays in left
sidebar (always visible, no collapse) with reduced padding and a
taller MONTH_HEIGHT so 6-week months render fully.
- LeftSidebar: drop Library collapse; Heaps regains its chevron toggle
to match Views/Folders.
- RightSidebar: render ActiveHeapCard above the Metadata header (with
its own eyebrow); preview overlay reuses RightSidebar so the active
heap stays visible there too.
- Toaster: top-right, more compact (smaller padding, font, gap).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wraps Views, Folders, Shared-with-me, and Heaps in a single
click-to-toggle Library section with a consistent h-9 eyebrow header
(matching the new Date header). Heaps keeps its own eyebrow
sub-section so it sits alongside Folders, and heap rows now reserve
the same chevron-slot spacer as leaf folder rows so indentation
lines up across hierarchies. ActiveHeapCard moves to the very top
of the sidebar so it stays visible under any panel state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pulls the date range picker out of the filter-bar pill into a
dedicated always-visible section at the top of the left sidebar, and
teaches the timeline to publish its visible photo sequence so "open
first photo" shortcuts respect the on-screen order.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Perf / a11y (high-impact review items)
- Timeline arrow-key handler binds once per (viewMode, currentSection)
and reads fresh state via navStateRef instead of an 8-element dep
array of new-each-render values.
- usePhotosQuery collapses 14 individual Zustand selectors into one
useShallow selector returning the params object.
- PhotoThumbnail no longer subscribes to the search query directly;
Timeline subscribes once and passes it down as a prop.
- PhotoThumbnail gains role="button", tabIndex, aria-label, aria-pressed,
Enter/Space key handlers and a focus-visible ring. Timeline marked
role="grid"; RightSidebar marked role="region".
Style consistency
- Swap clsx for cn (tailwind-merge aware) across 17 files so
conflicting utility classes collapse correctly.
- New Badge primitive (ui/badge.tsx) with default/neutral/overlay/
outline variants; adopted in ColorsView, RatedView, TagsView for
the repeated count overlay pill.
- Fix palette drift: text-amber-400 -> text-star, text-green-*
-> text-pick, text-red-* -> text-reject (5 files).
- Button gains an xs size (h-6 px-1.5 text-[11px]) for the repeated
compact-button pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Frontend cleanup pass driven by the post-shadcn review.
Performance
- Memoize PhotoThumbnail and route cell click/double-click through
stable handlers so heap-membership invalidation no longer re-renders
every visible thumbnail.
- Cap usePhotosQuery's eager background page-walk at 20 pages with a
50ms inter-page yield — was unbounded (up to 100k photos cold).
- Drop the per-thumbnail loading spinner in favour of the existing
pulse skeleton; only retry state still surfaces a spinner.
UX
- Coalesce rapid X/U presses into a single undo entry + one toast
(1.2s window) so accidental bursts are easy to back out.
- Optimistic rating/color updates with per-id snapshot rollback on
error, matching the existing discard pattern.
- Section-aware empty timeline state with a Clear-all-filters CTA.
- Carry the search-match chip from the grid into the preview header.
- Add a basket-icon badge for active heap membership so the green
tint isn't the only signal (colorblind-safe).
- Standardise error toasts via formatApiError(): FastAPI detail,
validation arrays, axios message, with a 'Network Error' filter.
Architecture
- Extract useBulkPhotoMutations and stop duplicating
bulkRating/bulkColor across RightSidebar and useKeyboardShortcuts.
- Split RightSidebar (714 -> 448 LOC) and PhotoInfoPanel (952 -> 716)
into co-located sub-components: BulkTakenAtEditor, BulkTagsEditor,
TagsEditor, TakenAtEditor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adopts shadcn/ui components (Dialog, Button, Input, Select, Popover,
Command, Checkbox, Switch, Toggle, Calendar, etc.) across the app,
replacing hand-rolled modals, dropdowns, and form controls. Adds a
reusable cmdk-backed MultiSelect for the Type, Tags, and Flag filters
so all multi-value filter popovers share one component and layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Selection now reads as a blue ring + tint with a springy scale-down,
hover stays a subtle gray ring so keyboard-driven and mouse-driven
states are tellable apart.
- Heap membership is signalled with a green tint only (no badge, no
ring, no scale).
- Discard/restore is optimistic and non-yanking: photos stay in the
grid greyed out until the next reload, X toggles based on the
current state, and the same treatment applies in preview.
- Filmstrip mirrors the grid styling (selection blue, heap green,
discarded grey).
- Preview close restores the LAST viewed photo as the focused/selected
one in the grid.
- Right sidebar collapses on view change and re-opens when a photo is
in focus; Esc clears active selection so the panel collapses too.
- Keyboard hints panel is context-aware (grid / preview / discarded
section), collapsible with H, persisted, and rendered inside the
preview column above the filmstrip.
- "Pick (P)" renamed to "Select (S)" everywhere.
- Needs review moved into the Flag pill dropdown.
- Fixed vertical videos overflowing the preview column (min-h-0).
- Replaced the bottom-right ScanProgress popover with an inline
spinner next to the FOLDERS sidebar header (and on the specific
folder row being scanned). ScanProgress is now a headless
invalidator; useScanActivity exposes the live status.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Folder tree now indents 20px per level (chevron width + gap) so a child's
chevron column lines up under its parent's label, and depth-1 rows nest
under the section eyebrow instead of starting flush with it. Spacer for
leaf rows matches the chevron button footprint so rows align regardless
of expandability.
Sidebar open/close buttons (previously split between TopBar and each
panel header) collapse into two toggles at the ends of the FilterBar.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds Redis-backed feature flags for vision stages with admin UI toggles
and manual backfill trigger, photo upload and download routers with
frontend upload modal, and rawpy-based RAW decoding with JPEG fallback
for misnamed DNGs. Fixes pgvector serialization, is_trashed filter, and
naive-datetime bind in incremental duplicate regrouping; bumps Celery
time limits on regroup tasks beyond the 5-minute default.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sharing:
- New HeapShare and FolderShare models with read/write permissions
- Sharing API router (CRUD for heap and folder shares)
- Heap endpoints accept shared access (photo_ids, add/remove with write)
- Photo list drops user_id filter in shared context, adds owner_username
- Media serving (thumb/original/proxy) falls back to share check on 404
- ShareDialog component for managing shares from kebab menus
- HeapsPanel shows "Shared with me" section for shared heaps
- LeftSidebar shows "Shared with me" section for shared folders
- Owner badge on PhotoThumbnail for photos from other users
Auth:
- Access token default bumped to 1 year, refresh to 10 years
- Refresh token persisted in localStorage (survives page reload)
- Timer-based refresh replaced with 401 axios interceptor
Vision pipeline fixes:
- Bootstrap sets Redis ready key even on partial export failure
- Export functions run conditionally (only for actually missing models)
- _load_thumb handles multi-user path (/data/thumbs/{user_id}/{photo_id}/)
- can_access_photo_via_share uses single subquery instead of N+1 loop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Move watch_folders to dedicated 'watcher' queue with its own
single-concurrency container so it never blocks scan/thumbnail slots
- Add get_current_user_media dependency that accepts ?token= query
param for <img src> / <video src> media endpoints (thumb, original,
proxy) — fixes 401 on thumbnails
- Append JWT token to all media URLs in the frontend
- Add missing 'memories' case in sidebar navigation switch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a Memories view that surfaces photos taken on the current date in
previous years (like Google Photos / Immich). Only uses EXIF-sourced
dates to avoid false matches from filesystem timestamps.
- Backend: GET /api/v1/photos/memories returns groups by year, up to
12 photos each, filtered to non-discarded/non-hidden EXIF dates
- Frontend: MemoriesView with year-grouped thumbnail grid
- Sidebar: new "Memories" nav item with clock icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.
Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup
Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a per-folder "hide from views" toggle so noisy subtrees
(screenshots, WhatsApp dumps, work archives) can be excluded from
cross-cutting views without losing indexing. Photos under a hidden
folder are still scanned, thumbnailed, embedded, OCR'd, face-
extracted — they just stop appearing in All Photos, Rated, Colors,
Map, Tags, People, Search, Duplicates, and the sidebar counts.
Navigating directly into the folder still shows every photo.
Schema (migration 0007_folder_hidden):
- folders.is_hidden user-set toggle, default false
- photos.is_hidden denormalized effective flag (true iff any
ancestor folder is hidden), indexed so cross-
cutting queries stay on the existing planner
paths
The denorm is maintained by two paths:
- The scanner walks the ancestry chain on insert, with a per-scan
memoized cache so each folder is resolved once per scan.
- POST /api/v1/folders/{id}/hide flips folders.is_hidden and runs a
WITH RECURSIVE CTE to recompute every folder's effective state in
one query, then bulk-updates photos WHERE IS DISTINCT FROM. Runs
in ~10 ms on a 13k-photo library.
Filters added (cross-cutting queries):
- /library/stats — every sidebar badge via a shared `visible` filter
- /photos (list) — only when neither folder_id nor heap_id is set;
folder browse and heap browse always show everything
- /photos/map
- /library/duplicates/groups
- /folders/tree photo_count subquery
- /tags count_subq (drives Tags + People sidebar counts)
- services/duplicates.regroup_duplicates (so hidden dupes never
contaminate the Duplicates view)
- services/search.hybrid_search — both semantic (pgvector) and FTS
legs join photos so rankings don't include hidden results
Intentionally NOT filtered:
- /photos?folder_id=X and /photos?heap_id=X (user-intentional browse)
- /library/maintenance/pipeline-stats (tracks real worker state)
- cleanup service (disk-level ops, not views)
Frontend:
- sourceFolders.setHidden(id, hidden) API client method
- FolderTreeNode.is_hidden carried through the tree into TreeItem
- LeftSidebar kebab menu: "Hide from views" / "Show in views" with a
mutation that invalidates folders, photos, stats, and tags caches
- Hidden folder rows swap the Folder icon for EyeOff and render the
label italic/muted so the state is visible at a glance
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Info icon with braille mule art in the right sidebar empty
state. Lighten text-muted (#a8997d → #c4b599) and text-faint
(#5e5448 → #7a6e5e) for better readability across the app.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace Timeline-based grouped views (tags, colors, rated) with
dedicated card-grid components that drill into Timeline detail views
on click/Enter. Adds shared useCardGridNav hook for arrow-key
navigation across all four card grids (tags, colors, rated, people).
- TagsView, ColorsView, RatedView: card grid → inline Timeline detail
- PeopleView: migrated to same pattern (Timeline replaces custom grid)
- Tags endpoint: fall back to first associated photo for representative
- Filter store: add ratingMax for exact rating filtering in RatedView
- Timeline: remove tag/rating/color grouping; skip date headers when
groupBy != 'date' so detail views render flat grids
- SettingsDialog: bump z-index above Leaflet map layers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the tag-grouping people section with a dedicated PeopleView:
- Grid of face cluster cards showing representative photo thumbnail,
person name, and photo count
- Click a card → navigates to all-photos filtered by that person's tag
- Inline rename via pencil icon on hover
- Empty state when no faces have been clustered yet
Wired into App.tsx as a section-level route alongside Map and Duplicates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add search API client (POST /photos/search) and useSearchQuery hook
for hybrid FTS + semantic search with RRF ranking
- Extend Tag type with kind, source, representative_photo_id fields
- Add tags.merge() API method
- Update useTagsQuery to accept optional kind filter
- Add People section to sidebar (face clusters from GET /tags?kind=face_cluster)
- Sidebar Tags count now shows user tags only; People shows face clusters
The existing GET /photos?q= flow is preserved for browsing; the new
search hook activates when the search box has a non-empty query.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Discard yanks photos from the grid optimistically (cache strip + active
cursor advance) instead of waiting for the mutation round-trip; wired
through the X hotkey, RightSidebar bulk discard, LeftSidebar discard
drop, and DiscardActionBar restore/delete.
- usePhotosQuery resolves on the first 500-photo page and streams the
remaining pages into the cache in the background, so the first
thumbnails paint immediately on large libraries.
- Closing preview restores the photo it was opened on (snapshot ref in
PreviewView, written directly to the store) and Timeline scrolls that
row back into view. Escape is handled on the dialog with
stopPropagation so Timeline's window-level Esc handler doesn't wipe
the restored selection.
- Preview overlay bumped to z-[1000] so it covers Leaflet map tiles,
and the right sidebar no longer collapses during preview — both fix
visible layout shifts on close.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new Map sidebar entry that plots photos by their EXIF GPS
coordinates on a clustered Leaflet map. While wiring this up, the
metadata extractor was reading unprefixed GPS keys that never exist
in `exiftool -G -j` output AND assumed coordinates were already
floats — every photo silently lost its GPS. The new extract_gps
helper handles Composite/EXIF group prefixes and parses DMS strings,
and lat/lon are stored as first-class indexed columns so the map
can query them without parsing exif_json on every request.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
LIBRARY_STATS_QUERY_KEY was never invalidated by the bulk/single
mutation paths in RightSidebar and PhotoInfoPanel, so the All Photos
/ Rated / Discarded / Tags badges only updated on reload. Add it to
both shared invalidation helpers and the inline updateMutation, and
correct the stale docstring on useLibraryStatsQuery.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Replaces the cool-grey neutral theme with a warm desert dusk palette
pulled from a new pixel-art TopBar: a tiled, right-to-left scrolling
desert under a sky gradient, a 6-frame walking mule sprite where the
logo used to sit, and an ASCII block-character title on a black plate.
The active heap card now uses a cactus/dune scene as its stack
backdrop, and the "Built with hubris" mark moves into the TopBar's
bottom-right corner (AppFooter component removed).
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>
- photos.py: stop crashing in FileResponse when a thumb hasn't been
generated; return a clean 404 with Retry-After so the frontend can
back off.
- thumbs.py: fix process_video_thumbnail (overwrite_output, robust
duration probe across stream/format, eager frame load + temp cleanup)
so videos stop ending up as the gray placeholder.
- library.py: new /maintenance/* endpoints — thumbnail-stats,
regenerate-thumbnails (with media_type / only_failed filters), and a
manual data-integrity cleanup trigger.
- Frontend Settings panel (gear in TopBar) surfacing those endpoints
plus a re-scan button and live thumbnail status counts.
- PhotoThumbnail: stretch the auto-retry schedule for slow RAW jobs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PhotoThumbnail: the bottom-right basket badge now expands into a
name chip when the active heap name is supplied. Truncated to a
120px max-width so it doesn't eat the thumbnail.
- Timeline: read activeHeap.name from useActiveHeapMembersQuery and
pipe it down to PhotoThumbnail.
- TopBar: drop the leftover heap pill next to "Mulimago" — the active
state now lives where the user navigates to it (the heaps row).
- HeapsPanel: the active heap row gets a soft bg-pick/10 wash when
not also filtered, the basket icon turns text-pick, and a small
"ACTIVE" pill renders next to the name. Together they make it
obvious which heap Pick / T target without needing the topbar pill.
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>
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>
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 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>
Extract the single-photo body of RightSidebar into a reusable PhotoInfoPanel
(rating / color / flag / filename / title / notes / tags / EXIF) and mount
it inside PreviewView as a toggleable right-side overlay so the user can
rate, tag, and read EXIF without leaving the loupe.
- New PhotoInfoPanel: self-contained, owns its own queries and mutations,
takes a single photoId. darkTheme prop reserved for future use.
- RightSidebar: thinned down — delegates the single-select case to
PhotoInfoPanel, keeps its own slim bulk-action panel for multi-select.
- PreviewView: I toggles the panel; new top-right Info button mirrors it.
- useKeyboardShortcuts: gate the global I (right-sidebar toggle) to grid
mode so it doesn't double-fire alongside the preview-scoped handler.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge the toggleable multi-line FilterBar and the separate ActiveFilterChips
strip into a single always-visible row of pills. Each filter category is a
pill that opens a small popover with its underlying control; when active, the
pill shows its current value inline (so the chips strip is redundant).
- New FilterPill primitive: outside-click + Escape to close, optional inline
X to clear without opening the popover.
- FilterBar rebuilt out of pills for Date/Type/Rating/Color/Flag/Tags/Sort,
with a Clear-all pill on the right when any filter is active.
- Drop filterBarOpen from filterStore, the SlidersHorizontal toggle from
TopBar, the \\ shortcut from useKeyboardShortcuts, and the matching hint
from KeyboardHints — the bar is always visible now.
- Delete ActiveFilterChips; its information lives inside the pills.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>