Commit Graph

19 Commits

Author SHA1 Message Date
30d03d8d4d feat: editable taken_at + folder-based date repair and filter
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>
2026-04-11 11:48:55 +02:00
c973ca0443 feat: colors view and color label thumbnail ornament
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>
2026-04-09 20:41:06 +02:00
7d1149aabd feat: themed scrollbars and chiseled thumbnail chips
- Replace browser scrollbars with slim 6px overlay pills that tint to
  primary on hover (transparent track, Firefox + WebKit).
- Rework the thumbnail badge family: drop the mismatched white ring
  halo for a 1px dark frame + inset top highlight, and swap rounded-
  full pills for rounded-sm chips so the ornaments match the pixel-art
  aesthetic used elsewhere in the app.
- Soften the ActiveHeapCard desert backdrop so the fanned thumbnails
  read on top.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:09:49 +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
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
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
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
bed817d274 feat: heap convert-to-folder + surface exact-duplicate detection
Two related polish items.

1. Heap convert to folder
   Closes a long-standing TODO from spec §6.10.
   - Backend: POST /heaps/{id}/convert with body
       { target_id, mode: 'move'|'copy', delete_heap: bool }
     target_id resolves either as a Folder id or a SourceRoot id (same
     convention as /photos/move). For each member photo, dispatches
     either shutil.move + photo.folder_id update, or shutil.copy2 +
     a new is_duplicate=true Photo row with all metadata copied. Name
     collisions on copy use the same " (copy N)" suffix scheme as
     /photos/copy. The heap row is optionally deleted on success.
     Per-photo failures are collected into the response instead of
     aborting the batch.
   - Frontend: new HeapConvertDialog with a target-folder dropdown
     (currently from sourceFolders.list, sub-folder picking is a
     follow-up), move/copy radio, and a "delete heap" checkbox.
     HeapsPanel rows get a hover FolderOutput button that opens it.
     Toast on success names the verb + count and notes whether the
     heap was deleted; invalidates heaps + photos + folders queries.

2. Surface exact-duplicate detection
   The scanner already sets Photo.is_duplicate=true when a SHA-256
   match is found, but nothing surfaced it. Now:
   - Backend list_photos accepts an optional is_duplicate query
     param so the frontend can filter duplicates-only views.
   - filterStore gains a duplicates: boolean field with setter, URL
     sync (?duplicates=true), filtersToParams entry, and a
     hasActiveFilters check.
   - LeftSidebar gets a new "Duplicates" library node (Copy icon)
     that clearAllFilters() + setDuplicates(true). isItemActive
     follows the filter so the highlight stays in sync after
     external filter changes.
   - PhotoThumbnail renders a small dark badge with the Copy icon
     bottom-right when photo.is_duplicate. Sits next to the existing
     basket / discard badges so the user can spot duplicates at a
     glance.
   - Photo TS type adds is_duplicate.

Perceptual-hash duplicate detection (re-encoded / resized matches)
is intentionally a follow-up — needs an imagehash dep, a phash
column, a backfill job, and similarity-search endpoint with
hamming-distance grouping. This commit only surfaces what the
scanner already finds via byte-level SHA-256 comparison.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:28:18 +02:00
485b60ff20 feat: drag photos onto a heap row to add them
Lightroom-style direct manipulation: pick up a photo (or a multi-
selection) and drop it on a heap to add it. Complements the P
shortcut without replacing it.

PhotoThumbnail
- Becomes draggable. onDragStart reads the current selection from
  the photo store at fire time: if the dragged photo is part of the
  selection, the payload is the whole selection; otherwise it's
  just that one photo. Matches Finder semantics.
- Payload uses a custom MIME (application/x-mulita-photos) so the
  drop target can recognise our drags vs. arbitrary file drags from
  the OS. Also sets text/plain so dropping outside the app shows a
  sensible "N photos" string.

HeapsPanel
- Each heap row is now a drop target. onDragOver previews the drop
  effect and highlights the row with a primary ring and a faint
  background tint. onDragLeave only clears the highlight if the
  cursor actually left the row (not just moved over a child).
- New dropMutation handles the drop: optimistic membership cache
  update so the basket affordance flips immediately, rollback on
  error from a captured `previous`, success toast naming the heap
  and the count of newly-added photos, onSettled invalidation of
  heaps + heap-photo-ids + photos so server truth re-syncs.

