15 Commits

Author SHA1 Message Date
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
522228fb79 refactor: drop LeftSidebar header in favor of group rows
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>
2026-04-08 12:48:45 +02:00
5e10b12b13 fix: arrow key navigation matches the visual grid
The Timeline arrow keys moved by currentIndex ± columns in the FLAT
photos array, but with date / tag grouping the rendered grid has
half-full last rows for each group, so flat-index nav routinely
landed in the wrong cell — and tag grouping (where one photo can
appear in multiple groups) made it incoherent.

Fix: navigate the actual visual grid the user sees.

- New photoRows = items.filter(type='row') in visual order. The
  buildItems pipeline already chunks photos into row items of
  [1..columns] cells per group; this is exactly the rendered layout.
- findActiveCell() walks photoRows looking for the activePhotoId
  and returns its (rowIndex, colIndex), or null if it isn't on
  screen. First-occurrence wins, which matches user intuition in
  the tag-grouped view.
- New move(dr, dc) helper:
    Left/Right: walk col, wrap across row boundaries (so going Right
    off the end of a half-full row jumps to the next group's first
    row). Clamps at the very first/last cell.
    Up/Down: change row, then clamp the column to the destination
    row's actual width — moving down into a 2-cell row from col 3
    lands on col 1, not nothing.
- The four arrow handlers all funnel through move(); shift-arrow
  still calls selectRange with the destination cell's globalIndex
  so range selection works the same as a shift-click on that cell.
- Headers are skipped automatically because they were never in
  photoRows. Edge cells, end-of-group, single-row groups, and
  tag-repeated photos all behave consistently.

Pulled activePhotoId out of usePhotoStore (was already in the store
but the Timeline component wasn't reading it). Effect deps updated
to invalidate the listener whenever the visible grid changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:25:16 +02:00
8fd8bfe3de feat: bulk rating / color / discard / pick from selection
Culling actions used to operate on a single photo (the activePhotoId)
even when many were selected — pressing 5 with ten thumbnails high-
lighted only rated one. Same for the RightSidebar buttons, which
weren't even visible in multi-select mode. Lightroom semantics: every
culling action applies to the whole selection.

Fix
- Three new bulk helpers in services/api.ts:
    photos.bulkSetRating(ids, rating)
    photos.bulkSetColor(ids, color | null)
    (existing photos.bulkDiscard / bulkRestore reused for X / U)
  All matching the backend BulkAction { ids, action, value } shape
  the /photos/bulk endpoint already accepts.

useKeyboardShortcuts
- New cullTargets() helper: selectedPhotos if non-empty, else
  activePhotoId in a singleton, else empty.
- updateActive() now branches on cullTargets().length:
    1 → existing PATCH /photos/{id} path (single-photo).
    2+ → fans out to the right bulk endpoint per field. rating goes
         to bulkSetRating, color_label to bulkSetColor, is_discarded
         to bulkDiscard / bulkRestore.
- 1-5 / 0 / X / U / 6-9 shortcuts now Just Work on multi-select
  without further changes — they all funnel through updateActive.

RightSidebar
- Restructured the Quick Actions block: filename / title / notes are
  hidden in multi-select (they only make sense for one photo); but
  rating / color / flag controls are now always visible when at
  least one photo is selected. A small "Rating, color, and flag
  apply to all N selected" hint shows in multi mode.
- New applyRating / setColor / applyDiscard helpers fan out to the
  bulk endpoints when selectedPhotos.length > 1, otherwise hit the
  per-photo PATCH path. The displayed value still reflects the
  active photo (last clicked) so the user has a visual anchor —
  matches Lightroom's "focused vs selected" model.
- Pick/Heap-toggle button is now selection-aware too: heapMutation
  takes ids[], the click handler reads selectedPhotos, and the
  add-vs-remove decision uses "every selected is a member" exactly
  like the P keyboard shortcut. Optimistic membership cache update
  also flips the basket badge across all selected thumbnails
  instantly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:19:53 +02:00
8413b112ee fix: folder tree counts computed from photos (not stale folder.photo_count)
The sidebar showed Juno=7 and sub=blank because the scanner's
folder.photo_count bookkeeping is broken end-to-end:

  for root, dirs, files in os.walk(folder_path):
      folder = await get_or_create_folder(...)
      ...
      processed_files += 1     # global counter

  # AFTER the loop:
  folder.last_scanned = datetime.utcnow()
  folder.photo_count = processed_files   # only the LAST folder

processed_files is the running total across the whole walk, not
per-folder; and the assignment runs once after the loop, only on
whichever folder os.walk happened to visit last. Result: that folder
gets the grand total, every other folder gets nothing (or stale).

Rather than fix the scanner's bookkeeping (which has leaked into
two production scans already), the tree endpoint now computes
counts on demand from the photos table:

- One GROUP BY per source root: photo.folder_id → COUNT, excluding
  discarded
- Each node starts with its DIRECT count
- A post-order walk accumulates descendants so every node reports
  recursive count — i.e. clicking the row gives you that number of
  photos because the photos query also expands descendants

The stored Folder.photo_count column is now unused by the API. A
future cleanup could drop it from the model entirely.

Verified on the dev DB: Library=7 (4 direct + Juno=2 + sub=1),
Juno=2, sub=1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:11:22 +02:00
b4a2241bd9 feat: per-section filter memory
Filters were global — switching from "Discarded" to a folder kept the
discarded flag, switching from a heap to All Photos kept the heap
filter, etc. Confusing because the user couldn't tell what state any
section would be in until they got there.

Now each "section" remembers its own filter state independently. The
in-memory map is keyed by section id ('all-photos', 'rated',
'discarded', 'duplicates', 'tags', 'folder-{id}', 'heap-{id}'), and
navigating saves the current section's state under its id and
restores the destination's. Sections you've never visited start with
their intrinsic preset on top of INITIAL_FILTERS.

filterStore additions
- currentSection: string (default 'all-photos')
- sectionFilters: Record<sectionId, FilterState> — in-memory snapshots
- sectionPresets: Record<sectionId, Partial<FilterState>> — the
  intrinsic filter that defines each section, used by clearAll
- navigateToSection(id, presetOverrides):
    1. snapshot the current FilterState slice into sectionFilters[
       currentSection]
    2. record presetOverrides in sectionPresets[id]
    3. set currentSection = id
    4. load sectionFilters[id] if a saved snapshot exists, otherwise
       apply presetOverrides on top of INITIAL_FILTERS
- clearAll: now resets the CURRENT section to its preset rather than
  jumping to all-photos. The user explicitly clicks All Photos to
  navigate.
- snapshotFilters() helper extracts the FilterState slice cleanly so
  control fields (filterBarOpen, the maps themselves) don't leak
  into per-section state.

URL sync
- writeUrl serialises currentSection as ?section=… (omitted for the
  default 'all-photos').
- parseUrl reads it back into currentSection on hydrate. Per-section
  memory is in-memory only; reload restores the current view but
  not the other sections' saved states (acceptable for MVP).

LeftSidebar
- applyLibraryNode now dispatches navigateToSection per node, with
  the appropriate preset:
    all-photos → {}
    rated      → { ratingMin: 1 }
    discarded  → { flag: 'discarded' }
    duplicates → { duplicates: true }
    tags       → { groupBy: 'tag' }
    folder-X   → { folderId: X }
- isItemActive collapses to a single check against currentSection
  for both library nodes and folder rows. Dropped the old
  selectedItem local state and the per-field active probes; they
  were doing the same job in a more fragile way.

HeapsPanel
- Heap row click → navigateToSection(`heap-${id}`, { heapId: id })
- isFiltered uses currentSection instead of filterStore.heapId
- Deleting the currently-viewed heap navigates back to all-photos
  via navigateToSection (was setFilterHeapId(null), which now lives
  in the section model).

User flow:
1. Click Discarded → seeing discarded photos.
2. Open FilterBar, set Rating ≥ 3 — discarded section now has rating.
3. Click Library "Library" folder → no rating filter, just library
   contents.
4. Open FilterBar, set media type Photo only — folder section now
   has that.
5. Click Discarded again → restored to discarded + rating ≥ 3.
6. Click Library folder again → restored to library + photo only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:07:16 +02:00
db37be902e fix: disable folder watcher on startup to unblock scan_folder
Diagnosis: every backend restart was dispatching watch_folders.delay()
unconditionally. watch_folders is an infinite-loop celery task
(for changes in watch(*paths)). With CELERYD_CONCURRENCY=4 and several
restarts during dev, all four worker slots ended up pinned by stale
watch_folders instances, leaving zero workers free for scan_folder.
The result: clicking "Scan all folders" successfully queued a task
that then sat in the queue forever, the new /photos/sub folder was
never walked, and the user's newly added photo never appeared.

The watcher was only opportunistically useful and the user already
triggers scans manually. Disabling it removes the foot-gun. Re-
enabling needs:
  - a Redis lock so only one watcher runs at a time
  - or a dedicated long-running container with concurrency=1
  - or a celery beat schedule with a singleton flag

Until then, manual scans work. Cleared the backlog by wiping the
redis broker volume so the stale watch_folders tasks are gone.

Verified: post-fix, scan_folder runs in 0.12s and reports
"Processed 7/7 files. Errors: 0", picking up the previously missing
/photos/sub/Samuel_Colman... file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:01:19 +02:00
6917e618e5 fix: parent folders clickable; refresh photos when scan completes
Two related sidebar UX bugs.

1. Parent folders weren't clickable
   renderTreeItem's onClick called toggleExpanded(item.id) for any
   row with children — so a parent folder only expanded/collapsed,
   never applied its filter. Restructured: folder rows always call
   applyLibraryNode (which the photos endpoint already expands to
   include descendants), and the chevron remains a separate
   stopPropagation button for expansion. Other group headers
   (Library, Folders, Tags) still toggle expansion on row click
   since they have no associated filter.

   Result: clicking any folder at any depth filters the timeline
   to that folder + every descendant, matching the Lightroom
   model the user expects.

