Commit Graph

101 Commits

Author SHA1 Message Date
2e7158a5bb feat: desert dusk theme with animated mule and pixel-art scenery
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>
2026-04-09 18:49:13 +02:00
938c5dce68 refactor: unify thumbnail ornaments into one chip family
Every overlay on a thumbnail now composes the shared THUMB_BADGE_* classes
(one shape, one height, one ring, three semantic colour variants: primary
for user state, neutral for metadata, pick for auto-suggested best). RAW
badges, BEST pill, Keep-this button and the dimensions chip — previously
three different styles — join the family, is_duplicate moves to neutral
since it's file metadata not a user decision, and the selection check
shrinks to match the rest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:57:55 +02:00
6d8e92940a feat: auto-focus first photo on timeline mount
Arrow-key navigation required a click to establish an active photo
first. Now the grid auto-selects photos[0] on initial mount when no
active photo is set, so the user can land on the app and immediately
walk the grid with arrows. Guarded on viewMode === 'grid' and
!activePhotoId so we never clobber an existing selection or fight
with PreviewView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:47:16 +02:00
f4c51f6f92 fix: make keyboard hint pill legible over busy thumbnails
The bottom-center hint bar used bg-surface/40 + text-text-muted, which
left busy photos leaking through and washed the text out — users
couldn't actually read the shortcut labels when a bright thumbnail sat
directly behind the pill. Switched to a near-opaque bg-black/80 with
white-alpha text + white/15 key caps. Backdrop blur stays for the
subtle glass feel, but the contrast is now unconditional on the
photo underneath.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:44:04 +02:00
64b84e2069 feat: active heap card in left sidebar with subtle toasts
- 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>
2026-04-09 17:35:06 +02:00
733c16bf82 feat: perceptual-hash duplicate detection + grouped picker view
The Duplicates section was useless: SHA-256-only detection only caught
byte-identical files, not the actual duplicates a real library
accumulates (re-encoded JPEGs, screenshots, resized exports), and the
view was a flat date-sorted list with no grouping or actions. This
replaces the whole flow.

Detection
- New phash + duplicate_group_id columns on Photo, added via an
  idempotent ALTER TABLE pass in init_db (the project has no Alembic).
- Thumbs worker computes a 64-bit pHash from the original-resolution
  decoded frame just before the destructive thumbnail loop. Falls back
  silently — phash is nice-to-have, not a blocker for thumbnails.
- backfill_phashes Celery task fills in phashes for photos that
  predated the column, reading the existing thumb_large rather than
  re-decoding the original.
- regroup_duplicates service runs union-find over Hamming distance
  (threshold 6), persists duplicate_group_id, and maintains is_duplicate
  as derived state so existing badges/counts keep working. Chained
  after scan_all_source_roots with a 60s countdown.

API
- GET /library/duplicates/groups returns all groups with members,
  bucketed in Python from one query. Each group has a reason ("exact"
  iff every member shares a SHA-256, "similar" otherwise).
- POST /library/maintenance/{regroup-duplicates,backfill-phashes}.

Frontend
- New DuplicatesView (sectioned grid, one section per cluster) replaces
  the timeline when the user is in the duplicates section. Each section
  shows a "Keep best, discard N" button that picks the highest-pixel
  copy and reuses the existing undoable bulk-discard so Cmd+Z works.
- Manual best override: hover any non-best thumbnail and click "Keep
  this" (Crown icon, top-right) to override the auto-pick. The header
  annotates "(manual)" so it's obvious which copy will be kept.
- Keyboard nav within the duplicates view walks the flat member list,
  with ↑/↓ jumping by the measured column count and scrollIntoView on
  every move. Timeline's keyboard handler now early-returns in the
  duplicates section so the two don't fight.
- BEST pill / Keep-this button live at top-right with a ring outline so
  they don't collide visually with the cyan selection ring around a
  selected cell. Dimensions chip moved to bottom-left to free both
  right corners for the keep affordances.
