Commit Graph

18 Commits

Author SHA1 Message Date
b2ebf401bb chore: text format 2026-04-09 23:48:16 +02:00
9c9f5bd899 chore: change text style 2026-04-09 20:43:33 +02:00
b993a188d3 style: tint topbar "Built with hubris" footer to black/50
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:22:13 +02:00
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
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
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
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
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
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
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
a8750afef0 feat: cleaner TopBar + live scan progress wired end-to-end
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>
2026-04-08 00:54:11 +02:00
324cc0298b chore: drop duplicate selected count + Discard button from TopBar
Both were redundant:
- "N selected" is already shown by the contextual KeyboardHints pill
  below the FilterBar
- The Discard action is in the RightSidebar Flag section and bound
  to X (and exists as a per-photo button on the thumbnail when
  is_discarded)

Also removes the no-longer-used discardPhotosMutation, the photos
api import, the toast import, the useMutation/useQueryClient imports,
and the usePhotoStore selectedPhotos read — TopBar is leaner now.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:22:30 +02:00
ebae775f3f feat: heaps end-to-end with active heap and T shortcut
Adds the spec §6.10 heaps concept: named photo collections with a
single "active" target for fast keyboard adds. Uses a basket icon
(ShoppingBasket) to visually distinguish heaps from folders.

Backend (routers/heaps.py)
- Replaces the 27-line stub with full CRUD: list (with photo counts
  via a single LEFT JOIN), create, patch (rename + set active), delete.
- Add/remove photos endpoints with idempotent semantics: re-adding an
  existing member is a no-op, removing a non-member is a no-op.
- Setting is_active=true on one heap clears the flag on every other
  heap in a single UPDATE so we maintain the single-active invariant.
- routers/photos.py list endpoint now applies the heap_id filter via
  IN-subquery against heap_photos (it was a declared param but had
  no filter logic).

Frontend
- New hooks/useHeapsQuery.ts and useFilterUrlSync wires heap_id as
  another URL-persisted filter; usePhotosQuery threads it through.
- New components/heaps/HeapsPanel.tsx replaces the LeftSidebar Heaps
  stub. Shows the basket icon, photo counts, lets you create heaps
  inline, click to filter the timeline, set active via the target
  icon, and delete heaps.
- TopBar shows an "active heap" pill (basket + name) so the user
  always knows where the next T-press will land.
- KeyboardHints adds T → Add to heap.

T shortcut (useKeyboardShortcuts)
- Reads the active heap from the heaps query cache and the selection
  from the photo store at fire time. Adds the selected photos (or the
  active photo if nothing is selected) via POST /heaps/{id}/photos.
- Toasts:
  - "Added to {heap}: N photos (M already present)" on success
  - "No active heap" hint when none is set
  - "Nothing selected" hint when there's no selection or active photo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:06:52 +02:00
997e11db78 refactor: rename trash to discard end-to-end
User-facing labels and code now use "discard" (verb) and "Discarded"
(state/view label) instead of "trash" / "Trashed". The DB column names
stay (is_trashed / trashed_at) so no migration is required — only the
SQLAlchemy attribute names are renamed via Column('old_name', ...).

Backend
- Photo model: is_discarded / discarded_at attributes (DB columns
  unchanged).
- PhotoBase / PhotoResponse / PhotoUpdate schemas use the new field
  names.
- Photos list endpoint: is_discarded query param, filter logic.
- DELETE /photos/{id} now sets is_discarded; success message updated.
- Bulk action 'trash' renamed to 'discard'.
- backend/app/routers/trash.py renamed to discard.py with renamed
  functions and route prefix /api/v1/discard.
- main.py imports and mounts the discard router.
- tasks/scan.py marks missing files as is_discarded.

Frontend
- Photo TS type: is_discarded.
- PhotoThumbnail: shows the trash-can icon when is_discarded.
- RightSidebar: button label "Discard"; mutation field name; local
  variable rename.
- TopBar: discardPhotosMutation and "Discard" button; toast text
  "Discarded".
- LeftSidebar: virtual node id 'discarded' / label "Discarded".
- FilterBar / filterStore / useFilterUrlSync: FlagFilter enum value
  'trashed' → 'discarded'; backend param key is_discarded.
- KeyboardHints: X label "Discard".
- useKeyboardShortcuts: PhotoUpdate field rename, X handler.
- api.ts: /trash routes → /discard, trash export → discard,
  bulkUpdate trash field → discard.

Out of scope (intentional): the docker-compose trash_data volume,
backend/Dockerfile mkdir /data/trash, config.py TrashSettings, and
the spec doc — all unused since soft-discard, and renaming them is
churn for no benefit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:54:31 +02:00
d2155d9dd2 feat: filter bar with search, URL sync, and active-filter chips
Adds the spec §6.7 filter bar to the top of the timeline with:
  - Date range (native date inputs)
  - Media type chips (Photo / Video / RAW / HEIC, multi-select)
  - Min star rating (click to set, click again to clear)
  - Color label dots (red/orange/yellow/green/blue/purple, single)
  - Flag (any / picked / rejected / unflagged)
  - Clear-all button

Active filters surface as removable chips below the bar so they're
visible whether the bar is collapsed or open. The TopBar search input
is now wired to the same filter store with a 300ms debounce, and shows
a clear button when populated.

Filter state is the source of truth in a Zustand store and round-trips
through the URL via history.replaceState — bookmarkable and shareable
per spec §6.7. Hydrate happens once on mount; subsequent store changes
write back to ?q=&date_from=&… without navigation.

Timeline reads filter state, builds the backend params via
filtersToParams, and includes them in the React Query key so the cache
invalidates on every filter change. Also fixes a latent bug: Timeline
was sending limit=1000&offset=0, which the backend silently ignores —
swapped to page=1&per_page=500 with explicit sort=taken_at&order=desc.

New keyboard shortcuts:
  - \\  toggles the filter bar
  - /   focuses the TopBar search input
  - Cmd/Ctrl+F  same as /

Filter button in the TopBar now lights up when filters are active or
the bar is open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:50:58 +02:00
78e12e8309 feat: timeline and folder import 2026-04-07 00:42:22 +02:00
6d1b227fb9 feat: structure 2 2026-04-07 00:15:00 +02:00