2. New files not appearing after Scan all folders
   scanLibraryMutation.onSettled invalidated ['photos'] when the
   trigger returned, but POST /library/scan just queues the celery
   task and returns immediately. By the time the worker finishes
   walking the directory and inserting new rows, the photos query
   has already refetched (with no new data) and is sitting on a
   30-second staleTime — so newly-indexed photos stayed invisible
   until the next manual refetch.

   Fix: ScanProgress already polls /library/scan/status. Track the
   previous is_scanning value via a ref; when it transitions from
   true → false, invalidate ['photos'], ['folders'], ['folders',
   'tree'], ['heaps'], and ['tags']. That's the actual moment new
   data is available, regardless of how the scan was triggered
   (button, watcher, startup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:54:31 +02:00
6985026106 feat: tag-grouped timeline view
Reworks the Tags sidebar entry from an expandable list of tags into a
single leaf entry. Clicking it switches the timeline grouping mode to
"tag" — every tag becomes a sticky-headered group, with an "Untagged"
group at the bottom for photos with no tags. A photo with N tags
appears in N groups. Existing filters and sort still apply within each
group.

Backend
- list_photos eagerly loads Photo.tags via selectinload to avoid an
  N+1 round-trip.
- Each photo in the list response now carries a `tags: [{id, name,
  color}]` array. The route stops using PhotoListResponse strict
  validation (returns a plain dict with the same shape plus the new
  field) so we don't have to extend the pydantic schema.

Frontend
- Photo TS type gains an optional tags field plus a PhotoTagSummary
  alias.
- filterStore: new groupBy: 'date' | 'tag' field, default 'date',
  with setGroupBy + URL sync via ?group=tag. clearAll resets it.
- usePhotosQuery threads groupBy through filtersToParams (it's
  client-side only but kept in the params for cache key
  consistency).
- LeftSidebar Tags entry is now a leaf node (no children), shows the
  total tag photo count as the badge, and is highlighted when
  groupBy === 'tag'. Click → setGroupBy('tag') without touching
  other filters. Selecting "All Photos" resets groupBy back to
  'date' via clearAll.
- Timeline.buildItems gets a third "tag" branch:
  - Iterates photos × tags into per-tag buckets
  - Photos with no tags go into an "Untagged" bucket
  - Tag groups sorted alphabetically; Untagged pinned to the end
  - Headers + rows pushed in the same shape the date branch uses,
    so the existing sticky-header overlay works for free
- Selection state is by photo id, so a photo appearing in multiple
  groups stays consistently selected/highlighted across instances.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:51:21 +02:00
2d37fba211 feat: Tags entry in the Library sidebar
Adds an expandable Tags node alongside All Photos / Rated /
Duplicates / Discarded. The children are populated dynamically from
useTagsQuery — one row per tag, showing the tag name and its photo
count badge. Click a tag row to filter the timeline to just that
tag (single-tag), with the active highlight following the filter
store.

Multi-tag filtering still lives in the FilterBar; the sidebar entry
is the quick "show me everything in this tag" affordance.

Implementation
- New 'tags' library tree node with children: allTags.map(...)
- 'tag-{id}' click handler in applyLibraryNode → clearAll() +
  setTagIds([id])
- isItemActive recognises a tag row as selected only when the
  filter store has exactly that single tag id, so combining it with
  multi-tag filter mode in the FilterBar doesn't leave a stale
  highlight.
- Tags section is collapsed by default like other library nodes; no
  effect when there are no tags yet (children list is empty).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:39:33 +02:00
914eb58ac5 feat: real recursive folder tree in the sidebar
The Folders section in the LeftSidebar previously rendered the flat
list of source roots — actual subdirectories were invisible. Now it
shows the full nested tree, click any node to filter, drop targets
work at every depth.

Backend
- New GET /folders/tree returning a list of root nodes (one per
  active SourceRoot). Each node is { id, name, path, photo_count,
  children: [...] } with children sorted alphabetically at every
  level. Walks Folder rows whose source_root_id matches and whose
  path is at or beneath the source root, then attaches them by
  parent path so partial scans don't break the tree.
- The source root's display label is overlaid on the root folder
  node so the top-level entry reads as "Library" instead of
  "/photos".
- list_photos folder_id filter now does descendant matching: when
  a Folder id is given, it includes the folder itself and every
  Folder whose path is a sep-prefixed descendant. Matches the
  Lightroom mental model: clicking "Library" or any parent folder
  shows everything beneath it. The existing source-root-id branch
  is unchanged.

Frontend
- New types/api.ts FolderTreeNode interface and sourceFolders.tree()
  helper.
- New hooks/useFolderTreeQuery.ts with a 30s staleTime and a
  findFolderInTree() walker for id-based name lookups.
- LeftSidebar drops the flat foldersData list and uses the tree
  query. folderNodeToTreeItem recursively maps backend nodes into
  the existing TreeItem shape; renderTreeItem already knew how to
  recurse into children, so the tree just works at any depth.
  Drop targets, drag-to-move, drag-to-copy, double-click rename,
  and active-state highlighting all carry over to nested folders.
- The renameMutation now also invalidates ['folders', 'tree'] so a
  source-root rename refreshes the tree label immediately.
- ActiveFilterChips switches to the tree query and uses the new
  findFolderInTree walker so the chip label resolves correctly for
  sub-folder filters too — not just top-level source roots.
- The "Scan all folders" button visibility now keys off the tree
  length instead of the flat folders length.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:36:01 +02:00
bb7c2b12d6 feat: heap convert can create a subfolder under the target
The previous /heaps/{id}/convert dropped photos directly into a chosen
source root, which is rarely what you want — Lightroom-style behaviour
is "make a folder named after the collection inside the library".
Now the dialog lets you do that.

Backend
- HeapConvertBody gains an optional subfolder_name field. Path
  separators and dot-segments are rejected. When set, the handler
  joins it onto the resolved parent_dir, mkdir's it if missing, and
  uses the resulting path as the move/copy destination. Otherwise
  the parent_dir itself is used (unchanged behaviour).
- The Folder DB row for the destination is created via the existing
  scanner get_or_create_folder helper so dedupe + path normalization
  stay consistent across the codebase.
- The target source root id is propagated through both the source-
  root and folder branches so the new Folder row is correctly
  parented when subfolder_name is set on a folder target too.

Frontend
- HeapConvertDialog grows a "Subfolder name" input that prefills
  with the heap name when the dialog opens. Trimmed empty value
  drops directly into the parent. A live hint below the input
  shows exactly which path will be created (or that the parent
  will be used).
- api.ts heaps.convert() signature accepts an optional
  subfolder_name field; the dialog sends it via mutationFn.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:31:14 +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
bc1e63095c feat: tags end-to-end (CRUD, photo membership, filter, sidebar UI)
The Tag model and photo_tags join table were already in place; this
fills in the rest — full backend CRUD, per-photo add/remove, list-
endpoint filtering, and a Tags section in the RightSidebar with
autocomplete-create.

Backend
- routers/tags.py rewritten from a 27-line stub:
    GET    /tags                — list with photo counts
    POST   /tags                — create (idempotent on name)
    PATCH  /tags/{id}           — rename / recolor
    DELETE /tags/{id}           — delete (FK cascades photo_tags)
- routers/photos.py:
    POST   /photos/{id}/tags    — add tag ids (idempotent)
    DELETE /photos/{id}/tags/{tag_id} — remove
    GET    /photos/{id}         — now returns a `tags` list alongside
                                  the existing PhotoResponse fields
                                  (fetched via the photo_tags join)
- list_photos applies the existing tag_ids query param: comma-
  separated, AND semantics, one IN-subquery per id since SQLite
  has no native set-contains-all.

Frontend
- New hooks/useTagsQuery.ts.
- services/api.ts: Tag interface, full tags client (list/create/
  update/delete), addToPhoto/removeFromPhoto helpers.
- filterStore: tagIds: string[] field, setTagIds, toggleTagId,
  hasActiveFilters update, filtersToParams sends tag_ids comma list.
- useFilterUrlSync round-trips ?tag_ids=… so tag-filtered views
  are bookmarkable.
- usePhotosQuery threads tagIds through.
- RightSidebar gains a new Tags section using a TagsEditor
  component:
    - shows existing tag chips with X to remove
    - autocomplete input that matches the user's typing against
      existing tag names
    - shows an inline "+ Create '<name>'" affordance when there's
      no exact match
    - Enter creates and attaches in one shot; Esc clears the input
    - existing colour values render as a tinted chip background
- FilterBar gets a Tags group (only rendered when there's at
  least one tag) with toggleable chips per tag.
- ActiveFilterChips shows "Tag: <name>" chips for each active
  tag id, looking up names lazily from the tags query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:20:32 +02:00
63383ecf1c feat: watcher source-root resolution, folder rename, alt-drag copy
Three small phase-11 follow-ups in one commit since they all touch the
same surface area.

1. Watcher source-root resolution
   The watch_folders task previously called scan_folder.delay(parent_dir)
   when files arrived, with no source_root_id. scan_folder would then
   auto-create a fresh SourceRoot for that arbitrary subdir, polluting
   the source_root list. Now the watcher loads (path, id) pairs at
   startup, defines find_source_root_for() that walks the parent chain,
   and dispatches with the resolved id. Events under no known root are
   logged at debug and ignored instead of creating stale rows.

2. Folder rename via UI
   - Backend: PATCH /folders/{id} accepts { name } and updates the
     SourceRoot display label only. The on-disk path is controlled by
     the docker mount and intentionally not editable from the UI.
   - Frontend: double-click a folder row in the LeftSidebar to start
     editing; Enter or blur commits, Esc reverts. New renamingId /
     renameDraft local state and a renameMutation that invalidates
     ['folders']. The click handler ignores clicks while the row is
     in edit mode so it doesn't navigate.
   - api.ts: new sourceFolders.rename(id, name) helper.

3. Bulk copy via Alt-drag onto folder
   - Backend: new POST /photos/copy that mirrors /photos/move but uses
     shutil.copy2 and creates fresh Photo rows with is_duplicate=true.
     Name collisions are resolved by appending " (copy)", " (copy 2)",
     etc., up to 100 tries before erroring. Same target_id resolution
     as /move (folder id or source root id).
   - Frontend: photos.copy(ids, targetId) helper. LeftSidebar's
     handleDrop now takes a `copy` flag derived from e.altKey on the
     drop event; folder targets dispatch copyDropMutation when held,
     moveDropMutation otherwise. The drop-effect cursor flips to
     'copy' on dragover when Alt is pressed so the user gets visual
     confirmation. Discard target ignores the modifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:58:49 +02:00
27 changed files with 2389 additions and 598 deletions

View File

@@ -1,19 +1,24 @@
"""
Folders API router. Source roots are config-driven (PHOTO_DIRS in .env →
backend bootstrap on startup); this router only exposes read access and a
manual rescan trigger. Adding/removing source roots happens by editing
docker-compose.yml + .env and restarting the stack.
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
in .env → backend bootstrap on startup) — adding or removing one is a
docker-compose change. The UI can read the list, trigger a manual rescan,
and rename the display label, but it can't change the on-disk path.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
import os
from app.database import get_db
from app.models import Folder, SourceRoot
from app.models import Folder, SourceRoot, Photo
router = APIRouter()
class FolderRename(BaseModel):
name: str
@router.get("")
async def get_folders(db: AsyncSession = Depends(get_db)):
"""Get all source folders"""
@@ -39,6 +44,139 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
return {"folders": folders_list}
@router.get("/tree")
async def get_folder_tree(db: AsyncSession = Depends(get_db)):
"""Recursive folder tree, one root per active SourceRoot. The tree
starts at the Folder row matching the SourceRoot.path (the scanner
creates one for every walked directory), with the SourceRoot's
display name overlaid so the top-level entry reads as "Library"
instead of "/photos".
Returns a list of root nodes; each node has:
{ id, name, path, photo_count, children: [...] }
photo_count is **recursive** — every node reports the total non-
discarded photos in its own subtree, so the badge matches what the
user sees when they click the row (which also filters recursively).
The stored Folder.photo_count column is intentionally NOT trusted;
the scanner's bookkeeping for that field has historically been
wrong (it leaks the global total into whichever folder os.walk
visited last). We compute counts here from the photos table.
Sub-folders that physically belong to the same source root but
weren't created on disk (e.g. the / row the scanner sometimes
creates as a parent walk) are skipped via path-prefix filtering.
"""
sr_result = await db.execute(
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
)
source_roots = sr_result.scalars().all()
out = []
for sr in source_roots:
# Folders physically inside this source root, by path prefix.
prefix = os.path.normpath(sr.path).rstrip(os.sep)
f_result = await db.execute(
select(Folder).where(
Folder.source_root_id == sr.id,
# Either the folder IS the source root, or it sits beneath it.
(Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%'))
)
)
folders = f_result.scalars().all()
if not folders:
continue
# Direct (non-recursive) photo counts per folder, computed from
# the photos table. Excludes discarded.
folder_ids = [f.id for f in folders]
direct_counts: dict[str, int] = {}
if folder_ids:
count_result = await db.execute(
select(Photo.folder_id, func.count(Photo.id))
.where(
Photo.is_discarded == False, # noqa: E712
Photo.folder_id.in_(folder_ids),
)
.group_by(Photo.folder_id)
)
direct_counts = {row[0]: int(row[1]) for row in count_result.all()}
# Build a path → node map so we can attach children regardless of
# parent_id consistency. We populate photo_count with the direct
# count first, then accumulate descendants in a post-order pass.
nodes = {
f.path: {
"id": f.id,
"name": f.name or os.path.basename(f.path),
"path": f.path,
"photo_count": direct_counts.get(f.id, 0),
"children": [],
}
for f in folders
}
root_node = None
for f in folders:
node = nodes[f.path]
if f.path == prefix:
root_node = node
# Override the display name with the source root's label.
node["name"] = sr.name or node["name"]
continue
parent_path = os.path.normpath(os.path.dirname(f.path))
parent = nodes.get(parent_path)
if parent is not None:
parent["children"].append(node)
# If parent isn't in the set (orphan from a partial scan), drop
# the node — it can't be rendered consistently.
if root_node is not None:
# Sort children alphabetically at every level.
def sort_recursive(n):
n["children"].sort(key=lambda c: c["name"].lower())
for c in n["children"]:
sort_recursive(c)
sort_recursive(root_node)
# Post-order: each node's recursive count is its own direct
# count plus the sum of every descendant's recursive count.
def accumulate(n) -> int:
total = n["photo_count"]
for c in n["children"]:
total += accumulate(c)
n["photo_count"] = total
return total
accumulate(root_node)
out.append(root_node)
return out
@router.patch("/{folder_id}")
async def rename_folder(
folder_id: str,
body: FolderRename,
db: AsyncSession = Depends(get_db),
):
"""Rename a source root's display label. Does NOT touch the on-disk
path — that's controlled by the docker mount."""
name = (body.name or '').strip()
if not name:
raise HTTPException(status_code=400, detail="Name cannot be empty")
result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id))
source_root = result.scalar_one_or_none()
if not source_root:
raise HTTPException(status_code=404, detail="Source folder not found")
source_root.name = name
await db.commit()
return {"id": source_root.id, "name": source_root.name, "path": source_root.path}
@router.post("/{folder_id}/scan")
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
"""Trigger manual re-scan of source root folder"""

View File

