Compare commits
15 Commits
a8750afef0
...
2f1e9033ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f1e9033ae | |||
| 522228fb79 | |||
| 5e10b12b13 | |||
| 8fd8bfe3de | |||
| 8413b112ee | |||
| b4a2241bd9 | |||
| db37be902e | |||
| 6917e618e5 | |||
| 6985026106 | |||
| 2d37fba211 | |||
| 914eb58ac5 | |||
| bb7c2b12d6 | |||
| bed817d274 | |||
| bc1e63095c | |||
| 63383ecf1c |
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,30 +182,108 @@ 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)
|
||||
)
|
||||
photo = result.scalar_one_or_none()
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -361,42 +361,61 @@ 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)
|
||||
|
||||
|
||||
# Check if it's a supported file type
|
||||
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||
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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
107
frontend/src/components/filter/FilterPill.tsx
Normal file
107
frontend/src/components/filter/FilterPill.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
221
frontend/src/components/heaps/HeapConvertDialog.tsx
Normal file
221
frontend/src/components/heaps/HeapConvertDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -140,11 +178,11 @@ export function LeftSidebar() {
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const handleScanAll = () => {
|
||||
scanLibraryMutation.mutate()
|
||||
}
|
||||
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
const newExpanded = new Set(expandedItems)
|
||||
if (newExpanded.has(id)) {
|
||||
@@ -154,44 +192,54 @@ 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),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
// Derive whether a tree item is currently the "active" filter target.
|
||||
// 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 */}
|
||||
@@ -274,26 +345,52 @@ export function LeftSidebar() {
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
|
||||
|
||||
{/* Item Icon */}
|
||||
{item.icon && (
|
||||
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
||||
{item.icon}
|
||||
</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 && (
|
||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||
{item.count}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* Render Children */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
@@ -303,25 +400,17 @@ export function LeftSidebar() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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))}
|
||||
<HeapsPanel />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Bottom Actions */}
|
||||
{foldersData?.folders?.length > 0 && (
|
||||
{folderTree.length > 0 && (
|
||||
<div className="border-t border-border p-3">
|
||||
<button
|
||||
onClick={handleScanAll}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
26
frontend/src/hooks/useFolderTreeQuery.ts
Normal file
26
frontend/src/hooks/useFolderTreeQuery.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
12
frontend/src/hooks/useTagsQuery.ts
Normal file
12
frontend/src/hooks/useTagsQuery.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user