- New "Duplicates" section in SettingsDialog: shows group/member counts
  and exposes both backfill + re-detect actions, sharing a query cache
  with DuplicatesView via DUPLICATE_GROUPS_QUERY_KEY.
- PhotoInfoPanel "Basic Info" section now shows the photo's full file
  path in monospace below the size/dimensions/date grid.
- New imagehash==4.3.1 dep in requirements.txt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:19:08 +02:00
e51b93d59e perf: speed up settings dialog + relocate settings entry point
- 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>
2026-04-09 16:31:52 +02:00
d6c667ae78 feat: persistent metadata panel, symmetric sidebar toggles, keyboard nav scroll
- 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>
2026-04-09 16:20:22 +02:00
root
a4b1802657 fix: square timeline cells that fully fill each row
The grid was leaving horizontal space unused for two reasons:

1. Width measurement was based on parentRef.clientWidth - PADDING*2,
   which is fragile to padding/box-sizing/scrollbar mismatches and was
   off by enough pixels in practice to drop a column. Replace with a
   1px-tall normal-flow sentinel rendered inside the inner virtualizer
   wrapper at the exact horizontal extent rows render at. ResizeObserver
   on the sentinel gives the authoritative row width — no padding
   subtraction, no scrollbar guesswork.

2. The flex layout with explicit per-cell px widths accumulated floor()
   rounding error and let cells drift away from square. Switch to a CSS
   grid with fixed-px tracks (`repeat(cols, ${cellSize}px)` + matching
   `gridAutoRows`) so every track is exactly cellSize wide AND tall. By
   construction `cols × cellSize + (cols-1) × gap == measured width`,
   so the row fills edge-to-edge with cells that are guaranteed square.

PhotoThumbnail gains an opt-in `fill` prop the timeline uses to switch
its inline width/height to 100%, so the cell stretches to whatever the
parent grid track gives it. Heap sidebar / non-grid callers still get
explicit `size`-by-`size` square thumbnails as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:19:29 +02:00
root
ce25a4460e fix: never cache index.html so new bundle hashes are picked up
Vite content-hashes the JS/CSS bundle filenames, but the only thing
that tells the browser to fetch a new hash is a fresh index.html.
Without explicit no-cache headers nginx falls back to heuristic
caching, so users keep loading the old index.html → old bundle hash
until they hard-reload. Just hit this rolling out the timeline
pagination fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:42:16 +02:00
root
872be4e0cf fix: load full library + stretch grid to fill row width
Two unrelated bugs surfaced together because the symptom looked
similar ("missing photos in the grid"):

usePhotosQuery only ever fetched page 1 with per_page=500, so any
filter matching more than 500 photos silently truncated. With a
13k-photo library that meant the Timeline only showed ~3.8% of
matches and folders with many descendants looked broken. Walks all
pages now (capped at 200 = 100k photos as a sanity bound), keying
the React Query cache on the full filter set as before.

Timeline grid was leaving an unused horizontal strip on the right.
Two issues in the column math:
  - off-by-one: floor((W - 2P) / (T + G)) double-counts gaps. With N
    columns there are only N-1 inter-cell gaps, so the correct form
    is floor((W - 2P + G) / (T + G)). Reclaims a column whenever the
    remainder almost fits.
  - the floor remainder was discarded instead of distributed back
    into the cells. Treat THUMBNAIL_SIZE as a minimum and stretch
    each cell to (available - (cols-1)*gap) / cols so the row fills
    the container.

Also swap the resize listener for a ResizeObserver on the scroll
container so the grid re-flows when the sidebar collapses (window
resize alone misses that).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:22:06 +02:00
root
d27ec1af2e fix: prune orphaned folder rows alongside photo rows
prune_missing_photos() previously only deleted Photo rows whose files
were gone, leaving every folder row from the old library in the DB —
which made the sidebar tree wildly out of sync with the on-disk
structure (still showing /photos/2024/, /photos/2026/03/, etc. that
no longer exist).