@@ -1,16 +1,22 @@
"""
Heaps API router
"""
from typing import Optional
import os
import shutil
import logging
from typing import Optional, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, func, update, insert, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Heap
from app.models import Heap, Photo, Folder
from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -29,6 +35,16 @@ class HeapPhotosBody(BaseModel):
photo_ids: list[str]
class HeapConvertBody(BaseModel):
target_id: str # folder id OR source root id
mode: Literal['move', 'copy'] = 'move'
delete_heap: bool = False
# Optional subfolder name to create inside the target. If provided, the
# actual destination is target_dir/subfolder_name (created if missing).
# Path separators and dot-segments are rejected.
subfolder_name: Optional[str] = None
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
@@ -180,6 +196,170 @@ async def add_photos_to_heap(
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
@router.post("/{heap_id}/convert")
async def convert_heap_to_folder(
heap_id: str,
body: HeapConvertBody,
db: AsyncSession = Depends(get_db),
):
"""Convert a heap into a folder by moving (or copying) every member
photo into the target directory. Optionally deletes the heap row at
the end.
target_id may be a Folder id or a SourceRoot id (matches the
/photos/move convention so the same dropdown can populate it).
"""
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
heap = heap_result.scalar_one_or_none()
if not heap:
raise HTTPException(status_code=404, detail="Heap not found")
# Resolve target_id → (target_dir, target_folder)
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == body.target_id)
)
source_root = sr_check.scalar_one_or_none()
if source_root is not None:
parent_dir = source_root.path
parent_source_root_id = source_root.id
else:
folder_check = await db.execute(
select(Folder).where(Folder.id == body.target_id)
)
parent_folder = folder_check.scalar_one_or_none()
if parent_folder is None:
raise HTTPException(status_code=404, detail="Target folder not found")
parent_dir = parent_folder.path
parent_source_root_id = parent_folder.source_root_id
if not os.path.isdir(parent_dir):
raise HTTPException(
status_code=400,
detail=f"Target parent does not exist: {parent_dir}",
)
# Resolve target_dir, creating an optional subfolder if requested.
if body.subfolder_name is not None:
sub = body.subfolder_name.strip()
if not sub:
raise HTTPException(status_code=400, detail="Subfolder name cannot be empty")
if '/' in sub or '\\' in sub or sub in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid subfolder name")
target_dir = os.path.join(parent_dir, sub)
if not os.path.exists(target_dir):
try:
os.makedirs(target_dir)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to create subfolder: {e}",
)
elif not os.path.isdir(target_dir):
raise HTTPException(
status_code=400,
detail=f"{target_dir} exists but is not a directory",
)
else:
target_dir = parent_dir
# Ensure a Folder row for the target, reusing the scanner helper so
# path normalization + dedupe stay consistent.
from app.tasks.scan import get_or_create_folder
target_folder = await get_or_create_folder(db, target_dir, parent_source_root_id)
# Fetch the heap's photos via the join table.
photo_result = await db.execute(
select(Photo)
.join(heap_photos, Photo.id == heap_photos.c.photo_id)
.where(heap_photos.c.heap_id == heap_id)
)
photos = photo_result.scalars().all()
moved = 0
copied = 0
errors: list[dict] = []
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
if not os.path.exists(os.path.join(directory, filename)):
return filename
stem, ext = os.path.splitext(filename)
for i in range(1, 100):
suffix = '' if i == 1 else f' {i}'
candidate = f"{stem} (copy{suffix}){ext}"
if not os.path.exists(os.path.join(directory, candidate)):
return candidate
return None
for photo in photos:
if not os.path.exists(photo.filepath):
errors.append({"id": photo.id, "error": "source file missing"})
continue
if body.mode == 'move':
if photo.folder_id == target_folder.id:
continue # already there
new_path = os.path.join(target_dir, photo.filename)
if os.path.exists(new_path):
errors.append({"id": photo.id, "error": f"name collision: {photo.filename}"})
continue
try:
shutil.move(photo.filepath, new_path)
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue
photo.filepath = new_path
photo.folder_id = target_folder.id
moved += 1
else: # copy
new_name = _unique_target_name(target_dir, photo.filename)
if new_name is None:
errors.append({"id": photo.id, "error": "too many name collisions"})
continue
new_path = os.path.join(target_dir, new_name)
try:
shutil.copy2(photo.filepath, new_path)
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue
new_photo = Photo(
filepath=new_path,
filename=new_name,
folder_id=target_folder.id,
file_hash=photo.file_hash,
media_type=photo.media_type,
original_format=photo.original_format,
width=photo.width,
height=photo.height,
file_size=photo.file_size,
taken_at=photo.taken_at,
taken_at_source=photo.taken_at_source,
user_title=photo.user_title,
user_notes=photo.user_notes,
rating=photo.rating,
color_label=photo.color_label,
exif_json=photo.exif_json,
is_duplicate=True,
processing_status='pending',
)
db.add(new_photo)
copied += 1
if body.delete_heap:
await db.delete(heap)
await db.commit()
return {
"status": "success",
"mode": body.mode,
"moved": moved,
"copied": copied,
"errors": errors,
"heap_deleted": body.delete_heap,
}
@router.delete("/{heap_id}/photos")
async def remove_photos_from_heap(
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)

View File