PhotoThumbnail's title attribute now mentions the drag affordance
alongside click/double-click/shift+click/ctrl+click hints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:59:29 +02:00
351ccd7bb4 refactor: unify Pick with active heap membership
Pick and add-to-heap were two ways of saying "I want to keep this
one". Merging them: P now toggles the selection's membership in the
active heap. The is_picked flag goes away (orphaned in the DB the
same way is_trashed was).

Backend
- Drop is_picked from PhotoBase / PhotoUpdate / PhotoResponse and
  from the photos list filter param.
- Drop the is_picked Column from the Photo model (DB column stays
  on legacy installs but is no longer read or written).
- Drop the bulk action 'pick' branch.
- New GET /heaps/{id}/photo_ids returns just the flat string list.
  Used by the frontend for fast client-side membership lookups
  without fetching full photo records.

Frontend
- New hooks/useActiveHeapMembersQuery.ts → returns
  { activeHeap, memberIds: Set<string> }. Subscribes once at the
  Timeline level and passes a derived isInActiveHeap bool down to
  each PhotoThumbnail (avoids hundreds of thumbnails subscribing
  to the same query).
- PhotoThumbnail: replaces the old check-icon Pick affordance with
  a clear basket badge in the bottom-right corner — a small filled
  pick-colour pill containing a ShoppingBasket icon — visible only
  when the photo belongs to the active heap.
- P shortcut (useKeyboardShortcuts) now toggles membership: if every
  selected photo is already a member, it removes them; otherwise it
  adds the missing ones. T binding removed (P fully replaces it).
- RightSidebar Pick button is now a Pick / Picked toggle bound to
  the active heap. Disabled with a hint when no heap is active.
  Shows the heap name in its title attr.
- filterStore drops 'picked' and 'unflagged' from FlagFilter.
  FilterBar's flag dropdown is now just Any / Discarded.
- LeftSidebar drops the "Flagged" virtual node (it just set
  flag=picked, which no longer exists).
- KeyboardHints: P → "Pick → heap".
- Photo TS type drops is_picked.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:20:31 +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
6e6672f225 fix: use square cells in timeline grid
PhotoThumbnail computed cell height as size * min(aspectRatio, 1.5),
so portrait photos overflowed their row. The TanStack Virtual row
estimate is a single fixed value (thumbnailSize + gap), so any cell
taller than that pushed into the row below — visible as overlapping
thumbnails whenever a portrait shared a row with landscapes.

Switching to square cells (Lightroom Library default) means every row
is exactly the estimated height. The image still fills via object-cover,
just cropped on the long axis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:15:19 +02:00
f4fc15101e feat: loupe view with zoom, pan, video, and filmstrip
Adds the Lightroom-style full-screen single-photo viewer (spec §6.11).
Open with E / Enter / double-click; navigate with arrow keys; Esc returns
to grid. Filmstrip at the bottom auto-scrolls the active cell into view.

Display:
- Stills source from /photos/{id}/proxy so RAW/HEIC are decoded server-
  side; large thumbnail is the onError fallback only.
- Videos render in a <video controls> sourced from /original.
- Continuous wheel zoom (1×–8×, ~15% per tick) with click-drag pan when
  zoomed past fit. Z toggles between fit and natural-resolution
  (computed from naturalWidth / clientWidth); a second Z snaps back.
- Live percentage indicator in the bottom-center while zoomed.

Polish:
- Neighbor preloading via new Image() when currentIndex changes so arrow
  nav feels instant (skips videos).
- Focus trap with role=dialog, aria-modal, focus-on-mount, restore-on-
  unmount, and Tab cycling among focusable children.

Plumbing:
- New canonical Photo TS interface in types/photo.ts; removes the three
  duplicated definitions in PhotoThumbnail/Timeline/photoStore.
- photoStore gains viewMode + openLoupe/closeLoupe.
- useKeyboardShortcuts wires E/Enter/G to open/close, gates rating and
  flag stubs with { enabled: viewMode === 'grid' } so they're inert in
  loupe.
- App.tsx mounts <LoupeView/> as a z-40 overlay covering TopBar, gates
  the auto-open right sidebar effect on viewMode === 'grid' so leaving
  loupe doesn't fight the user's prior sidebar state.
- PhotoThumbnail gains an onDoubleClick prop wired to openLoupe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:39:33 +02:00
72d301a9c7 feat: resilient thumbnail loading with backoff retries
Backend generates thumbnails on-demand via Celery, so the first request
often 404s while the worker runs. Auto-retry with 1.5s/3.5s/6s backoff,
manual retry fallback, and proper timer cleanup so fast-scrolling a
virtualized timeline doesn't setState on unmounted components.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:03 +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