Now also drops Folder rows whose path doesn't resolve under a mounted
source root, with the same defensive "skip if source root unmounted"
guard. The Settings orphan card surfaces both counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:32 +02:00
root
697343646a feat: prune orphaned photo rows + retry-pending action
Adds /api/v1/library/maintenance/{missing-stats,prune-missing} backed
by a new cleanup helper that deletes Photo rows whose files no longer
exist on disk under a *mounted* source root. Skips photos under
unmounted roots so a temporarily-disconnected drive doesn't get
silently nuked.

Settings panel surfaces the orphan count with a destructive Prune
button, plus a "Kick pending" action that re-queues photos stuck in
processing_status='pending' (typically left behind when the scanner
created the row but the worker never picked up the thumbnail task).

Common trigger: PHOTO_DIRS in .env was repointed at a different
library root, leaving every old row dangling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:47:06 +02:00
root
3df8add3b6 fix: stop scan crashing on duplicate file hashes
scan.py used scalar_one_or_none() to test whether any other photo
shared the same file_hash, but that helper raises MultipleResultsFound
the moment 2+ rows match — i.e. exactly the duplicate case it was
trying to flag. Every file beyond the second copy bombed out with
"Multiple rows were found when one or none was required" and was
left in the failed bucket. Replace with a COUNT(*) > 0 check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:14:40 +02:00
root
42250aa16e feat: worker diagnostics in settings panel
Adds /api/v1/library/maintenance/worker-status (Celery inspect + queue
depths + recent failed photos) and a Workers section in the Settings
dialog so users can debug stuck queues and task failures without
tailing container logs. Auto-polls every 5s while open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:11:33 +02:00
9ed577f40c feat: settings panel + thumbnail pipeline fixes
- 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>
2026-04-09 09:24:08 +02:00
6fa00f4b37 style: unify thumbnail indicators on the primary blue palette
The badges on a thumbnail were a rainbow — yellow stars, blue check,
green heap pill, red discard, black duplicate — and read as five
unrelated palettes. Collapse to a single family.

PhotoThumbnail:
- Rating stars: bg-primary pill with white star icons (was yellow on
  a translucent dark backing).
- Heap basket / name chip: bg-primary (was bg-pick green).
- Duplicate badge: bg-primary (was bg-black/70).
- Discarded badge: bg-black/75 (kept neutral-dark, deliberately NOT
  blue, so "in this collection" and "trashed" never collapse into the
  same visual).
- All badges share the white outer ring + thicker icon stroke from
  the previous contrast pass.

HeapsPanel:
- Active heap row uses bg-primary/8 instead of bg-pick/10.
- Basket icon turns text-primary on the active row (was text-pick).
- "Active" pill uses bg-primary/25 + text-primary (was bg-pick/25 +
  text-pick).

The whole indicator family now reads as one consistent thing in the
brand blue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:30:35 +02:00
e49f64659e feat: surface active heap on photos + sidebar row, drop topbar pill
- 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>
2026-04-08 22:27:22 +02:00
9317730885 style: improve thumbnail indicator + selection contrast
- Bump primary from #3b6ed8 to #3b82f6 (Tailwind blue-500). Higher
  saturation reads better against both the dark surface and varied
  photo content. Contrast against bg-bg goes from ~5.7:1 to ~6.6:1.
- Selection ring: add ring-offset-2 ring-offset-bg so the bright blue
  has a dark gap separating it from the photo edge — pops on light
  and dark photos alike. Hover ring gets the same treatment.
- Selection check badge: white ring + shadow + thicker stroke so the
  badge is legible against any photo (was disappearing on bright
  scenes).
- Rating stars: wrap in a translucent dark pill with backdrop-blur so
  yellow stars don't vanish on yellow / sandy photos.
- Heap / duplicate / discarded badges: matching white ring + thicker
  icon stroke so they all read consistently and don't blend in.