@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import json
import os
import logging
@@ -16,15 +17,16 @@ import logging
logger = logging.getLogger(__name__)
from app.database import get_db
from app.models import Photo, Folder, Tag, PhotoTag
from app.models import Photo, Folder, Tag
from app.models.folders import SourceRoot
from app.models.heaps import heap_photos
from app.models.tags import photo_tags
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
from app.config import settings
router = APIRouter()
@router.get("", response_model=PhotoListResponse)
@router.get("")
async def list_photos(
q: Optional[str] = None,
date_from: Optional[datetime] = None,
@@ -36,6 +38,7 @@ async def list_photos(
rating_max: Optional[int] = Query(None, ge=0, le=5),
color_label: Optional[str] = None,
is_discarded: Optional[bool] = False,
is_duplicate: Optional[bool] = None,
heap_id: Optional[str] = None,
sort: str = "taken_at",
order: str = "desc",
@@ -45,8 +48,9 @@ async def list_photos(
):
"""List photos with filters and pagination"""
# Build query
query = select(Photo)
# Build query — eager-load tags so the response can include them
# without an N+1 round-trip per photo.
query = select(Photo).options(selectinload(Photo.tags))
# Apply filters
filters = []
@@ -69,15 +73,17 @@ async def list_photos(
if date_to:
filters.append(Photo.taken_at <= date_to)
# Folder filter — the sidebar exposes "source roots" (top-level scan
# paths) under the same UI affordance as folders, so the same param has
# to accept either a folder id or a source root id. If the value matches
# a source root, expand to every folder under that root and use IN.
# Folder filter. The sidebar can pass either a SourceRoot id or a
# Folder id; both should include descendants so clicking a parent
# folder shows everything under it (Lightroom semantics).
if folder_id:
sr_check = await db.execute(
select(SourceRoot.id).where(SourceRoot.id == folder_id)
select(SourceRoot).where(SourceRoot.id == folder_id)
)
if sr_check.scalar_one_or_none() is not None:
sr_row = sr_check.scalar_one_or_none()
if sr_row is not None:
# Source root → all folders under it (any depth).
child_folders = await db.execute(
select(Folder.id).where(Folder.source_root_id == folder_id)
)
@@ -85,11 +91,25 @@ async def list_photos(
if child_ids:
filters.append(Photo.folder_id.in_(child_ids))
else:
# Source root with no folder rows yet — match nothing rather
# than returning the entire library.
filters.append(Photo.id == '__no_match__')
else:
filters.append(Photo.folder_id == folder_id)
# Folder id → that folder + every descendant by path prefix.
target_check = await db.execute(
select(Folder).where(Folder.id == folder_id)
)
target = target_check.scalar_one_or_none()
if target is None:
filters.append(Photo.id == '__no_match__')
else:
target_path = os.path.normpath(target.path).rstrip(os.sep)
desc_result = await db.execute(
select(Folder.id).where(
(Folder.path == target_path)
| (Folder.path.like(target_path + os.sep + '%'))
)
)
desc_ids = [row[0] for row in desc_result.all()]
filters.append(Photo.folder_id.in_(desc_ids))
# Media type filter
if media_type:
@@ -112,6 +132,11 @@ async def list_photos(
# Discard filter — defaults to hiding discarded photos
filters.append(Photo.is_discarded == is_discarded)
# Duplicate filter — only applied when explicitly set, so the default
# view shows everything regardless of duplicate status.
if is_duplicate is not None:
filters.append(Photo.is_duplicate == is_duplicate)
# Heap membership filter — restrict to photos that belong to the heap.
if heap_id:
filters.append(
@@ -120,6 +145,19 @@ async def list_photos(
)
)
# Tag filter — comma-separated tag ids, AND semantics. A photo must
# have a row in photo_tags for EVERY listed tag. Implemented as one
# subquery per tag id since SQLite doesn't have an efficient
# "set-contains-all" operator.
if tag_ids:
tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()]
for tid in tag_id_list:
filters.append(
Photo.id.in_(
select(photo_tags.c.photo_id).where(photo_tags.c.tag_id == tid)
)
)
# Apply all filters
if filters:
query = query.where(and_(*filters))
@@ -144,21 +182,31 @@ async def list_photos(
result = await db.execute(query)
photos = result.scalars().all()
# Convert to response
return PhotoListResponse(
photos=[PhotoResponse.from_orm(photo) for photo in photos],
total=total,
page=page,
per_page=per_page,
pages=(total + per_page - 1) // per_page
)
# Convert to response, attaching tags inline so the frontend can group
# client-side without a second round-trip.
photo_dicts = []
for photo in photos:
d = PhotoResponse.from_orm(photo).dict()
d["tags"] = [
{"id": t.id, "name": t.name, "color": t.color}
for t in (photo.tags or [])
]
photo_dicts.append(d)
@router.get("/{photo_id}", response_model=PhotoResponse)
return {
"photos": photo_dicts,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total else 0,
}
@router.get("/{photo_id}")
async def get_photo(
photo_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get single photo with full EXIF and tags"""
"""Get single photo with full EXIF and its tags."""
result = await db.execute(
select(Photo).where(Photo.id == photo_id)
)
@@ -167,7 +215,75 @@ async def get_photo(
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
return PhotoResponse.from_orm(photo)
# Fetch tags via the join table so we don't need to declare a
# relationship on the Photo model side.
tag_result = await db.execute(
select(Tag)
.join(photo_tags, Tag.id == photo_tags.c.tag_id)
.where(photo_tags.c.photo_id == photo_id)
.order_by(Tag.name.asc())
)
tags = tag_result.scalars().all()
base = PhotoResponse.from_orm(photo).dict()
base["tags"] = [
{"id": t.id, "name": t.name, "color": t.color} for t in tags
]
return base
@router.post("/{photo_id}/tags", status_code=201)
async def add_photo_tags(
photo_id: str,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
Idempotent: re-adding existing members is a no-op."""
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id))
if photo_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Photo not found")
tag_ids = body.get("tag_ids") or []
if not isinstance(tag_ids, list) or not tag_ids:
return {"status": "success", "added": 0}
existing = await db.execute(
select(photo_tags.c.tag_id).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.tag_id.in_(tag_ids),
)
)
existing_ids = {row[0] for row in existing.all()}
new_ids = [tid for tid in tag_ids if tid not in existing_ids]
if new_ids:
from sqlalchemy import insert
await db.execute(
insert(photo_tags),
[{"photo_id": photo_id, "tag_id": tid} for tid in new_ids],
)
await db.commit()
return {"status": "success", "added": len(new_ids)}
@router.delete("/{photo_id}/tags/{tag_id}", status_code=204)
async def remove_photo_tag(
photo_id: str,
tag_id: str,
db: AsyncSession = Depends(get_db),
):
"""Remove a tag from a photo. Removing a non-member is a no-op."""
from sqlalchemy import delete as sql_delete
await db.execute(
sql_delete(photo_tags).where(
photo_tags.c.photo_id == photo_id,
photo_tags.c.tag_id == tag_id,
)
)
await db.commit()
return None
@router.get("/{photo_id}/thumb/{size}")
async def get_thumbnail(
@@ -491,6 +607,129 @@ class MoveRequest(BaseModel):
target_id: str # folder id OR source root id
class CopyRequest(BaseModel):
photo_ids: list[str]
target_id: str # folder id OR source root id
@router.post("/copy")
async def copy_photos(
body: CopyRequest,
db: AsyncSession = Depends(get_db),
):
"""Copy photos into a target folder. Same target resolution as /move
(folder id or source root id), but uses shutil.copy2 and creates new
Photo rows for each copied file. Original photos are unaffected.
Each new row gets is_duplicate=true so the user can spot the
duplicates later. The new file's name is suffixed with " (copy)" if
a name collision would otherwise happen, and " (copy 2)", etc., for
further conflicts.
"""
import shutil
# Resolve target_id → (target_dir, target_folder)
sr_check = await db.execute(
select(SourceRoot).where(SourceRoot.id == body.target_id)
)
source_root = sr_check.scalar_one_or_none()
if source_root is not None:
target_dir = source_root.path
from app.tasks.scan import get_or_create_folder
target_folder = await get_or_create_folder(db, target_dir, source_root.id)
else:
folder_check = await db.execute(
select(Folder).where(Folder.id == body.target_id)
)
target_folder = folder_check.scalar_one_or_none()
if target_folder is None:
raise HTTPException(status_code=404, detail="Target folder not found")
target_dir = target_folder.path
if not os.path.isdir(target_dir):
raise HTTPException(
status_code=400,
detail=f"Target directory does not exist: {target_dir}",
)
if not body.photo_ids:
return {"status": "success", "copied": 0, "errors": []}
photos_result = await db.execute(
select(Photo).where(Photo.id.in_(body.photo_ids))
)
photos_to_copy = photos_result.scalars().all()
copied = 0
errors: list[dict] = []
def _unique_target_name(directory: str, filename: str) -> Optional[str]:
"""Find a non-colliding filename in `directory` based on `filename`,
suffixing " (copy)", " (copy 2)", ... if needed. Gives up after 100
attempts."""
if not os.path.exists(os.path.join(directory, filename)):
return filename
stem, ext = os.path.splitext(filename)
for i in range(1, 100):
candidate = f"{stem} (copy{'' if i == 1 else f' {i}'}){ext}"
if not os.path.exists(os.path.join(directory, candidate)):
return candidate
return None
for photo in photos_to_copy:
if not os.path.exists(photo.filepath):
errors.append({"id": photo.id, "error": "source file missing"})
continue
new_name = _unique_target_name(target_dir, photo.filename)
if new_name is None:
errors.append({"id": photo.id, "error": "too many name collisions"})
continue
new_path = os.path.join(target_dir, new_name)
try:
shutil.copy2(photo.filepath, new_path)
except OSError as e:
errors.append({"id": photo.id, "error": str(e)})
continue
# Create a new Photo row pointing at the copy. Most metadata is
# copied verbatim; the file_hash stays so the duplicate flag does
# the right thing across the library.
new_photo = Photo(
filepath=new_path,
filename=new_name,
folder_id=target_folder.id,
file_hash=photo.file_hash,
media_type=photo.media_type,
original_format=photo.original_format,
width=photo.width,
height=photo.height,
file_size=photo.file_size,
taken_at=photo.taken_at,
taken_at_source=photo.taken_at_source,
user_title=photo.user_title,
user_notes=photo.user_notes,
rating=photo.rating,
color_label=photo.color_label,
exif_json=photo.exif_json,
is_duplicate=True,
processing_status='pending',
)
db.add(new_photo)
copied += 1
await db.commit()
return {
"status": "success",
"copied": copied,
"errors": errors,
}
@router.post("/move")
async def move_photos(
body: MoveRequest,

View File

@@ -1,27 +1,114 @@
"""
Tags API router
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from pydantic import BaseModel
from sqlalchemy import select, func, insert, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Tag
from app.models.tags import photo_tags
router = APIRouter()
# ── Schemas ───────────────────────────────────────────────────────────────
class TagCreate(BaseModel):
name: str
color: Optional[str] = None
class TagUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
# ── Endpoints ─────────────────────────────────────────────────────────────
@router.get("")
async def list_tags(db: AsyncSession = Depends(get_db)):
"""List all tags with usage counts"""
result = await db.execute(select(Tag))
tags = result.scalars().all()
return tags
"""List all tags with their photo counts."""
count_subq = (
select(
photo_tags.c.tag_id,
func.count(photo_tags.c.photo_id).label("photo_count"),
)
.group_by(photo_tags.c.tag_id)
.subquery()
)
stmt = (
select(Tag, count_subq.c.photo_count)
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
.order_by(Tag.name.asc())
)
result = await db.execute(stmt)
rows = result.all()
@router.post("")
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
"""Create a new tag"""
tag = Tag(name=name, color=color)
return [
{
"id": tag.id,
"name": tag.name,
"color": tag.color,
"photo_count": int(count or 0),
}
for tag, count in rows
]
@router.post("", status_code=201)
async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
"""Create a new tag. Names are unique — re-creating an existing name
returns the existing row instead of erroring (idempotent for the
autocomplete UI flow)."""
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="Tag name is required")
existing = await db.execute(select(Tag).where(Tag.name == name))
found = existing.scalar_one_or_none()
if found:
return {"id": found.id, "name": found.name, "color": found.color, "photo_count": 0}
tag = Tag(name=name, color=body.color)
db.add(tag)
await db.commit()
await db.refresh(tag)
return tag
return {"id": tag.id, "name": tag.name, "color": tag.color, "photo_count": 0}
@router.patch("/{tag_id}")
async def update_tag(
tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db)
):
"""Rename or recolor a tag."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Tag name is required")
tag.name = name
if body.color is not None:
tag.color = body.color or None
await db.commit()
await db.refresh(tag)
return {"id": tag.id, "name": tag.name, "color": tag.color}
@router.delete("/{tag_id}", status_code=204)
async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a tag. Photo associations cascade-delete via the FK."""
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
await db.delete(tag)
await db.commit()
return None

View File

@@ -8,7 +8,7 @@ from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import SourceRoot
from app.tasks.scan import scan_all_source_roots, watch_folders
from app.tasks.scan import scan_all_source_roots
from app.config import settings
logger = logging.getLogger(__name__)
@@ -49,15 +49,17 @@ async def bootstrap_default_source_root() -> None:
async def start_initial_scan():
"""Start the initial library scan"""
"""Start the initial library scan.
NOTE: the folder watcher (watch_folders task) is intentionally NOT
dispatched here. It's an infinite loop celery task and every backend
restart was queuing a new instance, eventually pinning every worker
and starving scan_folder dispatches. Re-enabling it needs a Redis
lock or a dedicated long-running container — until then the user
triggers scans manually via "Scan all folders".
"""
try:
# Queue scan of all source roots
scan_all_source_roots.delay()
# Start folder watcher if configured
if settings.scanner.watch:
watch_folders.delay()
logger.info("Initial scan queued successfully")
except Exception as e:
logger.error(f"Failed to start initial scan: {e}")

View File

@@ -361,30 +361,40 @@ def watch_folders():
from watchfiles import watch
# Read source roots from the DB instead of the (now-removed) YAML
# config. Synchronous lookup is fine here — this runs once at task
# start, not on every event.
paths: list[str] = []
# config. We need both the path and the id so we can dispatch
# scan_folder with the source_root_id when an event fires.
roots: list[tuple[str, str]] = []
try:
async def _load_paths():
async def _load_roots():
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
)
return [
sr.path for sr in result.scalars().all()
(os.path.normpath(sr.path), sr.id)
for sr in result.scalars().all()
if os.path.exists(sr.path)
]
paths = asyncio.run(_load_paths())
roots = asyncio.run(_load_roots())
except Exception as e:
logger.error(f"watch_folders could not load source roots: {e}")
return
if not paths:
if not roots:
logger.warning("No valid source roots to watch")
return
paths = [p for p, _ in roots]
logger.info(f"Starting folder watcher for: {paths}")
def find_source_root_for(path: str) -> Optional[str]:
"""Return the source_root id whose path contains `path`, or None."""
normalized = os.path.normpath(path)
for root_path, root_id in roots:
if normalized == root_path or normalized.startswith(root_path + os.sep):
return root_id
return None
for changes in watch(*paths):
for change_type, filepath in changes:
filepath = str(filepath)
@@ -394,9 +404,18 @@ def watch_folders():
continue
if change_type == 'added' or change_type == 'modified':
# Queue scan for the parent folder
# Queue scan for the parent folder, with the source_root_id
# resolved by ancestor lookup so scan_folder doesn't
# auto-create a new SourceRoot for an arbitrary subdir.
parent_dir = str(Path(filepath).parent)
scan_folder.delay(parent_dir)
source_root_id = find_source_root_for(parent_dir)
if source_root_id is None:
logger.debug(
f"watcher event for {filepath}: parent {parent_dir} "
f"not under any active source root, ignoring"
)
continue
scan_folder.delay(parent_dir, source_root_id)
logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}")
elif change_type == 'deleted':
# Handle file deletion

View File

@@ -8,7 +8,6 @@ import { ToastContainer } from './components/ToastContainer'
import { KeyboardHints } from './components/KeyboardHints'
import { PreviewView } from './components/preview/PreviewView'
import { FilterBar } from './components/filter/FilterBar'
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
import { DiscardActionBar } from './components/discard/DiscardActionBar'
import { usePhotoStore } from './store/photoStore'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
@@ -52,7 +51,6 @@ function App() {
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar />
<FilterBar />
<ActiveFilterChips />
<DiscardActionBar />
<KeyboardHints />

View File

@@ -21,7 +21,6 @@ export function KeyboardHints() {
{ key: 'Click', action: 'Select' },
{ key: 'Shift+Click', action: 'Range' },
{ key: 'Space', action: 'Preview' },
{ key: '\\', action: 'Filters' },
{ key: '/', action: 'Search' },
]

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { library } from '../services/api'
import clsx from 'clsx'
@@ -15,6 +15,8 @@ interface ScanStatus {
export function ScanProgress() {
const [isVisible, setIsVisible] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const queryClient = useQueryClient()
const wasScanningRef = useRef(false)
// Poll scan status every 2 seconds when scanning
const { data: scanStatus } = useQuery<ScanStatus>({
@@ -31,18 +33,34 @@ export function ScanProgress() {
})
useEffect(() => {
if (scanStatus?.is_scanning) {
const isScanning = scanStatus?.is_scanning ?? false
if (isScanning) {
setIsVisible(true)
setIsMinimized(false)
} else if (isVisible && !scanStatus?.is_scanning && (scanStatus?.processed_files ?? 0) > 0) {
// Keep showing for 3 seconds after scan completes
setTimeout(() => {
if (!scanStatus?.is_scanning) {
setIsVisible(false)
}
}, 3000)
wasScanningRef.current = true
} else if (wasScanningRef.current) {
// Just transitioned from scanning → done. THIS is the right moment
// to invalidate caches that might have new data: the photos query
// (new files indexed), the folder tree (new folders walked), the
// heap counts (in case a heap photo got reattached).
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['heaps'] })
queryClient.invalidateQueries({ queryKey: ['tags'] })
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
// Keep showing for 3 seconds after scan completes
setTimeout(() => {
if (!scanStatus?.is_scanning) {
setIsVisible(false)
}
}, 3000)
}
}
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible])
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible, queryClient])
if (!isVisible || !scanStatus) return null

View File

@@ -1,115 +0,0 @@
import { X } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { sourceFolders, heaps as heapsApi } from '../../services/api'
export function ActiveFilterChips() {
const f = useFilterStore()
// Look up names for id-based filters so the chips show something
// human-readable instead of opaque uuids.
const { data: foldersData } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
enabled: f.folderId !== null,
})
const folder = f.folderId
? (foldersData?.folders ?? []).find((x: any) => x.id === f.folderId)
: null
const { data: heaps = [] } = useQuery({
queryKey: ['heaps'],
queryFn: heapsApi.list,
enabled: f.heapId !== null,
})
const heap = f.heapId ? heaps.find((h) => h.id === f.heapId) : null
if (!hasActiveFilters(f)) return null
const chips: { key: string; label: string; onRemove: () => void }[] = []
if (f.q.trim()) {
chips.push({
key: 'q',
label: `Search: "${f.q.trim()}"`,
onRemove: () => f.setQ(''),
})
}
if (f.dateFrom) {
chips.push({
key: 'dateFrom',
label: `From: ${f.dateFrom}`,
onRemove: () => f.setDateFrom(null),
})
}
if (f.dateTo) {
chips.push({
key: 'dateTo',
label: `To: ${f.dateTo}`,
onRemove: () => f.setDateTo(null),
})
}
for (const t of f.mediaTypes) {
chips.push({
key: `mt-${t}`,
label: t.toUpperCase(),
onRemove: () => f.toggleMediaType(t),
})
}
if (f.ratingMin > 0) {
chips.push({
key: 'rating',
label: `Rating ≥ ${f.ratingMin}`,
onRemove: () => f.setRatingMin(0),
})
}
if (f.colorLabel) {
chips.push({
key: 'color',
label: f.colorLabel,
onRemove: () => f.setColorLabel(null),
})
}
if (f.flag !== 'any') {
chips.push({
key: 'flag',
label: f.flag,
onRemove: () => f.setFlag('any'),
})
}
if (f.folderId) {
chips.push({
key: 'folder',
label: `Folder: ${folder?.name || folder?.path?.split('/').pop() || f.folderId}`,
onRemove: () => f.setFolderId(null),
})
}
if (f.heapId) {
chips.push({
key: 'heap',
label: `Heap: ${heap?.name ?? f.heapId}`,
onRemove: () => f.setHeapId(null),
})
}
return (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">
<span className="text-text-muted">Active filters:</span>
{chips.map((chip) => (
<span
key={chip.key}
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-primary"
>
{chip.label}
<button
onClick={chip.onRemove}
className="rounded p-0.5 hover:bg-primary/30"
title="Remove"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)
}

View File

@@ -2,11 +2,13 @@ import { Star, X, ArrowDown, ArrowUp } from 'lucide-react'
import clsx from 'clsx'
import {
useFilterStore,
hasActiveFilters,
type MediaType,
type ColorLabel,
type FlagFilter,
type SortField,
} from '../../store/filterStore'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { FilterPill } from './FilterPill'
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'photo', label: 'Photo' },
@@ -15,7 +17,7 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'heic', label: 'HEIC' },
]
const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
{ value: 'red', className: 'bg-red-500' },
{ value: 'orange', className: 'bg-orange-500' },
{ value: 'yellow', className: 'bg-yellow-400' },
@@ -24,11 +26,6 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
{ value: 'purple', className: 'bg-purple-500' },
]
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
{ value: 'any', label: 'Any' },
{ value: 'discarded', label: 'Discarded' },
]
const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: 'taken_at', label: 'Date taken' },
{ value: 'added_at', label: 'Date added' },
@@ -37,8 +34,14 @@ const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: 'rating', label: 'Rating' },
]
/**
* Compact, always-visible filter toolbar built out of FilterPill primitives.
* Each pill represents a filter category, opens a popover with the
* underlying control, and shows a short value summary inline when active.
* Replaces the old expandable FilterBar + ActiveFilterChips combo.
*/
export function FilterBar() {
const filterBarOpen = useFilterStore((s) => s.filterBarOpen)
const filterState = useFilterStore()
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const mediaTypes = useFilterStore((s) => s.mediaTypes)
@@ -47,6 +50,7 @@ export function FilterBar() {
const flag = useFilterStore((s) => s.flag)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const tagIds = useFilterStore((s) => s.tagIds)
const setDateFrom = useFilterStore((s) => s.setDateFrom)
const setDateTo = useFilterStore((s) => s.setDateTo)
@@ -54,169 +58,283 @@ export function FilterBar() {
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setColorLabel = useFilterStore((s) => s.setColorLabel)
const setFlag = useFilterStore((s) => s.setFlag)
const setTagIds = useFilterStore((s) => s.setTagIds)
const toggleTagId = useFilterStore((s) => s.toggleTagId)
const setSortBy = useFilterStore((s) => s.setSortBy)
const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder)
const clearAll = useFilterStore((s) => s.clearAll)
if (!filterBarOpen) return null
const { data: allTags = [] } = useTagsQuery()
// Pre-compute pill values + active flags so the JSX stays terse.
const dateActive = dateFrom !== null || dateTo !== null
const dateValue = dateActive
? `${dateFrom ?? '…'}${dateTo ?? '…'}`
: null
const typeActive = mediaTypes.length > 0
const typeValue = typeActive
? mediaTypes.map((t) => t.toUpperCase()).join(', ')
: null
const ratingActive = ratingMin > 0
const ratingValue = ratingActive ? `${ratingMin}` : null
const colorActive = colorLabel !== null
const colorValue = colorActive ? colorLabel : null
const flagActive = flag !== 'any'
const flagValue = flagActive ? flag : null
const tagActive = tagIds.length > 0
const activeTagNames = allTags
.filter((t) => tagIds.includes(t.id))
.map((t) => t.name)
const tagValue = tagActive
? activeTagNames.length <= 2
? activeTagNames.join(', ')
: `${activeTagNames.slice(0, 2).join(', ')} +${activeTagNames.length - 2}`
: null
const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? sortBy
const sortValue = `${sortLabel} ${sortOrder === 'desc' ? '↓' : '↑'}`
const anyActive = hasActiveFilters(filterState)
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 border-b border-border bg-surface px-4 py-3 text-xs">
{/* Date range */}
<Group label="Date">
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
<span className="text-text-muted"></span>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</Group>
{/* Media type chips */}
<Group label="Type">
{MEDIA_TYPES.map(({ value, label }) => {
const active = mediaTypes.includes(value)
return (
<button
key={value}
onClick={() => toggleMediaType(value)}
className={clsx(
'rounded px-2 py-1 transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{label}
</button>
)
})}
</Group>
{/* Min rating */}
<Group label="Rating ≥">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
className="p-0.5"
title={`At least ${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={clsx(
'h-4 w-4 transition-colors',
n <= ratingMin
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</Group>
{/* Color label dots */}
<Group label="Color">
{COLOR_LABELS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => setColorLabel(active ? null : value)}
className={clsx(
'h-4 w-4 rounded-full ring-offset-1 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
onClick={() => setColorLabel(null)}
className="ml-1 text-text-muted hover:text-text"
title="Clear color"
>
<X className="h-3 w-3" />
</button>
)}
</Group>
{/* Flag */}
<Group label="Flag">
{FLAG_OPTIONS.map(({ value, label }) => {
const active = flag === value
return (
<button
key={value}
onClick={() => setFlag(value)}
className={clsx(
'rounded px-2 py-1 transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{label}
</button>
)
})}
</Group>
{/* Sort */}
<Group label="Sort">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={toggleSortOrder}
className="rounded bg-surface-2 p-1 text-text-muted hover:bg-surface-offset hover:text-text"
title={sortOrder === 'desc' ? 'Descending (click for ascending)' : 'Ascending (click for descending)'}
>
{sortOrder === 'desc' ? (
<ArrowDown className="h-3.5 w-3.5" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</button>
</Group>
<button
onClick={clearAll}
className="ml-auto rounded border border-border px-2 py-1 text-text-muted hover:bg-surface-2 hover:text-text"
<div className="flex items-center gap-1.5 overflow-x-auto border-b border-border bg-surface px-3 py-1.5">
{/* Date */}
<FilterPill
label="Date"
value={dateValue}
isActive={dateActive}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
>
Clear all
</button>
</div>
)
}
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] text-text-muted">From</label>
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
<div>
<label className="mb-1 block text-[11px] text-text-muted">To</label>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</div>
</div>
</FilterPill>
function Group({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="flex items-center gap-1.5">
<span className="text-text-muted">{label}:</span>
{children}
{/* Type */}
<FilterPill
label="Type"
value={typeValue}
isActive={typeActive}
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
>
<div className="flex flex-wrap gap-1">
{MEDIA_TYPES.map(({ value, label }) => {
const active = mediaTypes.includes(value)
return (
<button
key={value}
onClick={() => toggleMediaType(value)}
className={clsx(
'rounded px-2 py-1 text-xs transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{label}
</button>
)
})}
</div>
</FilterPill>
{/* Rating */}
<FilterPill
label="Rating"
value={ratingValue}
isActive={ratingActive}
onClear={() => setRatingMin(0)}
>
<div>
<p className="mb-1 text-[11px] text-text-muted">Minimum</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
className="p-0.5"
title={`At least ${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={clsx(
'h-5 w-5 transition-colors',
n <= ratingMin
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
</FilterPill>
{/* Color */}
<FilterPill
label="Color"
value={colorValue}
isActive={colorActive}
onClear={() => setColorLabel(null)}
>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => setColorLabel(active ? null : value)}
className={clsx(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
title={value}
/>
)
})}
{colorLabel && (
<button
onClick={() => setColorLabel(null)}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</FilterPill>
{/* Flag — discarded toggle */}
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
>
<div className="flex flex-col gap-1">
<button
onClick={() => setFlag('any')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'any'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Any
</button>
<button
onClick={() => setFlag('discarded')}
className={clsx(
'rounded px-2 py-1 text-left text-xs transition-colors',
flag === 'discarded'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Discarded
</button>
</div>
</FilterPill>
{/* Tags */}
{allTags.length > 0 && (
<FilterPill
label="Tags"
value={tagValue}
isActive={tagActive}
onClear={() => setTagIds([])}
>
<div className="flex max-h-60 flex-wrap gap-1 overflow-y-auto">
{allTags.map((tag) => {
const active = tagIds.includes(tag.id)
return (
<button
key={tag.id}
onClick={() => toggleTagId(tag.id)}
className={clsx(
'rounded px-2 py-1 text-xs transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
{tag.name}
</button>
)
})}
</div>
</FilterPill>
)}
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
<div className="space-y-2">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortField)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={toggleSortOrder}
className="flex w-full items-center justify-center gap-1 rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset hover:text-text"
>
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-3.5 w-3.5" />
Descending
</>
) : (
<>
<ArrowUp className="h-3.5 w-3.5" />
Ascending
</>
)}
</button>
</div>
</FilterPill>
{anyActive && (
<button
onClick={clearAll}
className="ml-auto whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear all filters in this section"
>
Clear all
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, X } from 'lucide-react'
import clsx from 'clsx'
interface FilterPillProps {
/** Category label, always shown ("Date", "Type", etc.). */
label: string
/** When the filter is active, a short summary of its current value
* ("≥ 3★", "RAW + Photo", "Mar 2024 → Apr 2026"). Renders inside the
* pill so the user sees the state without opening the popover. */
value?: string | null
isActive?: boolean
/** When provided + isActive, an X appears inside the pill that clears
* this filter without opening the popover. */
onClear?: () => void
/** Popover contents — usually the existing control for this filter. */
children: React.ReactNode
/** Force the popover open programmatically (rare). */
defaultOpen?: boolean
/** Right-align the popover instead of left (for pills near the right
* edge so they don't overflow the viewport). */
alignRight?: boolean
}
/**
* A toolbar pill that hosts a filter category. Click the pill to open a
* small popover with the actual control; the popover closes on outside
* click or Escape. Active filters tint the pill primary and show their
* current value inline.
*/
export function FilterPill({
label,
value,
isActive = false,
onClear,
children,
defaultOpen = false,
alignRight = false,
}: FilterPillProps) {
const [open, setOpen] = useState(defaultOpen)
const wrapperRef = useRef<HTMLDivElement>(null)
// Close on outside click + Escape.
useEffect(() => {
if (!open) return
const onDocMouseDown = (e: MouseEvent) => {
if (!wrapperRef.current) return
if (!wrapperRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocMouseDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocMouseDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
return (
<div ref={wrapperRef} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={clsx(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
isActive
? 'border-primary/40 bg-primary/15 text-primary'
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && value && (
<span className="font-mono text-[11px] opacity-90">{value}</span>
)}
{isActive && onClear ? (
<button
onClick={(e) => {
e.stopPropagation()
onClear()
}}
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/30"
title={`Clear ${label}`}
aria-label={`Clear ${label}`}
>
<X className="h-3 w-3" />
</button>
) : (
<ChevronDown className="h-3 w-3 opacity-60" />
)}
</button>
{open && (
<div
className={clsx(
'absolute top-full z-30 mt-1 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl',
alignRight ? 'right-0' : 'left-0'
)}
>
{children}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,221 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { X, Folder, AlertCircle } from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, heaps as heapsApi, type Heap } from '../../services/api'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { toast } from '../ToastContainer'
interface HeapConvertDialogProps {
heap: Heap | null
onClose: () => void
}
/**
* Modal that converts a heap into a folder. The user picks a target folder
* (any source root, today — sub-folder picking is a follow-up), chooses
* move vs copy semantics, and optionally has the heap deleted on success.
*/
export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
const queryClient = useQueryClient()
const [targetId, setTargetId] = useState('')
const [mode, setMode] = useState<'move' | 'copy'>('move')
const [deleteHeap, setDeleteHeap] = useState(false)
const [subfolderName, setSubfolderName] = useState('')
const { data: foldersData } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
enabled: !!heap,
})
const folders = foldersData?.folders ?? []
// Default to the first folder when the dialog opens or folders load.
useEffect(() => {
if (!targetId && folders.length > 0) {
setTargetId(folders[0].id)
}
}, [folders, targetId])
// Reset state on close, prefill subfolder name when opened.
useEffect(() => {
if (heap) {
setSubfolderName(heap.name)
} else {
setTargetId('')
setMode('move')
setDeleteHeap(false)
setSubfolderName('')
}
}, [heap])
const convertMutation = useMutation({
mutationFn: () =>
heapsApi.convert(heap!.id, {
target_id: targetId,
mode,
delete_heap: deleteHeap,
// Empty subfolder = drop directly into the parent. Trim and only
// send if the user kept it populated.
subfolder_name: subfolderName.trim() || null,
}),
onSuccess: (data) => {
const total = (data.moved ?? 0) + (data.copied ?? 0)
const verb = data.mode === 'move' ? 'Moved' : 'Copied'
toast.success(
`${verb} ${total} photo${total === 1 ? '' : 's'}`,
data.heap_deleted ? `Heap "${heap?.name}" deleted` : undefined
)
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
onClose()
},
onError: (e: any) =>
toast.error('Convert failed', e?.response?.data?.detail || e.message),
})
if (!heap) return null
const targetFolder = folders.find((f: any) => f.id === targetId)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-text">
Convert "{heap.name}" to folder
</h2>
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Target picker */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
{folders.length === 0 ? (
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
No folders available
</div>
) : (
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
>
{folders.map((f: any) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
)}
{targetFolder && (
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
<Folder className="h-3 w-3" />
{targetFolder.path}
</p>
)}
</div>
{/* Subfolder name */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">
Subfolder name
</label>
<input
type="text"
value={subfolderName}
onChange={(e) => setSubfolderName(e.target.value)}
placeholder="(none — use parent directly)"
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
<p className="mt-1 text-xs text-text-faint">
{subfolderName.trim() && targetFolder
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
: 'Photos go directly into the parent folder.'}
</p>
</div>
{/* Mode toggle */}
<div className="mb-4">
<label className="mb-1 block text-xs text-text-muted">Mode</label>
<div className="flex gap-2">
<button
onClick={() => setMode('move')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'move'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Move
</button>
<button
onClick={() => setMode('copy')}
className={clsx(
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
mode === 'copy'
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
Copy
</button>
</div>
<p className="mt-1 text-xs text-text-faint">
{mode === 'move'
? 'Files are moved on disk; original photos update their folder.'
: 'Files are copied on disk; new photo records are created.'}
</p>
</div>
{/* Delete heap toggle */}
<div className="mb-4 flex items-center gap-2">
<input
id="delete-heap"
type="checkbox"
checked={deleteHeap}
onChange={(e) => setDeleteHeap(e.target.checked)}
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
/>
<label htmlFor="delete-heap" className="text-sm text-text">
Delete heap after conversion
</label>
</div>
{convertMutation.isError && (
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
<span>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={convertMutation.isPending}
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => convertMutation.mutate()}
disabled={!targetId || convertMutation.isPending}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{convertMutation.isPending ? 'Converting…' : 'Convert'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -6,14 +6,16 @@ import {
X,
ChevronDown,
ChevronRight,
FolderOutput,
} from 'lucide-react'
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { heaps as heapsApi } from '../../services/api'
import { heaps as heapsApi, type Heap } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { HeapConvertDialog } from './HeapConvertDialog'
/**
* Heaps panel for the left sidebar. Renders the list of heaps with the
@@ -27,8 +29,8 @@ import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
*/
export function HeapsPanel() {
const { data: heaps = [] } = useHeapsQuery()
const filterHeapId = useFilterStore((s) => s.heapId)
const setFilterHeapId = useFilterStore((s) => s.setHeapId)
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(true)
@@ -37,6 +39,7 @@ export function HeapsPanel() {
// Which heap row is currently being hovered with a drag — used to render
// the drop highlight ring. Only one heap can be the target at a time.
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -68,8 +71,10 @@ export function HeapsPanel() {
mutationFn: (heapId: string) => heapsApi.delete(heapId),
onSuccess: (_, heapId) => {
invalidate()
// If we were filtering by this heap, clear the filter
if (filterHeapId === heapId) setFilterHeapId(null)
// If we were viewing this heap, snap back to all-photos.
if (currentSection === `heap-${heapId}`) {
navigateToSection('all-photos', {})
}
},
onError: (e: any) =>
toast.error('Failed to delete heap', e.message || 'Unknown error'),
@@ -194,7 +199,7 @@ export function HeapsPanel() {
)}
{heaps.map((heap) => {
const isFiltered = filterHeapId === heap.id
const isFiltered = currentSection === `heap-${heap.id}`
const isActive = heap.is_active
const isDropTarget = dropTargetId === heap.id
return (
@@ -206,7 +211,9 @@ export function HeapsPanel() {
isDropTarget && 'ring-2 ring-primary bg-primary/10'
)}
style={{ paddingLeft: '32px' }}
onClick={() => setFilterHeapId(heap.id)}
onClick={() =>
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
@@ -277,6 +284,16 @@ export function HeapsPanel() {
>
<Target className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConvertingHeap(heap)
}}
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
title="Convert to folder…"
>
<FolderOutput className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
@@ -294,6 +311,11 @@ export function HeapsPanel() {
})}
</div>
)}
<HeapConvertDialog
heap={convertingHeap}
onClose={() => setConvertingHeap(null)}
/>
</div>
)
}

View File

@@ -6,17 +6,21 @@ import {
Image,
Star,
Trash2,
MoreHorizontal,
HardDrive,
RefreshCw,
Copy,
Tag as TagIcon,
Layers2,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi } from '../../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery'
interface TreeItem {
id: string
@@ -29,15 +33,16 @@ interface TreeItem {
export function LeftSidebar() {
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
const [isScanning, setIsScanning] = useState(false)
// Inline rename state for source-root rows. Stores the id being edited
// and the draft name. Double-click a folder row to start.
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const queryClient = useQueryClient()
const clearAllFilters = useFilterStore((s) => s.clearAll)
const setRatingMin = useFilterStore((s) => s.setRatingMin)
const setFlag = useFilterStore((s) => s.setFlag)
const setFolderId = useFilterStore((s) => s.setFolderId)
const filterFolderId = useFilterStore((s) => s.folderId)
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
@@ -76,6 +81,28 @@ export function LeftSidebar() {
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Bulk copy mutation — Alt-drag uses this instead of move.
const copyDropMutation = useMutation({
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
photosApi.copy(photoIds, targetId),
onSuccess: (data) => {
const copied = data?.copied ?? 0
const errCount = data?.errors?.length ?? 0
if (copied > 0) {
toast.success(
'Copied',
`${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
)
} else if (errCount > 0) {
toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`)
}
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
onError: (e: any) =>
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Reads the dragged ids out of a drop event payload.
const readDragIds = (e: React.DragEvent): string[] | null => {
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
@@ -88,37 +115,48 @@ export function LeftSidebar() {
}
}
// Map a library tree id to a filter-store mutation. Each "virtual node" in
// the library tree is just a saved filter preset.
// Map a library tree id to a section navigation. Each "virtual node" in
// the library tree is its own section, with its own remembered filter
// state. The preset is the section's intrinsic filter (the thing that
// makes it that section); user-added filters from the FilterBar layer
// on top and are saved when the user navigates away.
const applyLibraryNode = (id: string) => {
switch (id) {
case 'all-photos':
clearAllFilters()
navigateToSection('all-photos', {})
break
case 'rated':
clearAllFilters()
setRatingMin(1)
navigateToSection('rated', { ratingMin: 1 })
break
case 'discarded':
clearAllFilters()
setFlag('discarded')
navigateToSection('discarded', { flag: 'discarded' })
break
case 'duplicates':
navigateToSection('duplicates', { duplicates: true })
break
case 'tags':
navigateToSection('tags', { groupBy: 'tag' })
break
default:
if (id.startsWith('folder-')) {
// Folder rows: filter to that folder, clear other filters that
// would compete (heap, discarded, etc.) so the user sees what they
// expect when they click a folder.
const folderId = id.slice('folder-'.length)
clearAllFilters()
setFolderId(folderId)
navigateToSection(`folder-${folderId}`, { folderId })
}
}
}
// Fetch folders from API
const { data: foldersData } = useQuery({
queryKey: ['folders'],
queryFn: sourceFolders.list,
// Fetch the recursive folder tree (one root per active source root).
const { data: folderTree = [] } = useFolderTreeQuery()
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
sourceFolders.rename(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['folders'] })
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
})
// Mutation for scanning all folders
@@ -155,28 +193,39 @@ export function LeftSidebar() {
setExpandedItems(newExpanded)
}
// Recursively map a backend FolderTreeNode into our generic TreeItem.
const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({
id: `folder-${node.id}`,
label: node.name,
icon: <Folder className="h-4 w-4" />,
count: node.photo_count,
type: 'folder',
children: node.children.length > 0
? node.children.map(folderNodeToTreeItem)
: undefined,
})
// Total tag count for the badge on the Tags entry.
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const libraryTree: TreeItem[] = [
{
id: 'library',
label: 'Library',
icon: <HardDrive className="h-4 w-4" />,
label: 'Views',
icon: <Layers2 className="h-4 w-4" />,
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
],
},
{
id: 'folders',
label: 'Folders',
icon: <Folder className="h-4 w-4" />,
children: foldersData?.folders?.map((folder: any) => ({
id: `folder-${folder.id}`,
label: folder.name || folder.path.split('/').pop() || folder.path,
icon: <Folder className="h-4 w-4" />,
count: folder.photo_count,
type: 'folder',
})) || [],
icon: <HardDrive className="h-4 w-4" />,
children: folderTree.map(folderNodeToTreeItem),
},
]
@@ -184,14 +233,13 @@ export function LeftSidebar() {
// Folder rows are selected when the filter store's folderId matches; the
// library "All Photos" virtual node is selected when no folder/heap filter
// is set.
// Active highlight is now driven entirely by currentSection. Each
// library node and folder row maps 1:1 to a section id.
const isItemActive = (id: string): boolean => {
if (id.startsWith('folder-')) {
return filterFolderId === id.slice('folder-'.length)
return currentSection === id
}
if (id === 'all-photos') {
return filterFolderId === null && selectedItem === 'all-photos'
}
return selectedItem === id
return currentSection === id
}
// Which tree items accept photo drops, and what each does on drop.
@@ -199,14 +247,18 @@ export function LeftSidebar() {
return id === 'discarded' || id.startsWith('folder-')
}
const handleDrop = (id: string, ids: string[]) => {
const handleDrop = (id: string, ids: string[], copy: boolean) => {
if (id === 'discarded') {
discardDropMutation.mutate(ids)
return
}
if (id.startsWith('folder-')) {
const targetId = id.slice('folder-'.length)
moveDropMutation.mutate({ targetId, photoIds: ids })
if (copy) {
copyDropMutation.mutate({ targetId, photoIds: ids })
} else {
moveDropMutation.mutate({ targetId, photoIds: ids })
}
}
}
@@ -230,17 +282,36 @@ export function LeftSidebar() {
)}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={() => {
setSelectedItem(item.id)
if (hasChildren) {
if (renamingId === item.id) return
// Folder rows are always filterable, parent or leaf — clicking
// anywhere on the row applies the filter and the chevron
// (separate button below) handles expansion. Other group
// headers (Library, Folders) just toggle expansion since
// they have no associated section.
if (item.id.startsWith('folder-')) {
applyLibraryNode(item.id)
} else if (hasChildren) {
toggleExpanded(item.id)
} else {
applyLibraryNode(item.id)
}
}}
onDoubleClick={
item.id.startsWith('folder-')
? (e) => {
e.stopPropagation()
setRenamingId(item.id)
setRenameDraft(item.label)
}
: undefined
}
onDragOver={acceptsDrop ? (e) => {
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move'
// Alt held → copy (only meaningful for folder targets;
// discarding doesn't copy).
const wantCopy = e.altKey && item.id.startsWith('folder-')
e.dataTransfer.dropEffect = wantCopy ? 'copy' : 'move'
if (dropTargetId !== item.id) setDropTargetId(item.id)
}
} : undefined}
@@ -253,7 +324,7 @@ export function LeftSidebar() {
e.preventDefault()
setDropTargetId(null)
const ids = readDragIds(e)
if (ids) handleDrop(item.id, ids)
if (ids) handleDrop(item.id, ids, e.altKey)
} : undefined}
>
{/* Expand/Collapse Icon */}
@@ -282,8 +353,34 @@ export function LeftSidebar() {
</span>
)}
{/* Label */}
<span className="flex-1 truncate">{item.label}</span>
{/* Label (or inline rename input for folder rows) */}
{renamingId === item.id ? (
<input
autoFocus
type="text"
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => {
const next = renameDraft.trim()
const id = item.id.slice('folder-'.length)
if (next && next !== item.label) {
renameMutation.mutate({ id, name: next })
}
setRenamingId(null)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setRenamingId(null)
}
}}
className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none"
/>
) : (
<span className="flex-1 truncate">{item.label}</span>
)}
{/* Count Badge */}
{item.count !== undefined && item.count > 0 && (
@@ -306,14 +403,6 @@ export function LeftSidebar() {
return (
<div className="flex h-full flex-col bg-surface">
{/* Sidebar Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<h2 className="text-sm font-semibold text-text">Library</h2>
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
{/* Tree View */}
<div className="flex-1 overflow-y-auto py-2">
{libraryTree.map((item) => renderTreeItem(item))}
@@ -321,7 +410,7 @@ export function LeftSidebar() {
</div>
{/* Bottom Actions */}
{foldersData?.folders?.length > 0 && (
{folderTree.length > 0 && (
<div className="border-t border-border p-3">
<button
onClick={handleScanAll}

View File

@@ -18,8 +18,16 @@ import { usePhotoStore } from '../../store/photoStore'
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { tags as tagsApi, type Tag } from '../../services/api'
import { toast } from '../ToastContainer'
interface PhotoTagSummary {
id: string
name: string
color: string | null
}
interface PhotoDetails {
id: string
filename: string
@@ -34,6 +42,7 @@ interface PhotoDetails {
user_notes: string | null
color_label: string | null
exif_json: string | null
tags?: PhotoTagSummary[]
}
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
@@ -100,7 +109,7 @@ export function RightSidebar() {
const queryClient = useQueryClient()
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['basic', 'camera', 'location'])
new Set(['basic', 'camera', 'location', 'tags'])
)
const toggleSection = (section: string) => {
@@ -136,26 +145,51 @@ export function RightSidebar() {
},
})
// Bulk equivalents — used when more than one photo is selected so the
// rating / color / discard buttons apply to the whole selection.
const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
}
const bulkRatingMutation = useMutation({
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
photosApi.bulkSetRating(ids, rating),
onSuccess: invalidatePhotoQueries,
})
const bulkColorMutation = useMutation({
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
photosApi.bulkSetColor(ids, color),
onSuccess: invalidatePhotoQueries,
})
const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
onSuccess: invalidatePhotoQueries,
})
const bulkRestoreMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
onSuccess: invalidatePhotoQueries,
})
// Membership in the active heap (for the Pick toggle button).
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
const isInActiveHeap =
!!activePhotoId && activeHeapMembers.has(activePhotoId)
const heapMutation = useMutation({
mutationFn: ({ remove }: { remove: boolean }) => {
if (!activeHeap || !activePhotoId) return Promise.resolve(null)
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
if (!activeHeap || ids.length === 0) return Promise.resolve(null)
return remove
? heapsApi.removePhotos(activeHeap.id, [activePhotoId])
: heapsApi.addPhotos(activeHeap.id, [activePhotoId])
? heapsApi.removePhotos(activeHeap.id, ids)
: heapsApi.addPhotos(activeHeap.id, ids)
},
// Optimistic flip so the badge / button label update instantly.
onMutate: ({ remove }) => {
if (!activeHeap || !activePhotoId) return { previous: undefined }
onMutate: ({ ids, remove }) => {
if (!activeHeap || ids.length === 0) return { previous: undefined }
const key = ['heap-photo-ids', activeHeap.id] as const
const previous = queryClient.getQueryData<string[]>(key)
const set = new Set(previous ?? [])
if (remove) set.delete(activePhotoId)
else set.add(activePhotoId)
if (remove) ids.forEach((id) => set.delete(id))
else ids.forEach((id) => set.add(id))
queryClient.setQueryData<string[]>(key, Array.from(set))
return { previous }
},
@@ -174,6 +208,46 @@ export function RightSidebar() {
},
})
// ── Tags state + mutations ──────────────────────────────────────────
const { data: allTags = [] } = useTagsQuery()
const [tagInput, setTagInput] = useState('')
const invalidateTagsAndPhoto = () => {
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
}
const addTagMutation = useMutation({
mutationFn: async (name: string) => {
// Idempotent create — backend returns existing row if name matches.
const created = await tagsApi.create(name)
if (activePhotoId) {
await tagsApi.addToPhoto(activePhotoId, [created.id])
}
return created
},
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
})
const attachExistingTagMutation = useMutation({
mutationFn: (tagId: string) =>
tagsApi.addToPhoto(activePhotoId!, [tagId]),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
})
const removeTagMutation = useMutation({
mutationFn: (tagId: string) =>
tagsApi.removeFromPhoto(activePhotoId!, tagId),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Remove tag failed', e?.message || 'Unknown error'),
})
// Local drafts for the editable text fields. These mirror the server value
// but stay independent while the user is typing, so we don't fight focus or
// clobber edits with stale refetches.
@@ -228,8 +302,30 @@ export function RightSidebar() {
updateMutation.mutate({ user_notes: next || null })
}
// Apply a rating / color / discard to the current selection. Falls back
// to the single-photo path when only one photo is selected so the
// RightSidebar matches the keyboard shortcut behaviour exactly.
const applyRating = (value: number) => {
if (selectedPhotos.length > 1) {
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value })
} else {
updateMutation.mutate({ rating: value })
}
}
const setColor = (label: ColorLabel | null) => {
updateMutation.mutate({ color_label: label })
if (selectedPhotos.length > 1) {
bulkColorMutation.mutate({ ids: selectedPhotos, color: label })
} else {
updateMutation.mutate({ color_label: label })
}
}
const applyDiscard = (next: boolean) => {
if (selectedPhotos.length > 1) {
if (next) bulkDiscardMutation.mutate(selectedPhotos)
else bulkRestoreMutation.mutate(selectedPhotos)
} else {
updateMutation.mutate({ is_discarded: next })
}
}
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
@@ -268,62 +364,70 @@ export function RightSidebar() {
</button>
</div>
{/* Quick Actions — operate on the active photo */}
{photo && !multipleSelected && (
{/* Quick Actions */}
{photo && (
<div className="space-y-3 border-b border-border p-4">
{/* Filename (editable, renames the file on disk) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
onBlur={commitFilename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setFilenameDraft(photo.filename ?? '')
e.currentTarget.blur()
}
}}
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none"
/>
</div>
{multipleSelected && (
<p className="text-xs text-text-muted">
Rating, color, and flag apply to all {selectedPhotos.length} selected.
</p>
)}
{/* Title (editable) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setTitleDraft(photo.user_title ?? '')
e.currentTarget.blur()
}
}}
placeholder="No title"
className="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
{/* Per-photo fields — only meaningful for a single selection */}
{!multipleSelected && (
<>
<div>
<label className="mb-1 block text-xs text-text-muted">Filename</label>
<input
type="text"
value={filenameDraft}
onChange={(e) => setFilenameDraft(e.target.value)}
onBlur={commitFilename}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setFilenameDraft(photo.filename ?? '')
e.currentTarget.blur()
}
}}
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none"
/>
</div>
{/* Notes (editable) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
placeholder="Add notes…"
rows={3}
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
<input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setTitleDraft(photo.user_title ?? '')
e.currentTarget.blur()
}
}}
placeholder="No title"
className="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Notes</label>
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes}
placeholder="Add notes…"
rows={3}
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
</div>
</>
)}
{/* Rating */}
<div>
@@ -332,9 +436,7 @@ export function RightSidebar() {
{[1, 2, 3, 4, 5].map((value) => (
<button
key={value}
onClick={() =>
updateMutation.mutate({ rating: rating === value ? 0 : value })
}
onClick={() => applyRating(rating === value ? 0 : value)}
className="p-0.5"
title={`Set rating to ${value}`}
>
@@ -387,7 +489,17 @@ export function RightSidebar() {
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-2">
<button
onClick={() => heapMutation.mutate({ remove: isInActiveHeap })}
onClick={() => {
const ids = selectedPhotos.length > 0
? selectedPhotos
: activePhotoId ? [activePhotoId] : []
if (!activeHeap || ids.length === 0) return
// If every selected photo is already a member, remove
// them; otherwise add the missing ones. Mirrors the
// P keyboard shortcut behaviour exactly.
const allMembers = ids.every((id) => activeHeapMembers.has(id))
heapMutation.mutate({ ids, remove: allMembers })
}}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
@@ -407,9 +519,7 @@ export function RightSidebar() {
{isInActiveHeap ? 'Picked' : 'Pick'}
</button>
<button
onClick={() =>
updateMutation.mutate({ is_discarded: !isDiscarded })
}
onClick={() => applyDiscard(!isDiscarded)}
className={clsx(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isDiscarded
@@ -520,6 +630,26 @@ export function RightSidebar() {
<div className="text-xs text-text-muted">No GPS data</div>
)}
</Section>
{/* Tags */}
<Section
title="Tags"
expanded={expandedSections.has('tags')}
onToggle={() => toggleSection('tags')}
>
<TagsEditor
photoTags={photo.tags ?? []}
allTags={allTags}
tagInput={tagInput}
onTagInputChange={setTagInput}
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
onCreateAndAttach={(name) => {
addTagMutation.mutate(name)
setTagInput('')
}}
onRemove={(id) => removeTagMutation.mutate(id)}
/>
</Section>
</>
)}
@@ -574,6 +704,129 @@ function Section({
)
}
interface TagsEditorProps {
photoTags: PhotoTagSummary[]
allTags: Tag[]
tagInput: string
onTagInputChange: (value: string) => void
onAttachExisting: (id: string) => void
onCreateAndAttach: (name: string) => void
onRemove: (id: string) => void
}
function TagsEditor({
photoTags,
allTags,
tagInput,
onTagInputChange,
onAttachExisting,
onCreateAndAttach,
onRemove,
}: TagsEditorProps) {
const trimmed = tagInput.trim()
const lowerTrimmed = trimmed.toLowerCase()
const photoTagIds = new Set(photoTags.map((t) => t.id))
// Suggestions: tags whose name contains the input AND that aren't
// already on the photo. Capped at 6 to keep the dropdown short.
const suggestions = trimmed
? allTags
.filter(
(t) =>
!photoTagIds.has(t.id) &&
t.name.toLowerCase().includes(lowerTrimmed)
)
.slice(0, 6)
: []
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
: null
const handleSubmit = () => {
if (!trimmed) return
if (exactMatch) {
if (!photoTagIds.has(exactMatch.id)) {
onAttachExisting(exactMatch.id)
}
onTagInputChange('')
} else {
onCreateAndAttach(trimmed)
}
}
return (
<div className="space-y-2">
{/* Existing tag chips */}
{photoTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{photoTags.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={tag.color ? { backgroundColor: `${tag.color}33`, color: tag.color } : undefined}
>
{tag.name}
<button
onClick={() => onRemove(tag.id)}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
title="Remove tag"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags</div>
)}
{/* Add tag input + suggestions */}
<div className="relative">
<input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Add tag…"
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => {
onAttachExisting(s.id)
onTagInputChange('')
}}
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
>
{s.name}
</button>
))}
</div>
)}
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
>
+ Create "{trimmed}"
</button>
)}
</div>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>

View File

@@ -1,12 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import {
Search,
SlidersHorizontal,
X,
ShoppingBasket,
} from 'lucide-react'
import clsx from 'clsx'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { Search, X, ShoppingBasket } from 'lucide-react'
import { useFilterStore } from '../../store/filterStore'
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
import muliLogo from '../../assets/muli-logo.png'
@@ -17,10 +11,6 @@ export function TopBar() {
// mirror so typing stays responsive while we debounce store updates.
const storeQ = useFilterStore((s) => s.q)
const setStoreQ = useFilterStore((s) => s.setQ)
const filterBarOpen = useFilterStore((s) => s.filterBarOpen)
const toggleFilterBar = useFilterStore((s) => s.toggleFilterBar)
const filterState = useFilterStore()
const filtersActive = hasActiveFilters(filterState) || filterBarOpen
const [searchQuery, setSearchQuery] = useState(storeQ)
@@ -101,24 +91,8 @@ export function TopBar() {
</div>
</div>
{/* Right — filter toggle */}
<div className="flex items-center gap-2">
<button
onClick={toggleFilterBar}
className={clsx(
'group relative rounded p-1.5 transition-colors',
filtersActive
? 'bg-primary/20 text-primary'
: 'text-text-muted hover:bg-surface-2 hover:text-text'
)}
title="Toggle filters (\\)"
>
<SlidersHorizontal className="h-4 w-4" />
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
\
</kbd>
</button>
</div>
{/* Right — reserved for future actions */}
<div className="flex items-center gap-2" />
</header>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check } from 'lucide-react'
import { Star, ShoppingBasket, Trash2, RefreshCw, Check, Copy } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
@@ -207,6 +207,14 @@ export function PhotoThumbnail({
<ShoppingBasket className="h-3 w-3" />
</div>
)}
{photo.is_duplicate && (
<div
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white shadow-md"
title="Duplicate (matches another photo's hash)"
>
<Copy className="h-3 w-3" />
</div>
)}
{photo.is_discarded && (
<Trash2 className="h-4 w-4 text-reject" />
)}

View File

@@ -24,17 +24,22 @@ type TimelineItem =
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
/**
* Build groups by month label when sorted by a date field. For non-temporal
* sorts (filename / file_size / rating) we return a single un-headered group.
* Build the flat header|row item array the virtualizer renders.
*
* Three modes:
* - groupBy='tag': one bucket per unique tag (plus an "Untagged" bucket
* for photos with no tags). A photo with N tags appears in N buckets.
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
* - otherwise: one un-headered stream.
*/
function buildItems(
photos: Photo[],
columns: number,
sortBy: string
sortBy: string,
groupBy: 'date' | 'tag'
): TimelineItem[] {
if (photos.length === 0) return []
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
const items: TimelineItem[] = []
// Helper: split a flat array of cells into rows of `columns` cells.
@@ -50,6 +55,58 @@ function buildItems(
}
}
// ── Tag grouping ──────────────────────────────────────────────────────
if (groupBy === 'tag') {
// Bucket by tag name. A photo with multiple tags lands in multiple
// buckets. Photos with no tags go into "Untagged".
const tagBuckets = new Map<string, PhotoCell[]>()
const untagged: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
const tags = photo.tags ?? []
if (tags.length === 0) {
untagged.push(cell)
} else {
for (const t of tags) {
const arr = tagBuckets.get(t.name) ?? []
arr.push(cell)
tagBuckets.set(t.name, arr)
}
}
})
// Sort tag groups alphabetically; Untagged goes at the end.
const sortedTagNames = Array.from(tagBuckets.keys()).sort((a, b) =>
a.localeCompare(b)
)
let bucketIndex = 0
for (const name of sortedTagNames) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::${name}`,
label: name,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::${name}`, tagBuckets.get(name)!)
bucketIndex++
}
if (untagged.length > 0) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::__untagged`,
label: 'Untagged',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::untagged`, untagged)
}
return items
}
// ── Date grouping (existing) ──────────────────────────────────────────
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
if (!isDateSort) {
// No grouping — one row stream.
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
@@ -110,6 +167,7 @@ export function Timeline() {
const {
selectedPhotos,
activePhotoId,
lastSelectedIndex,
rangeStartIndex,
selectPhoto,
@@ -119,6 +177,7 @@ export function Timeline() {
} = usePhotoStore()
const sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy)
// Calculate number of columns based on container width.
const columns = useMemo(() => {
@@ -138,11 +197,12 @@ export function Timeline() {
// subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Build the flat virtualizer items: a mix of date-group headers and rows
// of photos. Headers only appear when sorted by a date field.
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear when sorted by a date field; tag headers
// appear when groupBy === 'tag' (overrides date grouping).
const items = useMemo(
() => buildItems(photos, columns, sortBy),
[photos, columns, sortBy]
() => buildItems(photos, columns, sortBy, groupBy),
[photos, columns, sortBy, groupBy]
)
// Pre-computed offset of every header in the virtualizer's coordinate
@@ -223,50 +283,102 @@ export function Timeline() {
return () => window.removeEventListener('resize', measureWidth)
}, [])
// Handle keyboard shortcuts for photo navigation. Operates on the flat
// photos array, so it ignores grouping.
// Photo rows in visual order — drops the header items so navigation
// walks the grid as the user sees it. Each row has cells of length
// [1..columns], the last row of a group can be short, and a single
// photo with multiple tags will appear in multiple rows.
const photoRows = useMemo(
() => items.filter((it): it is Extract<TimelineItem, { type: 'row' }> => it.type === 'row'),
[items]
)
// Locate the active photo in the visual grid. Returns the FIRST
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
// can repeat a photo across groups. Returns null when there's no
// active photo or it isn't currently rendered.
const findActiveCell = (): { row: number; col: number } | null => {
if (!activePhotoId) return null
for (let r = 0; r < photoRows.length; r++) {
const row = photoRows[r]
const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
if (c >= 0) return { row: r, col: c }
}
return null
}
// Handle keyboard shortcuts for photo navigation. Operates on the
// grouped grid the user sees, so a half-full last row of a group
// doesn't make ArrowDown skip into the wrong place.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (photos.length === 0) return
if (photoRows.length === 0) return
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return
}
const currentIndex = lastSelectedIndex ?? -1
const move = (dr: number, dc: number) => {
const current = findActiveCell() ?? { row: 0, col: -1 }
let nextRow = current.row
let nextCol = current.col + dc
if (dc !== 0) {
// Wrap left/right across row boundaries.
while (nextCol < 0 && nextRow > 0) {
nextRow -= 1
nextCol = photoRows[nextRow].cells.length - 1
}
while (
nextRow < photoRows.length &&
nextCol >= photoRows[nextRow].cells.length
) {
if (nextRow === photoRows.length - 1) {
nextCol = photoRows[nextRow].cells.length - 1
break
}
nextRow += 1
nextCol = 0
}
if (nextCol < 0) nextCol = 0
}
if (dr !== 0) {
nextRow += dr
if (nextRow < 0) nextRow = 0
if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
// Clamp the column to the destination row's actual width so
// moving down into a half-full row lands on its last cell
// instead of nothing.
const rowLen = photoRows[nextRow].cells.length
if (nextCol >= rowLen) nextCol = rowLen - 1
if (nextCol < 0) nextCol = 0
}
const dest = photoRows[nextRow]?.cells[nextCol]
if (!dest) return
if (e.shiftKey) {
selectRange(dest.globalIndex)
} else {
selectPhoto(dest.photo.id, dest.globalIndex)
}
}
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
if (currentIndex > columns - 1) {
const newIndex = currentIndex - columns
if (e.shiftKey) selectRange(newIndex)
else selectPhoto(photos[newIndex].id, newIndex)
}
move(-1, 0)
break
case 'ArrowDown':
e.preventDefault()
if (currentIndex < photos.length - columns) {
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
if (e.shiftKey) selectRange(newIndex)
else selectPhoto(photos[newIndex].id, newIndex)
}
move(1, 0)
break
case 'ArrowLeft':
e.preventDefault()
if (currentIndex > 0) {
const newIndex = currentIndex - 1
if (e.shiftKey) selectRange(newIndex)
else selectPhoto(photos[newIndex].id, newIndex)
}
move(0, -1)
break
case 'ArrowRight':
e.preventDefault()
if (currentIndex < photos.length - 1) {
const newIndex = currentIndex + 1
if (e.shiftKey) selectRange(newIndex)
else selectPhoto(photos[newIndex].id, newIndex)
}
move(0, 1)
break
case 'a':
if (e.ctrlKey || e.metaKey) {
@@ -288,7 +400,7 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photos, selectedPhotos, lastSelectedIndex, columns])
}, [photoRows, photos, selectedPhotos, activePhotoId])
if (isLoading) {
return (

View File

@@ -71,6 +71,20 @@ function parseUrl(): Partial<FilterState> {
const folderId = sp.get('folder_id')
if (folderId) out.folderId = folderId
const tagIds = sp.get('tag_ids')
if (tagIds) {
const ids = tagIds.split(',').map((t) => t.trim()).filter(Boolean)
if (ids.length > 0) out.tagIds = ids
}
if (sp.get('duplicates') === 'true') out.duplicates = true
const groupBy = sp.get('group')
if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy
const section = sp.get('section')
if (section) (out as any).currentSection = section
const sortBy = sp.get('sort')
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
out.sortBy = sortBy as SortField
@@ -84,7 +98,7 @@ function parseUrl(): Partial<FilterState> {
return out
}
function writeUrl(f: FilterState) {
function writeUrl(f: FilterState & { currentSection?: string }) {
const sp = new URLSearchParams()
if (f.q.trim()) sp.set('q', f.q.trim())
if (f.dateFrom) sp.set('date_from', f.dateFrom)
@@ -95,6 +109,11 @@ function writeUrl(f: FilterState) {
if (f.flag !== 'any') sp.set('flag', f.flag)
if (f.heapId) sp.set('heap_id', f.heapId)
if (f.folderId) sp.set('folder_id', f.folderId)
if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(','))
if (f.duplicates) sp.set('duplicates', 'true')
if (f.groupBy !== 'date') sp.set('group', f.groupBy)
if (f.currentSection && f.currentSection !== 'all-photos')
sp.set('section', f.currentSection)
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query'
import { sourceFolders, type FolderTreeNode } from '../services/api'
export const FOLDER_TREE_QUERY_KEY = ['folders', 'tree'] as const
export function useFolderTreeQuery() {
return useQuery<FolderTreeNode[]>({
queryKey: FOLDER_TREE_QUERY_KEY,
queryFn: sourceFolders.tree,
staleTime: 30_000,
})
}
/** Walk the tree to find a node by id. Used for chip name lookups. */
export function findFolderInTree(
tree: FolderTreeNode[] | undefined,
id: string
): FolderTreeNode | null {
if (!tree) return null
for (const node of tree) {
if (node.id === id) return node
const child = findFolderInTree(node.children, id)
if (child) return child
}
return null
}

View File

@@ -1,7 +1,6 @@
import { useHotkeys } from 'react-hotkeys-hook'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore'
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
@@ -55,10 +54,74 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
},
})
const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
}
const bulkRatingMutation = useMutation({
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
photosApi.bulkSetRating(ids, rating),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk rating failed', e.message || 'Unknown error'),
})
const bulkColorMutation = useMutation({
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
photosApi.bulkSetColor(ids, color),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
})
const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
})
const bulkRestoreMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
})
/** The set of photo ids the next culling action should apply to.
* - Multi-selection → all selected photos
* - Single selection → that one photo
* - No selection but an activePhotoId set (last clicked) → that one
* - Otherwise → empty
*/
const cullTargets = (): string[] => {
const state = usePhotoStore.getState()
if (state.selectedPhotos.length > 0) return state.selectedPhotos
if (state.activePhotoId) return [state.activePhotoId]
return []
}
/** Apply a partial PhotoUpdate to the cull targets. Picks the right
* bulk endpoint when there are 2+ photos so a single API call covers
* the whole selection. */
const updateActive = (data: PhotoUpdate) => {
const id = usePhotoStore.getState().activePhotoId
if (!id) return
updateMutation.mutate({ id, data })
const ids = cullTargets()
if (ids.length === 0) return
if (ids.length === 1) {
updateMutation.mutate({ id: ids[0], data })
return
}
// Multi-selection — fan out to the right bulk endpoint per field.
if (data.rating !== undefined) {
bulkRatingMutation.mutate({ ids, rating: data.rating })
}
if (data.color_label !== undefined) {
bulkColorMutation.mutate({ ids, color: data.color_label })
}
if (data.is_discarded === true) {
bulkDiscardMutation.mutate(ids)
} else if (data.is_discarded === false) {
bulkRestoreMutation.mutate(ids)
}
}
// P key (Pick): toggle the current selection's membership in the active
@@ -158,9 +221,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
useHotkeys('\\', () => useFilterStore.getState().toggleFilterBar(), HK_OPTS)
// Search focus (/ or Cmd/Ctrl+F).
const focusSearch = () => {
const el = document.getElementById('topbar-search') as HTMLInputElement | null
el?.focus()

View File

@@ -20,6 +20,9 @@ export function usePhotosQuery() {
const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId)
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates)
const groupBy = useFilterStore((s) => s.groupBy)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
@@ -35,10 +38,13 @@ export function usePhotosQuery() {
flag,
heapId,
folderId,
tagIds,
duplicates,
groupBy,
sortBy,
sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, sortBy, sortOrder]
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
)
return useQuery({

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { tags as tagsApi, type Tag } from '../services/api'
export const TAGS_QUERY_KEY = ['tags'] as const
export function useTagsQuery() {
return useQuery<Tag[]>({
queryKey: TAGS_QUERY_KEY,
queryFn: tagsApi.list,
staleTime: 30_000,
})
}

View File

@@ -10,17 +10,39 @@ const api = axios.create({
})
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
// .env → bootstrap on backend startup), so the UI only reads them.
// .env → bootstrap on backend startup), so the UI only reads them and
// optionally renames the display label.
export interface FolderTreeNode {
id: string
name: string
path: string
photo_count: number
children: FolderTreeNode[]
}
export const sourceFolders = {
list: async () => {
const response = await api.get('/folders')
return response.data
},
/** Recursive folder tree, one root per active source root. */
tree: async (): Promise<FolderTreeNode[]> => {
const response = await api.get('/folders/tree')
return response.data
},
scan: async (folderId: string) => {
const response = await api.post(`/folders/${folderId}/scan`)
return response.data
},
/** Rename the display label only — the on-disk path is controlled by
* the docker mount and cannot be changed from the UI. */
rename: async (folderId: string, name: string) => {
const response = await api.patch(`/folders/${folderId}`, { name })
return response.data
},
}
// Photos API
@@ -74,6 +96,26 @@ export const photos = {
return response.data
},
/** Bulk set rating (0-5). */
bulkSetRating: async (photoIds: string[], rating: number) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_rating',
value: rating,
})
return response.data
},
/** Bulk set color label (or null to clear). */
bulkSetColor: async (photoIds: string[], color: string | null) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'set_color',
value: color,
})
return response.data
},
/** Move photos into a target folder (or source root). Returns
* { moved, errors[] }. */
move: async (photoIds: string[], targetId: string) => {
@@ -84,6 +126,16 @@ export const photos = {
return response.data as { status: string; moved: number; errors: Array<{ id: string; error: string }> }
},
/** Copy photos into a target folder. Originals are unaffected; new
* rows are created with is_duplicate=true. */
copy: async (photoIds: string[], targetId: string) => {
const response = await api.post('/photos/copy', {
photo_ids: photoIds,
target_id: targetId,
})
return response.data as { status: string; copied: number; errors: Array<{ id: string; error: string }> }
},
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
},
@@ -170,35 +222,69 @@ export const heaps = {
})
return response.data
},
/** Convert a heap into a folder by moving (or copying) every member
* photo into the target directory. Optionally creates a subfolder
* inside the target by name. */
convert: async (
heapId: string,
body: {
target_id: string
mode: 'move' | 'copy'
delete_heap: boolean
subfolder_name?: string | null
}
) => {
const response = await api.post(`/heaps/${heapId}/convert`, body)
return response.data as {
status: string
mode: 'move' | 'copy'
moved: number
copied: number
errors: Array<{ id: string; error: string }>
heap_deleted: boolean
}
},
}
// Tags API
export interface Tag {
id: string
name: string
color: string | null
photo_count: number
}
export const tags = {
list: async () => {
list: async (): Promise<Tag[]> => {
const response = await api.get('/tags')
return response.data
},
create: async (name: string, color?: string) => {
const response = await api.post('/tags', {
name,
color,
})
create: async (name: string, color?: string): Promise<Tag> => {
const response = await api.post('/tags', { name, color })
return response.data
},
update: async (tagId: string, data: {
name?: string
color?: string
}) => {
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
const response = await api.patch(`/tags/${tagId}`, data)
return response.data
},
delete: async (tagId: string) => {
const response = await api.delete(`/tags/${tagId}`)
delete: async (tagId: string): Promise<void> => {
await api.delete(`/tags/${tagId}`)
},
/** Add one or more tags to a photo. */
addToPhoto: async (photoId: string, tagIds: string[]) => {
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
return response.data
},
/** Remove a tag from a photo. */
removeFromPhoto: async (photoId: string, tagId: string): Promise<void> => {
await api.delete(`/photos/${photoId}/tags/${tagId}`)
},
}
// Discard API

View File

@@ -10,6 +10,7 @@ export type SortField =
| 'file_size'
| 'rating'
export type SortOrder = 'asc' | 'desc'
export type GroupBy = 'date' | 'tag'
export interface FilterState {
q: string
@@ -24,12 +25,33 @@ export interface FilterState {
heapId: string | null
/** When set, restrict to photos in this folder. */
folderId: string | null
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
tagIds: string[]
/** When true, restrict to photos flagged as duplicates by the scanner. */
duplicates: boolean
/** Visual grouping mode. 'date' groups by month when sortBy is a date
* field; 'tag' groups by photo tag membership. Independent of filters. */
groupBy: GroupBy
sortBy: SortField
sortOrder: SortOrder
}
/** Identifies which "section" of the app the user is currently viewing.
* Sections each carry their own filter state — switching to one restores
* whatever filters were active there last time, switching away saves the
* current state under the section being left. */
export const ALL_PHOTOS_SECTION = 'all-photos'
interface FilterStore extends FilterState {
filterBarOpen: boolean
/** The active section id. Changes via navigateToSection. */
currentSection: string
/** Per-section snapshot of filter state, in-memory. Restored on return. */
sectionFilters: Record<string, FilterState>
/** Per-section "intrinsic" filters — the preset that defines what makes
* a section that section (e.g. flag=discarded for the discarded
* section). Used by clearAll to reset within a section without
* navigating away. */
sectionPresets: Record<string, Partial<FilterState>>
setQ: (q: string) => void
setDateFrom: (date: string | null) => void
@@ -40,14 +62,27 @@ interface FilterStore extends FilterState {
setFlag: (flag: FlagFilter) => void
setHeapId: (id: string | null) => void
setFolderId: (id: string | null) => void
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setDuplicates: (v: boolean) => void
setGroupBy: (mode: GroupBy) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
toggleSortOrder: () => void
setFilterBarOpen: (open: boolean) => void
toggleFilterBar: () => void
/** Navigate to a section. Saves the current section's filter state into
* the in-memory map under the OLD section id, then loads the saved
* state for the destination — or, if none exists, applies the preset
* overrides on top of INITIAL_FILTERS. The preset is also stored so
* clearAll inside the section resets correctly. */
navigateToSection: (
sectionId: string,
presetOverrides?: Partial<FilterState>
) => void
hydrate: (partial: Partial<FilterState>) => void
hydrate: (partial: Partial<FilterState> & { currentSection?: string }) => void
/** Reset filters within the CURRENT section back to its preset. Doesn't
* navigate. For an explicit "go to all photos" use navigateToSection. */
clearAll: () => void
}
@@ -61,13 +96,40 @@ export const INITIAL_FILTERS: FilterState = {
flag: 'any',
heapId: null,
folderId: null,
tagIds: [],
duplicates: false,
groupBy: 'date',
sortBy: 'taken_at',
sortOrder: 'desc',
}
/** Pull the FilterState slice out of the full store, dropping the
* control fields. Used when snapshotting current filters into the
* per-section map. */
function snapshotFilters(s: FilterState): FilterState {
return {
q: s.q,
dateFrom: s.dateFrom,
dateTo: s.dateTo,
mediaTypes: [...s.mediaTypes],
ratingMin: s.ratingMin,
colorLabel: s.colorLabel,
flag: s.flag,
heapId: s.heapId,
folderId: s.folderId,
tagIds: [...s.tagIds],
duplicates: s.duplicates,
groupBy: s.groupBy,
sortBy: s.sortBy,
sortOrder: s.sortOrder,
}
}
export const useFilterStore = create<FilterStore>((set) => ({
...INITIAL_FILTERS,
filterBarOpen: false,
currentSection: ALL_PHOTOS_SECTION,
sectionFilters: {},
sectionPresets: { [ALL_PHOTOS_SECTION]: {} },
setQ: (q) => set({ q }),
setDateFrom: (dateFrom) => set({ dateFrom }),
@@ -83,16 +145,54 @@ export const useFilterStore = create<FilterStore>((set) => ({
setFlag: (flag) => set({ flag }),
setHeapId: (heapId) => set({ heapId }),
setFolderId: (folderId) => set({ folderId }),
setTagIds: (tagIds) => set({ tagIds }),
toggleTagId: (id) =>
set((s) => ({
tagIds: s.tagIds.includes(id)
? s.tagIds.filter((t) => t !== id)
: [...s.tagIds, id],
})),
setDuplicates: (duplicates) => set({ duplicates }),
setGroupBy: (groupBy) => set({ groupBy }),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
toggleSortOrder: () =>
set((s) => ({ sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' })),
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })),
navigateToSection: (sectionId, presetOverrides = {}) =>
set((s) => {
// Snapshot the current section's filters before switching.
const updatedSectionFilters = {
...s.sectionFilters,
[s.currentSection]: snapshotFilters(s),
}
// Remember this section's intrinsic preset (last write wins, which
// is fine — sections are uniquely identified by id).
const updatedSectionPresets = {
...s.sectionPresets,
[sectionId]: presetOverrides,
}
// Restore the destination section's saved state, or apply the
// preset on top of fresh defaults if it's never been visited.
const saved = updatedSectionFilters[sectionId]
const next: FilterState = saved
? saved
: { ...INITIAL_FILTERS, ...presetOverrides }
return {
...next,
currentSection: sectionId,
sectionFilters: updatedSectionFilters,
sectionPresets: updatedSectionPresets,
}
}),
hydrate: (partial) => set(partial),
clearAll: () => set({ ...INITIAL_FILTERS }),
clearAll: () =>
set((s) => {
const preset = s.sectionPresets[s.currentSection] ?? {}
return { ...INITIAL_FILTERS, ...preset }
}),
}))
/** Convert filter state to the query params the backend list endpoint expects.
@@ -108,6 +208,8 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.flag === 'discarded') params.is_discarded = 'true'
if (f.heapId) params.heap_id = f.heapId
if (f.folderId) params.folder_id = f.folderId
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
if (f.duplicates) params.is_duplicate = 'true'
params.sort = f.sortBy
params.order = f.sortOrder
return params
@@ -124,6 +226,8 @@ export function hasActiveFilters(f: FilterState): boolean {
f.colorLabel !== null ||
f.flag !== 'any' ||
f.heapId !== null ||
f.folderId !== null
f.folderId !== null ||
f.tagIds.length > 0 ||
f.duplicates
)
}

View File

@@ -1,3 +1,9 @@
export interface PhotoTagSummary {
id: string
name: string
color: string | null
}
export interface Photo {
id: string
filepath: string
@@ -8,8 +14,10 @@ export interface Photo {
taken_at: string | null
rating: number
is_discarded: boolean
is_duplicate: boolean
file_hash: string
thumb_small?: string
thumb_medium?: string
thumb_large?: string
tags?: PhotoTagSummary[]
}