- Timeline date headers: text-text instead of text-text-muted so the
  group labels actually pop above the grid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:22:07 +02:00
eb16564b84 fix: gate Timeline arrow keys to grid mode
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>
2026-04-08 22:14:19 +02:00
7bb03be51a fix: stable preview nav handlers via ref
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>
2026-04-08 22:07:46 +02:00
123c60ed2c fix: visible-order range selection + preview-after-Space sequence
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>
2026-04-08 22:04:18 +02:00
e2ee9b691c fix: pass visible sequence to openPreview from the click site
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>
2026-04-08 21:52:42 +02:00
57f510ad3d fix: keep keyboard hints on a single line
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>
2026-04-08 21:46:27 +02:00
26424469db feat: center hints over the timeline + add hubris footer
- 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>
2026-04-08 21:34:18 +02:00
ec4a14976d style: switch primary accent from teal to deep royal blue
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>
2026-04-08 21:30:46 +02:00
e7d62c29e1 feat: change empty state 2026-04-08 21:27:56 +02:00
8e00dd40f0 chore: rename app from Mulita to Mulimago
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>
2026-04-08 21:27:07 +02:00
8db4242503 fix: clear-all sits next to filters with a borderless style
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>
2026-04-08 21:24:52 +02:00
dea19e5c23 fix: filter bar layout — pills left, search right
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>
2026-04-08 21:23:04 +02:00
5a0f9ff592 fix: heap convert dialog supports nested subfolders + visiblePhotoIds loop guard
- 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>
2026-04-08 21:17:32 +02:00
a56062d353 fix: preview navigation walks the timeline's visible order
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>
2026-04-08 21:13:08 +02:00
7a0f738aa8 fix: keep Rating + Tags pills in their sections, only hide Flag in Discarded
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>
2026-04-08 21:07:26 +02:00
d7f953d0a9 fix: hide section-locked filter pills in their own sections
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>
2026-04-08 21:04:45 +02:00
30e0900e49 fix: lock filter bar height + freeze pill positions
- 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>
2026-04-08 21:01:01 +02:00
c01c3b02ce chore: use the Mulita logo as the browser favicon
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>
2026-04-08 20:58:13 +02:00
4e5b2cabf6 fix: align sidebar counts in a single right column
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>
2026-04-08 20:57:11 +02:00
f05ae77ef0 feat: folder CRUD with discard-or-delete dialog
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>
2026-04-08 20:51:23 +02:00
bd904aca36 fix: assorted UI polish from review pass
- 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>
2026-04-08 20:45:30 +02:00
696477eefd feat: heap row kebab menu with rename + duplicate
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>
2026-04-08 20:36:00 +02:00
fc63b1f69d config: env-driven CORS, ports, log level, timezone
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>
2026-04-08 20:31:59 +02:00
ac5b18b60c fix: cross-machine access + center filter bar + floating hints
- 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>
2026-04-08 20:28:04 +02:00
a5b4054a71 feat: bulk tag add/remove on multi-select right sidebar
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>
2026-04-08 20:25:18 +02:00
a55839d9a2 fix: more audit findings — perf, types, and a11y polish
- 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>
2026-04-08 20:22:23 +02:00
749e836617 fix: high-severity findings from code audit
- 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>
2026-04-08 20:18:19 +02:00
07b9660e92 feat: undo for destructive photo actions
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>
2026-04-08 14:08:58 +02:00
b870084be0 feat: per-photo permanent delete + discarded thumbnail treatment
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>
2026-04-08 13:12:11 +02:00
3a03a56db2 feat: photo metadata panel in preview view
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>
2026-04-08 13:01:25 +02:00
1c428dda8b fix: portal FilterPill popover so it escapes overflow clipping
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>
2026-04-08 12:53:04 +02:00
2f1e9033ae refactor: compact pill-based filter toolbar
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>
2026-04-08 12:49:14 +02:00