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 →
|
Folders API router. Source roots themselves are config-driven (PHOTO_DIRS
|
||||||
backend bootstrap on startup); this router only exposes read access and a
|
in .env → backend bootstrap on startup) — adding or removing one is a
|
||||||
manual rescan trigger. Adding/removing source roots happens by editing
|
docker-compose change. The UI can read the list, trigger a manual rescan,
|
||||||
docker-compose.yml + .env and restarting the stack.
|
and rename the display label, but it can't change the on-disk path.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Folder, SourceRoot
|
from app.models import Folder, SourceRoot, Photo
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class FolderRename(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_folders(db: AsyncSession = Depends(get_db)):
|
async def get_folders(db: AsyncSession = Depends(get_db)):
|
||||||
"""Get all source folders"""
|
"""Get all source folders"""
|
||||||
@@ -39,6 +44,139 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
return {"folders": folders_list}
|
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")
|
@router.post("/{folder_id}/scan")
|
||||||
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
"""Trigger manual re-scan of source root folder"""
|
"""Trigger manual re-scan of source root folder"""
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Heaps API router
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, func, update, insert, delete
|
from sqlalchemy import select, func, update, insert, delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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
|
from app.models.heaps import heap_photos
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +35,16 @@ class HeapPhotosBody(BaseModel):
|
|||||||
photo_ids: list[str]
|
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 ─────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -180,6 +196,170 @@ async def add_photos_to_heap(
|
|||||||
return {"status": "success", "added": len(new_ids), "already_present": len(existing_ids)}
|
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")
|
@router.delete("/{heap_id}/photos")
|
||||||
async def remove_photos_from_heap(
|
async def remove_photos_from_heap(
|
||||||
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, and_, or_, func
|
from sqlalchemy import select, and_, or_, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -16,15 +17,16 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from app.database import get_db
|
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.folders import SourceRoot
|
||||||
from app.models.heaps import heap_photos
|
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.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.get("", response_model=PhotoListResponse)
|
@router.get("")
|
||||||
async def list_photos(
|
async def list_photos(
|
||||||
q: Optional[str] = None,
|
q: Optional[str] = None,
|
||||||
date_from: Optional[datetime] = None,
|
date_from: Optional[datetime] = None,
|
||||||
@@ -36,6 +38,7 @@ async def list_photos(
|
|||||||
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
rating_max: Optional[int] = Query(None, ge=0, le=5),
|
||||||
color_label: Optional[str] = None,
|
color_label: Optional[str] = None,
|
||||||
is_discarded: Optional[bool] = False,
|
is_discarded: Optional[bool] = False,
|
||||||
|
is_duplicate: Optional[bool] = None,
|
||||||
heap_id: Optional[str] = None,
|
heap_id: Optional[str] = None,
|
||||||
sort: str = "taken_at",
|
sort: str = "taken_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
@@ -45,8 +48,9 @@ async def list_photos(
|
|||||||
):
|
):
|
||||||
"""List photos with filters and pagination"""
|
"""List photos with filters and pagination"""
|
||||||
|
|
||||||
# Build query
|
# Build query — eager-load tags so the response can include them
|
||||||
query = select(Photo)
|
# without an N+1 round-trip per photo.
|
||||||
|
query = select(Photo).options(selectinload(Photo.tags))
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
filters = []
|
filters = []
|
||||||
@@ -69,15 +73,17 @@ async def list_photos(
|
|||||||
if date_to:
|
if date_to:
|
||||||
filters.append(Photo.taken_at <= date_to)
|
filters.append(Photo.taken_at <= date_to)
|
||||||
|
|
||||||
# Folder filter — the sidebar exposes "source roots" (top-level scan
|
# Folder filter. The sidebar can pass either a SourceRoot id or a
|
||||||
# paths) under the same UI affordance as folders, so the same param has
|
# Folder id; both should include descendants so clicking a parent
|
||||||
# to accept either a folder id or a source root id. If the value matches
|
# folder shows everything under it (Lightroom semantics).
|
||||||
# a source root, expand to every folder under that root and use IN.
|
|
||||||
if folder_id:
|
if folder_id:
|
||||||
sr_check = await db.execute(
|
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(
|
child_folders = await db.execute(
|
||||||
select(Folder.id).where(Folder.source_root_id == folder_id)
|
select(Folder.id).where(Folder.source_root_id == folder_id)
|
||||||
)
|
)
|
||||||
@@ -85,11 +91,25 @@ async def list_photos(
|
|||||||
if child_ids:
|
if child_ids:
|
||||||
filters.append(Photo.folder_id.in_(child_ids))
|
filters.append(Photo.folder_id.in_(child_ids))
|
||||||
else:
|
else:
|
||||||
# Source root with no folder rows yet — match nothing rather
|
|
||||||
# than returning the entire library.
|
|
||||||
filters.append(Photo.id == '__no_match__')
|
filters.append(Photo.id == '__no_match__')
|
||||||
else:
|
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
|
# Media type filter
|
||||||
if media_type:
|
if media_type:
|
||||||
@@ -112,6 +132,11 @@ async def list_photos(
|
|||||||
# Discard filter — defaults to hiding discarded photos
|
# Discard filter — defaults to hiding discarded photos
|
||||||
filters.append(Photo.is_discarded == is_discarded)
|
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.
|
# Heap membership filter — restrict to photos that belong to the heap.
|
||||||
if heap_id:
|
if heap_id:
|
||||||
filters.append(
|
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
|
# Apply all filters
|
||||||
if filters:
|
if filters:
|
||||||
query = query.where(and_(*filters))
|
query = query.where(and_(*filters))
|
||||||
@@ -144,30 +182,108 @@ async def list_photos(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
photos = result.scalars().all()
|
photos = result.scalars().all()
|
||||||
|
|
||||||
# Convert to response
|
# Convert to response, attaching tags inline so the frontend can group
|
||||||
return PhotoListResponse(
|
# client-side without a second round-trip.
|
||||||
photos=[PhotoResponse.from_orm(photo) for photo in photos],
|
photo_dicts = []
|
||||||
total=total,
|
for photo in photos:
|
||||||
page=page,
|
d = PhotoResponse.from_orm(photo).dict()
|
||||||
per_page=per_page,
|
d["tags"] = [
|
||||||
pages=(total + per_page - 1) // per_page
|
{"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(
|
async def get_photo(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
db: AsyncSession = Depends(get_db)
|
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(
|
result = await db.execute(
|
||||||
select(Photo).where(Photo.id == photo_id)
|
select(Photo).where(Photo.id == photo_id)
|
||||||
)
|
)
|
||||||
photo = result.scalar_one_or_none()
|
photo = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not photo:
|
if not photo:
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
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}")
|
@router.get("/{photo_id}/thumb/{size}")
|
||||||
async def get_thumbnail(
|
async def get_thumbnail(
|
||||||
@@ -491,6 +607,129 @@ class MoveRequest(BaseModel):
|
|||||||
target_id: str # folder id OR source root id
|
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")
|
@router.post("/move")
|
||||||
async def move_photos(
|
async def move_photos(
|
||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
|
|||||||
@@ -1,27 +1,114 @@
|
|||||||
"""
|
"""
|
||||||
Tags API router
|
Tags API router
|
||||||
"""
|
"""
|
||||||
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Tag
|
from app.models import Tag
|
||||||
|
from app.models.tags import photo_tags
|
||||||
|
|
||||||
router = APIRouter()
|
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("")
|
@router.get("")
|
||||||
async def list_tags(db: AsyncSession = Depends(get_db)):
|
async def list_tags(db: AsyncSession = Depends(get_db)):
|
||||||
"""List all tags with usage counts"""
|
"""List all tags with their photo counts."""
|
||||||
result = await db.execute(select(Tag))
|
count_subq = (
|
||||||
tags = result.scalars().all()
|
select(
|
||||||
return tags
|
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("")
|
return [
|
||||||
async def create_tag(name: str, color: str = None, db: AsyncSession = Depends(get_db)):
|
{
|
||||||
"""Create a new tag"""
|
"id": tag.id,
|
||||||
tag = Tag(name=name, color=color)
|
"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)
|
db.add(tag)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tag)
|
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.database import AsyncSessionLocal
|
||||||
from app.models import SourceRoot
|
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
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -49,15 +49,17 @@ async def bootstrap_default_source_root() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def start_initial_scan():
|
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:
|
try:
|
||||||
# Queue scan of all source roots
|
|
||||||
scan_all_source_roots.delay()
|
scan_all_source_roots.delay()
|
||||||
|
|
||||||
# Start folder watcher if configured
|
|
||||||
if settings.scanner.watch:
|
|
||||||
watch_folders.delay()
|
|
||||||
|
|
||||||
logger.info("Initial scan queued successfully")
|
logger.info("Initial scan queued successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start initial scan: {e}")
|
logger.error(f"Failed to start initial scan: {e}")
|
||||||
|
|||||||
@@ -361,42 +361,61 @@ def watch_folders():
|
|||||||
from watchfiles import watch
|
from watchfiles import watch
|
||||||
|
|
||||||
# Read source roots from the DB instead of the (now-removed) YAML
|
# Read source roots from the DB instead of the (now-removed) YAML
|
||||||
# config. Synchronous lookup is fine here — this runs once at task
|
# config. We need both the path and the id so we can dispatch
|
||||||
# start, not on every event.
|
# scan_folder with the source_root_id when an event fires.
|
||||||
paths: list[str] = []
|
roots: list[tuple[str, str]] = []
|
||||||
try:
|
try:
|
||||||
async def _load_paths():
|
async def _load_roots():
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||||
)
|
)
|
||||||
return [
|
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)
|
if os.path.exists(sr.path)
|
||||||
]
|
]
|
||||||
paths = asyncio.run(_load_paths())
|
roots = asyncio.run(_load_roots())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"watch_folders could not load source roots: {e}")
|
logger.error(f"watch_folders could not load source roots: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not paths:
|
if not roots:
|
||||||
logger.warning("No valid source roots to watch")
|
logger.warning("No valid source roots to watch")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
paths = [p for p, _ in roots]
|
||||||
logger.info(f"Starting folder watcher for: {paths}")
|
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 changes in watch(*paths):
|
||||||
for change_type, filepath in changes:
|
for change_type, filepath in changes:
|
||||||
filepath = str(filepath)
|
filepath = str(filepath)
|
||||||
|
|
||||||
# Check if it's a supported file type
|
# Check if it's a supported file type
|
||||||
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
|
if Path(filepath).suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if change_type == 'added' or change_type == 'modified':
|
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)
|
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}")
|
logger.info(f"File {change_type}: {filepath}, queued scan for {parent_dir}")
|
||||||
elif change_type == 'deleted':
|
elif change_type == 'deleted':
|
||||||
# Handle file deletion
|
# Handle file deletion
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ToastContainer } from './components/ToastContainer'
|
|||||||
import { KeyboardHints } from './components/KeyboardHints'
|
import { KeyboardHints } from './components/KeyboardHints'
|
||||||
import { PreviewView } from './components/preview/PreviewView'
|
import { PreviewView } from './components/preview/PreviewView'
|
||||||
import { FilterBar } from './components/filter/FilterBar'
|
import { FilterBar } from './components/filter/FilterBar'
|
||||||
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
|
||||||
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
@@ -52,7 +51,6 @@ function App() {
|
|||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
<div className="flex flex-col h-screen bg-bg text-text">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<FilterBar />
|
<FilterBar />
|
||||||
<ActiveFilterChips />
|
|
||||||
<DiscardActionBar />
|
<DiscardActionBar />
|
||||||
<KeyboardHints />
|
<KeyboardHints />
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export function KeyboardHints() {
|
|||||||
{ key: 'Click', action: 'Select' },
|
{ key: 'Click', action: 'Select' },
|
||||||
{ key: 'Shift+Click', action: 'Range' },
|
{ key: 'Shift+Click', action: 'Range' },
|
||||||
{ key: 'Space', action: 'Preview' },
|
{ key: 'Space', action: 'Preview' },
|
||||||
{ key: '\\', action: 'Filters' },
|
|
||||||
{ key: '/', action: 'Search' },
|
{ 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 { 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 { library } from '../services/api'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@ interface ScanStatus {
|
|||||||
export function ScanProgress() {
|
export function ScanProgress() {
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
const [isMinimized, setIsMinimized] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const wasScanningRef = useRef(false)
|
||||||
|
|
||||||
// Poll scan status every 2 seconds when scanning
|
// Poll scan status every 2 seconds when scanning
|
||||||
const { data: scanStatus } = useQuery<ScanStatus>({
|
const { data: scanStatus } = useQuery<ScanStatus>({
|
||||||
@@ -31,18 +33,34 @@ export function ScanProgress() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scanStatus?.is_scanning) {
|
const isScanning = scanStatus?.is_scanning ?? false
|
||||||
|
|
||||||
|
if (isScanning) {
|
||||||
setIsVisible(true)
|
setIsVisible(true)
|
||||||
setIsMinimized(false)
|
setIsMinimized(false)
|
||||||
} else if (isVisible && !scanStatus?.is_scanning && (scanStatus?.processed_files ?? 0) > 0) {
|
wasScanningRef.current = true
|
||||||
// Keep showing for 3 seconds after scan completes
|
} else if (wasScanningRef.current) {
|
||||||
setTimeout(() => {
|
// Just transitioned from scanning → done. THIS is the right moment
|
||||||
if (!scanStatus?.is_scanning) {
|
// to invalidate caches that might have new data: the photos query
|
||||||
setIsVisible(false)
|
// (new files indexed), the folder tree (new folders walked), the
|
||||||
}
|
// heap counts (in case a heap photo got reattached).
|
||||||
}, 3000)
|
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
|
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 clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
useFilterStore,
|
useFilterStore,
|
||||||
|
hasActiveFilters,
|
||||||
type MediaType,
|
type MediaType,
|
||||||
type ColorLabel,
|
type ColorLabel,
|
||||||
type FlagFilter,
|
|
||||||
type SortField,
|
type SortField,
|
||||||
} from '../../store/filterStore'
|
} from '../../store/filterStore'
|
||||||
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
|
import { FilterPill } from './FilterPill'
|
||||||
|
|
||||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||||
{ value: 'photo', label: 'Photo' },
|
{ value: 'photo', label: 'Photo' },
|
||||||
@@ -15,7 +17,7 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
|||||||
{ value: 'heic', label: 'HEIC' },
|
{ 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: 'red', className: 'bg-red-500' },
|
||||||
{ value: 'orange', className: 'bg-orange-500' },
|
{ value: 'orange', className: 'bg-orange-500' },
|
||||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||||
@@ -24,11 +26,6 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
|
|||||||
{ value: 'purple', className: 'bg-purple-500' },
|
{ 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 }[] = [
|
const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||||
{ value: 'taken_at', label: 'Date taken' },
|
{ value: 'taken_at', label: 'Date taken' },
|
||||||
{ value: 'added_at', label: 'Date added' },
|
{ value: 'added_at', label: 'Date added' },
|
||||||
@@ -37,8 +34,14 @@ const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
|||||||
{ value: 'rating', label: 'Rating' },
|
{ 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() {
|
export function FilterBar() {
|
||||||
const filterBarOpen = useFilterStore((s) => s.filterBarOpen)
|
const filterState = useFilterStore()
|
||||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||||
const dateTo = useFilterStore((s) => s.dateTo)
|
const dateTo = useFilterStore((s) => s.dateTo)
|
||||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||||
@@ -47,6 +50,7 @@ export function FilterBar() {
|
|||||||
const flag = useFilterStore((s) => s.flag)
|
const flag = useFilterStore((s) => s.flag)
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
|
const tagIds = useFilterStore((s) => s.tagIds)
|
||||||
|
|
||||||
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
||||||
const setDateTo = useFilterStore((s) => s.setDateTo)
|
const setDateTo = useFilterStore((s) => s.setDateTo)
|
||||||
@@ -54,169 +58,283 @@ export function FilterBar() {
|
|||||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||||
const setColorLabel = useFilterStore((s) => s.setColorLabel)
|
const setColorLabel = useFilterStore((s) => s.setColorLabel)
|
||||||
const setFlag = useFilterStore((s) => s.setFlag)
|
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 setSortBy = useFilterStore((s) => s.setSortBy)
|
||||||
const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder)
|
const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder)
|
||||||
const clearAll = useFilterStore((s) => s.clearAll)
|
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 (
|
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">
|
<div className="flex items-center gap-1.5 overflow-x-auto border-b border-border bg-surface px-3 py-1.5">
|
||||||
{/* Date range */}
|
{/* Date */}
|
||||||
<Group label="Date">
|
<FilterPill
|
||||||
<input
|
label="Date"
|
||||||
type="date"
|
value={dateValue}
|
||||||
value={dateFrom ?? ''}
|
isActive={dateActive}
|
||||||
onChange={(e) => setDateFrom(e.target.value || null)}
|
onClear={() => {
|
||||||
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text"
|
setDateFrom(null)
|
||||||
/>
|
setDateTo(null)
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
Clear all
|
<div className="space-y-2">
|
||||||
</button>
|
<div>
|
||||||
</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({
|
{/* Type */}
|
||||||
label,
|
<FilterPill
|
||||||
children,
|
label="Type"
|
||||||
}: {
|
value={typeValue}
|
||||||
label: string
|
isActive={typeActive}
|
||||||
children: React.ReactNode
|
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
|
||||||
}) {
|
>
|
||||||
return (
|
<div className="flex flex-wrap gap-1">
|
||||||
<div className="flex items-center gap-1.5">
|
{MEDIA_TYPES.map(({ value, label }) => {
|
||||||
<span className="text-text-muted">{label}:</span>
|
const active = mediaTypes.includes(value)
|
||||||
{children}
|
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>
|
</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,
|
X,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FolderOutput,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
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 { useFilterStore } from '../../store/filterStore'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
import { HeapConvertDialog } from './HeapConvertDialog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Heaps panel for the left sidebar. Renders the list of heaps with the
|
* 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() {
|
export function HeapsPanel() {
|
||||||
const { data: heaps = [] } = useHeapsQuery()
|
const { data: heaps = [] } = useHeapsQuery()
|
||||||
const filterHeapId = useFilterStore((s) => s.heapId)
|
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||||
const setFilterHeapId = useFilterStore((s) => s.setHeapId)
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(true)
|
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
|
// 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.
|
// the drop highlight ring. Only one heap can be the target at a time.
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
const [convertingHeap, setConvertingHeap] = useState<Heap | null>(null)
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
@@ -68,8 +71,10 @@ export function HeapsPanel() {
|
|||||||
mutationFn: (heapId: string) => heapsApi.delete(heapId),
|
mutationFn: (heapId: string) => heapsApi.delete(heapId),
|
||||||
onSuccess: (_, heapId) => {
|
onSuccess: (_, heapId) => {
|
||||||
invalidate()
|
invalidate()
|
||||||
// If we were filtering by this heap, clear the filter
|
// If we were viewing this heap, snap back to all-photos.
|
||||||
if (filterHeapId === heapId) setFilterHeapId(null)
|
if (currentSection === `heap-${heapId}`) {
|
||||||
|
navigateToSection('all-photos', {})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
||||||
@@ -194,7 +199,7 @@ export function HeapsPanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{heaps.map((heap) => {
|
{heaps.map((heap) => {
|
||||||
const isFiltered = filterHeapId === heap.id
|
const isFiltered = currentSection === `heap-${heap.id}`
|
||||||
const isActive = heap.is_active
|
const isActive = heap.is_active
|
||||||
const isDropTarget = dropTargetId === heap.id
|
const isDropTarget = dropTargetId === heap.id
|
||||||
return (
|
return (
|
||||||
@@ -206,7 +211,9 @@ export function HeapsPanel() {
|
|||||||
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: '32px' }}
|
style={{ paddingLeft: '32px' }}
|
||||||
onClick={() => setFilterHeapId(heap.id)}
|
onClick={() =>
|
||||||
|
navigateToSection(`heap-${heap.id}`, { heapId: heap.id })
|
||||||
|
}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -277,6 +284,16 @@ export function HeapsPanel() {
|
|||||||
>
|
>
|
||||||
<Target className="h-3 w-3" />
|
<Target className="h-3 w-3" />
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -294,6 +311,11 @@ export function HeapsPanel() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<HeapConvertDialog
|
||||||
|
heap={convertingHeap}
|
||||||
|
onClose={() => setConvertingHeap(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
MoreHorizontal,
|
|
||||||
HardDrive,
|
HardDrive,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
Tag as TagIcon,
|
||||||
|
Layers2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||||
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -29,15 +33,16 @@ interface TreeItem {
|
|||||||
|
|
||||||
export function LeftSidebar() {
|
export function LeftSidebar() {
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
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 queryClient = useQueryClient()
|
||||||
const clearAllFilters = useFilterStore((s) => s.clearAll)
|
const navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
const setFlag = useFilterStore((s) => s.setFlag)
|
const { data: allTags = [] } = useTagsQuery()
|
||||||
const setFolderId = useFilterStore((s) => s.setFolderId)
|
|
||||||
const filterFolderId = useFilterStore((s) => s.folderId)
|
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
// 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'),
|
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.
|
// Reads the dragged ids out of a drop event payload.
|
||||||
const readDragIds = (e: React.DragEvent): string[] | null => {
|
const readDragIds = (e: React.DragEvent): string[] | null => {
|
||||||
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
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
|
// Map a library tree id to a section navigation. Each "virtual node" in
|
||||||
// the library tree is just a saved filter preset.
|
// 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) => {
|
const applyLibraryNode = (id: string) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 'all-photos':
|
case 'all-photos':
|
||||||
clearAllFilters()
|
navigateToSection('all-photos', {})
|
||||||
break
|
break
|
||||||
case 'rated':
|
case 'rated':
|
||||||
clearAllFilters()
|
navigateToSection('rated', { ratingMin: 1 })
|
||||||
setRatingMin(1)
|
|
||||||
break
|
break
|
||||||
case 'discarded':
|
case 'discarded':
|
||||||
clearAllFilters()
|
navigateToSection('discarded', { flag: 'discarded' })
|
||||||
setFlag('discarded')
|
break
|
||||||
|
case 'duplicates':
|
||||||
|
navigateToSection('duplicates', { duplicates: true })
|
||||||
|
break
|
||||||
|
case 'tags':
|
||||||
|
navigateToSection('tags', { groupBy: 'tag' })
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
if (id.startsWith('folder-')) {
|
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)
|
const folderId = id.slice('folder-'.length)
|
||||||
clearAllFilters()
|
navigateToSection(`folder-${folderId}`, { folderId })
|
||||||
setFolderId(folderId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders from API
|
// Fetch the recursive folder tree (one root per active source root).
|
||||||
const { data: foldersData } = useQuery({
|
const { data: folderTree = [] } = useFolderTreeQuery()
|
||||||
queryKey: ['folders'],
|
|
||||||
queryFn: sourceFolders.list,
|
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
|
// Mutation for scanning all folders
|
||||||
@@ -140,11 +178,11 @@ export function LeftSidebar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleScanAll = () => {
|
const handleScanAll = () => {
|
||||||
scanLibraryMutation.mutate()
|
scanLibraryMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleExpanded = (id: string) => {
|
const toggleExpanded = (id: string) => {
|
||||||
const newExpanded = new Set(expandedItems)
|
const newExpanded = new Set(expandedItems)
|
||||||
if (newExpanded.has(id)) {
|
if (newExpanded.has(id)) {
|
||||||
@@ -154,44 +192,54 @@ export function LeftSidebar() {
|
|||||||
}
|
}
|
||||||
setExpandedItems(newExpanded)
|
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[] = [
|
const libraryTree: TreeItem[] = [
|
||||||
{
|
{
|
||||||
id: 'library',
|
id: 'library',
|
||||||
label: 'Library',
|
label: 'Views',
|
||||||
icon: <HardDrive className="h-4 w-4" />,
|
icon: <Layers2 className="h-4 w-4" />,
|
||||||
children: [
|
children: [
|
||||||
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
{ 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: '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: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'folders',
|
id: 'folders',
|
||||||
label: 'Folders',
|
label: 'Folders',
|
||||||
icon: <Folder className="h-4 w-4" />,
|
icon: <HardDrive className="h-4 w-4" />,
|
||||||
children: foldersData?.folders?.map((folder: any) => ({
|
children: folderTree.map(folderNodeToTreeItem),
|
||||||
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',
|
|
||||||
})) || [],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Derive whether a tree item is currently the "active" filter target.
|
// Derive whether a tree item is currently the "active" filter target.
|
||||||
// Folder rows are selected when the filter store's folderId matches; the
|
// Folder rows are selected when the filter store's folderId matches; the
|
||||||
// library "All Photos" virtual node is selected when no folder/heap filter
|
// library "All Photos" virtual node is selected when no folder/heap filter
|
||||||
// is set.
|
// 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 => {
|
const isItemActive = (id: string): boolean => {
|
||||||
if (id.startsWith('folder-')) {
|
if (id.startsWith('folder-')) {
|
||||||
return filterFolderId === id.slice('folder-'.length)
|
return currentSection === id
|
||||||
}
|
}
|
||||||
if (id === 'all-photos') {
|
return currentSection === id
|
||||||
return filterFolderId === null && selectedItem === 'all-photos'
|
|
||||||
}
|
|
||||||
return selectedItem === id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which tree items accept photo drops, and what each does on drop.
|
// 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-')
|
return id === 'discarded' || id.startsWith('folder-')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (id: string, ids: string[]) => {
|
const handleDrop = (id: string, ids: string[], copy: boolean) => {
|
||||||
if (id === 'discarded') {
|
if (id === 'discarded') {
|
||||||
discardDropMutation.mutate(ids)
|
discardDropMutation.mutate(ids)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (id.startsWith('folder-')) {
|
if (id.startsWith('folder-')) {
|
||||||
const targetId = id.slice('folder-'.length)
|
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` }}
|
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedItem(item.id)
|
if (renamingId === item.id) return
|
||||||
if (hasChildren) {
|
// 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)
|
toggleExpanded(item.id)
|
||||||
} else {
|
} else {
|
||||||
applyLibraryNode(item.id)
|
applyLibraryNode(item.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={
|
||||||
|
item.id.startsWith('folder-')
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setRenamingId(item.id)
|
||||||
|
setRenameDraft(item.label)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDragOver={acceptsDrop ? (e) => {
|
onDragOver={acceptsDrop ? (e) => {
|
||||||
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||||
e.preventDefault()
|
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)
|
if (dropTargetId !== item.id) setDropTargetId(item.id)
|
||||||
}
|
}
|
||||||
} : undefined}
|
} : undefined}
|
||||||
@@ -253,7 +324,7 @@ export function LeftSidebar() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDropTargetId(null)
|
setDropTargetId(null)
|
||||||
const ids = readDragIds(e)
|
const ids = readDragIds(e)
|
||||||
if (ids) handleDrop(item.id, ids)
|
if (ids) handleDrop(item.id, ids, e.altKey)
|
||||||
} : undefined}
|
} : undefined}
|
||||||
>
|
>
|
||||||
{/* Expand/Collapse Icon */}
|
{/* Expand/Collapse Icon */}
|
||||||
@@ -274,26 +345,52 @@ export function LeftSidebar() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-4" />
|
<div className="w-4" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Item Icon */}
|
{/* Item Icon */}
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label (or inline rename input for folder rows) */}
|
||||||
<span className="flex-1 truncate">{item.label}</span>
|
{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 */}
|
{/* Count Badge */}
|
||||||
{item.count !== undefined && item.count > 0 && (
|
{item.count !== undefined && item.count > 0 && (
|
||||||
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||||
{item.count}
|
{item.count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Render Children */}
|
{/* Render Children */}
|
||||||
{hasChildren && isExpanded && (
|
{hasChildren && isExpanded && (
|
||||||
<div>
|
<div>
|
||||||
@@ -303,25 +400,17 @@ export function LeftSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<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 */}
|
{/* Tree View */}
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
<HeapsPanel />
|
<HeapsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
{foldersData?.folders?.length > 0 && (
|
{folderTree.length > 0 && (
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleScanAll}
|
onClick={handleScanAll}
|
||||||
|
|||||||
@@ -18,8 +18,16 @@ import { usePhotoStore } from '../../store/photoStore'
|
|||||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
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'
|
import { toast } from '../ToastContainer'
|
||||||
|
|
||||||
|
interface PhotoTagSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface PhotoDetails {
|
interface PhotoDetails {
|
||||||
id: string
|
id: string
|
||||||
filename: string
|
filename: string
|
||||||
@@ -34,6 +42,7 @@ interface PhotoDetails {
|
|||||||
user_notes: string | null
|
user_notes: string | null
|
||||||
color_label: string | null
|
color_label: string | null
|
||||||
exif_json: string | null
|
exif_json: string | null
|
||||||
|
tags?: PhotoTagSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||||
@@ -100,7 +109,7 @@ export function RightSidebar() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
new Set(['basic', 'camera', 'location'])
|
new Set(['basic', 'camera', 'location', 'tags'])
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
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).
|
// Membership in the active heap (for the Pick toggle button).
|
||||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
const isInActiveHeap =
|
const isInActiveHeap =
|
||||||
!!activePhotoId && activeHeapMembers.has(activePhotoId)
|
!!activePhotoId && activeHeapMembers.has(activePhotoId)
|
||||||
|
|
||||||
const heapMutation = useMutation({
|
const heapMutation = useMutation({
|
||||||
mutationFn: ({ remove }: { remove: boolean }) => {
|
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
|
||||||
if (!activeHeap || !activePhotoId) return Promise.resolve(null)
|
if (!activeHeap || ids.length === 0) return Promise.resolve(null)
|
||||||
return remove
|
return remove
|
||||||
? heapsApi.removePhotos(activeHeap.id, [activePhotoId])
|
? heapsApi.removePhotos(activeHeap.id, ids)
|
||||||
: heapsApi.addPhotos(activeHeap.id, [activePhotoId])
|
: heapsApi.addPhotos(activeHeap.id, ids)
|
||||||
},
|
},
|
||||||
// Optimistic flip so the badge / button label update instantly.
|
// Optimistic flip so the badge / button label update instantly.
|
||||||
onMutate: ({ remove }) => {
|
onMutate: ({ ids, remove }) => {
|
||||||
if (!activeHeap || !activePhotoId) return { previous: undefined }
|
if (!activeHeap || ids.length === 0) return { previous: undefined }
|
||||||
const key = ['heap-photo-ids', activeHeap.id] as const
|
const key = ['heap-photo-ids', activeHeap.id] as const
|
||||||
const previous = queryClient.getQueryData<string[]>(key)
|
const previous = queryClient.getQueryData<string[]>(key)
|
||||||
const set = new Set(previous ?? [])
|
const set = new Set(previous ?? [])
|
||||||
if (remove) set.delete(activePhotoId)
|
if (remove) ids.forEach((id) => set.delete(id))
|
||||||
else set.add(activePhotoId)
|
else ids.forEach((id) => set.add(id))
|
||||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||||
return { previous }
|
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
|
// 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
|
// but stay independent while the user is typing, so we don't fight focus or
|
||||||
// clobber edits with stale refetches.
|
// clobber edits with stale refetches.
|
||||||
@@ -228,8 +302,30 @@ export function RightSidebar() {
|
|||||||
updateMutation.mutate({ user_notes: next || null })
|
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) => {
|
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])
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
@@ -268,62 +364,70 @@ export function RightSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions — operate on the active photo */}
|
{/* Quick Actions */}
|
||||||
{photo && !multipleSelected && (
|
{photo && (
|
||||||
<div className="space-y-3 border-b border-border p-4">
|
<div className="space-y-3 border-b border-border p-4">
|
||||||
{/* Filename (editable, renames the file on disk) */}
|
{multipleSelected && (
|
||||||
<div>
|
<p className="text-xs text-text-muted">
|
||||||
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
Rating, color, and flag apply to all {selectedPhotos.length} selected.
|
||||||
<input
|
</p>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Title (editable) */}
|
{/* Per-photo fields — only meaningful for a single selection */}
|
||||||
<div>
|
{!multipleSelected && (
|
||||||
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
<>
|
||||||
<input
|
<div>
|
||||||
type="text"
|
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
||||||
value={titleDraft}
|
<input
|
||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
type="text"
|
||||||
onBlur={commitTitle}
|
value={filenameDraft}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => setFilenameDraft(e.target.value)}
|
||||||
if (e.key === 'Enter') {
|
onBlur={commitFilename}
|
||||||
e.currentTarget.blur()
|
onKeyDown={(e) => {
|
||||||
} else if (e.key === 'Escape') {
|
if (e.key === 'Enter') {
|
||||||
setTitleDraft(photo.user_title ?? '')
|
e.currentTarget.blur()
|
||||||
e.currentTarget.blur()
|
} else if (e.key === 'Escape') {
|
||||||
}
|
setFilenameDraft(photo.filename ?? '')
|
||||||
}}
|
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"
|
}}
|
||||||
/>
|
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>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notes (editable) */}
|
<div>
|
||||||
<div>
|
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
<input
|
||||||
<textarea
|
type="text"
|
||||||
value={notesDraft}
|
value={titleDraft}
|
||||||
onChange={(e) => setNotesDraft(e.target.value)}
|
onChange={(e) => setTitleDraft(e.target.value)}
|
||||||
onBlur={commitNotes}
|
onBlur={commitTitle}
|
||||||
placeholder="Add notes…"
|
onKeyDown={(e) => {
|
||||||
rows={3}
|
if (e.key === 'Enter') {
|
||||||
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"
|
e.currentTarget.blur()
|
||||||
/>
|
} else if (e.key === 'Escape') {
|
||||||
</div>
|
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 */}
|
{/* Rating */}
|
||||||
<div>
|
<div>
|
||||||
@@ -332,9 +436,7 @@ export function RightSidebar() {
|
|||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() =>
|
onClick={() => applyRating(rating === value ? 0 : value)}
|
||||||
updateMutation.mutate({ rating: rating === value ? 0 : value })
|
|
||||||
}
|
|
||||||
className="p-0.5"
|
className="p-0.5"
|
||||||
title={`Set rating to ${value}`}
|
title={`Set rating to ${value}`}
|
||||||
>
|
>
|
||||||
@@ -387,7 +489,17 @@ export function RightSidebar() {
|
|||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<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}
|
disabled={!activeHeap || heapMutation.isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
'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'}
|
{isInActiveHeap ? 'Picked' : 'Pick'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => applyDiscard(!isDiscarded)}
|
||||||
updateMutation.mutate({ is_discarded: !isDiscarded })
|
|
||||||
}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
isDiscarded
|
isDiscarded
|
||||||
@@ -520,6 +630,26 @@ export function RightSidebar() {
|
|||||||
<div className="text-xs text-text-muted">No GPS data</div>
|
<div className="text-xs text-text-muted">No GPS data</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</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 }) {
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import {
|
import { Search, X, ShoppingBasket } from 'lucide-react'
|
||||||
Search,
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
SlidersHorizontal,
|
|
||||||
X,
|
|
||||||
ShoppingBasket,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
|
||||||
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
|
||||||
import muliLogo from '../../assets/muli-logo.png'
|
import muliLogo from '../../assets/muli-logo.png'
|
||||||
|
|
||||||
@@ -17,10 +11,6 @@ export function TopBar() {
|
|||||||
// mirror so typing stays responsive while we debounce store updates.
|
// mirror so typing stays responsive while we debounce store updates.
|
||||||
const storeQ = useFilterStore((s) => s.q)
|
const storeQ = useFilterStore((s) => s.q)
|
||||||
const setStoreQ = useFilterStore((s) => s.setQ)
|
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)
|
const [searchQuery, setSearchQuery] = useState(storeQ)
|
||||||
|
|
||||||
@@ -101,24 +91,8 @@ export function TopBar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right — filter toggle */}
|
{/* Right — reserved for future actions */}
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 clsx from 'clsx'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
@@ -207,6 +207,14 @@ export function PhotoThumbnail({
|
|||||||
<ShoppingBasket className="h-3 w-3" />
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
</div>
|
</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 && (
|
{photo.is_discarded && (
|
||||||
<Trash2 className="h-4 w-4 text-reject" />
|
<Trash2 className="h-4 w-4 text-reject" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,17 +24,22 @@ type TimelineItem =
|
|||||||
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
|
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build groups by month label when sorted by a date field. For non-temporal
|
* Build the flat header|row item array the virtualizer renders.
|
||||||
* sorts (filename / file_size / rating) we return a single un-headered group.
|
*
|
||||||
|
* 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(
|
function buildItems(
|
||||||
photos: Photo[],
|
photos: Photo[],
|
||||||
columns: number,
|
columns: number,
|
||||||
sortBy: string
|
sortBy: string,
|
||||||
|
groupBy: 'date' | 'tag'
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
if (photos.length === 0) return []
|
if (photos.length === 0) return []
|
||||||
|
|
||||||
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
|
||||||
const items: TimelineItem[] = []
|
const items: TimelineItem[] = []
|
||||||
|
|
||||||
// Helper: split a flat array of cells into rows of `columns` cells.
|
// 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) {
|
if (!isDateSort) {
|
||||||
// No grouping — one row stream.
|
// No grouping — one row stream.
|
||||||
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
|
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
|
||||||
@@ -110,6 +167,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
selectedPhotos,
|
selectedPhotos,
|
||||||
|
activePhotoId,
|
||||||
lastSelectedIndex,
|
lastSelectedIndex,
|
||||||
rangeStartIndex,
|
rangeStartIndex,
|
||||||
selectPhoto,
|
selectPhoto,
|
||||||
@@ -119,6 +177,7 @@ export function Timeline() {
|
|||||||
} = usePhotoStore()
|
} = usePhotoStore()
|
||||||
|
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
|
const groupBy = useFilterStore((s) => s.groupBy)
|
||||||
|
|
||||||
// Calculate number of columns based on container width.
|
// Calculate number of columns based on container width.
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
@@ -138,11 +197,12 @@ export function Timeline() {
|
|||||||
// subscribing to the same query.
|
// subscribing to the same query.
|
||||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
// Build the flat virtualizer items: a mix of date-group headers and rows
|
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||||
// of photos. Headers only appear when sorted by a date field.
|
// photos. Date headers appear when sorted by a date field; tag headers
|
||||||
|
// appear when groupBy === 'tag' (overrides date grouping).
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => buildItems(photos, columns, sortBy),
|
() => buildItems(photos, columns, sortBy, groupBy),
|
||||||
[photos, columns, sortBy]
|
[photos, columns, sortBy, groupBy]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pre-computed offset of every header in the virtualizer's coordinate
|
// Pre-computed offset of every header in the virtualizer's coordinate
|
||||||
@@ -223,50 +283,102 @@ export function Timeline() {
|
|||||||
return () => window.removeEventListener('resize', measureWidth)
|
return () => window.removeEventListener('resize', measureWidth)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle keyboard shortcuts for photo navigation. Operates on the flat
|
// Photo rows in visual order — drops the header items so navigation
|
||||||
// photos array, so it ignores grouping.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (photos.length === 0) return
|
if (photoRows.length === 0) return
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||||
return
|
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) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex > columns - 1) {
|
move(-1, 0)
|
||||||
const newIndex = currentIndex - columns
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex < photos.length - columns) {
|
move(1, 0)
|
||||||
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex > 0) {
|
move(0, -1)
|
||||||
const newIndex = currentIndex - 1
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex < photos.length - 1) {
|
move(0, 1)
|
||||||
const newIndex = currentIndex + 1
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'a':
|
case 'a':
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -288,7 +400,7 @@ export function Timeline() {
|
|||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [photos, selectedPhotos, lastSelectedIndex, columns])
|
}, [photoRows, photos, selectedPhotos, activePhotoId])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ function parseUrl(): Partial<FilterState> {
|
|||||||
const folderId = sp.get('folder_id')
|
const folderId = sp.get('folder_id')
|
||||||
if (folderId) out.folderId = folderId
|
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')
|
const sortBy = sp.get('sort')
|
||||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||||
out.sortBy = sortBy as SortField
|
out.sortBy = sortBy as SortField
|
||||||
@@ -84,7 +98,7 @@ function parseUrl(): Partial<FilterState> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeUrl(f: FilterState) {
|
function writeUrl(f: FilterState & { currentSection?: string }) {
|
||||||
const sp = new URLSearchParams()
|
const sp = new URLSearchParams()
|
||||||
if (f.q.trim()) sp.set('q', f.q.trim())
|
if (f.q.trim()) sp.set('q', f.q.trim())
|
||||||
if (f.dateFrom) sp.set('date_from', f.dateFrom)
|
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.flag !== 'any') sp.set('flag', f.flag)
|
||||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||||
if (f.folderId) sp.set('folder_id', f.folderId)
|
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.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
||||||
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
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 { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePhotoStore } from '../store/photoStore'
|
import { usePhotoStore } from '../store/photoStore'
|
||||||
import { useFilterStore } from '../store/filterStore'
|
|
||||||
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
||||||
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
||||||
import { toast } from '../components/ToastContainer'
|
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 updateActive = (data: PhotoUpdate) => {
|
||||||
const id = usePhotoStore.getState().activePhotoId
|
const ids = cullTargets()
|
||||||
if (!id) return
|
if (ids.length === 0) return
|
||||||
updateMutation.mutate({ id, data })
|
|
||||||
|
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
|
// 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('tab', onToggleLeftSidebar, HK_OPTS)
|
||||||
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
||||||
|
|
||||||
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
|
// Search focus (/ or Cmd/Ctrl+F).
|
||||||
useHotkeys('\\', () => useFilterStore.getState().toggleFilterBar(), HK_OPTS)
|
|
||||||
|
|
||||||
const focusSearch = () => {
|
const focusSearch = () => {
|
||||||
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
||||||
el?.focus()
|
el?.focus()
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export function usePhotosQuery() {
|
|||||||
const flag = useFilterStore((s) => s.flag)
|
const flag = useFilterStore((s) => s.flag)
|
||||||
const heapId = useFilterStore((s) => s.heapId)
|
const heapId = useFilterStore((s) => s.heapId)
|
||||||
const folderId = useFilterStore((s) => s.folderId)
|
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 sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||||
|
|
||||||
@@ -35,10 +38,13 @@ export function usePhotosQuery() {
|
|||||||
flag,
|
flag,
|
||||||
heapId,
|
heapId,
|
||||||
folderId,
|
folderId,
|
||||||
|
tagIds,
|
||||||
|
duplicates,
|
||||||
|
groupBy,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
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({
|
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
|
// 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 = {
|
export const sourceFolders = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
const response = await api.get('/folders')
|
const response = await api.get('/folders')
|
||||||
return response.data
|
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) => {
|
scan: async (folderId: string) => {
|
||||||
const response = await api.post(`/folders/${folderId}/scan`)
|
const response = await api.post(`/folders/${folderId}/scan`)
|
||||||
return response.data
|
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
|
// Photos API
|
||||||
@@ -74,6 +96,26 @@ export const photos = {
|
|||||||
return response.data
|
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
|
/** Move photos into a target folder (or source root). Returns
|
||||||
* { moved, errors[] }. */
|
* { moved, errors[] }. */
|
||||||
move: async (photoIds: string[], targetId: string) => {
|
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 }> }
|
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') => {
|
getThumbnailUrl: (photoId: string, size: 'small' | 'medium' | 'large' = 'medium') => {
|
||||||
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
|
return `${API_BASE_URL}/photos/${photoId}/thumb/${size}`
|
||||||
},
|
},
|
||||||
@@ -170,35 +222,69 @@ export const heaps = {
|
|||||||
})
|
})
|
||||||
return response.data
|
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
|
// Tags API
|
||||||
|
export interface Tag {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string | null
|
||||||
|
photo_count: number
|
||||||
|
}
|
||||||
|
|
||||||
export const tags = {
|
export const tags = {
|
||||||
list: async () => {
|
list: async (): Promise<Tag[]> => {
|
||||||
const response = await api.get('/tags')
|
const response = await api.get('/tags')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (name: string, color?: string) => {
|
create: async (name: string, color?: string): Promise<Tag> => {
|
||||||
const response = await api.post('/tags', {
|
const response = await api.post('/tags', { name, color })
|
||||||
name,
|
|
||||||
color,
|
|
||||||
})
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (tagId: string, data: {
|
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
|
||||||
name?: string
|
|
||||||
color?: string
|
|
||||||
}) => {
|
|
||||||
const response = await api.patch(`/tags/${tagId}`, data)
|
const response = await api.patch(`/tags/${tagId}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (tagId: string) => {
|
delete: async (tagId: string): Promise<void> => {
|
||||||
const response = await api.delete(`/tags/${tagId}`)
|
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
|
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
|
// Discard API
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type SortField =
|
|||||||
| 'file_size'
|
| 'file_size'
|
||||||
| 'rating'
|
| 'rating'
|
||||||
export type SortOrder = 'asc' | 'desc'
|
export type SortOrder = 'asc' | 'desc'
|
||||||
|
export type GroupBy = 'date' | 'tag'
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
q: string
|
q: string
|
||||||
@@ -24,12 +25,33 @@ export interface FilterState {
|
|||||||
heapId: string | null
|
heapId: string | null
|
||||||
/** When set, restrict to photos in this folder. */
|
/** When set, restrict to photos in this folder. */
|
||||||
folderId: string | null
|
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
|
sortBy: SortField
|
||||||
sortOrder: SortOrder
|
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 {
|
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
|
setQ: (q: string) => void
|
||||||
setDateFrom: (date: string | null) => void
|
setDateFrom: (date: string | null) => void
|
||||||
@@ -40,14 +62,27 @@ interface FilterStore extends FilterState {
|
|||||||
setFlag: (flag: FlagFilter) => void
|
setFlag: (flag: FlagFilter) => void
|
||||||
setHeapId: (id: string | null) => void
|
setHeapId: (id: string | null) => void
|
||||||
setFolderId: (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
|
setSortBy: (field: SortField) => void
|
||||||
setSortOrder: (order: SortOrder) => void
|
setSortOrder: (order: SortOrder) => void
|
||||||
toggleSortOrder: () => void
|
toggleSortOrder: () => void
|
||||||
|
|
||||||
setFilterBarOpen: (open: boolean) => void
|
/** Navigate to a section. Saves the current section's filter state into
|
||||||
toggleFilterBar: () => void
|
* 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
|
clearAll: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,13 +96,40 @@ export const INITIAL_FILTERS: FilterState = {
|
|||||||
flag: 'any',
|
flag: 'any',
|
||||||
heapId: null,
|
heapId: null,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
|
tagIds: [],
|
||||||
|
duplicates: false,
|
||||||
|
groupBy: 'date',
|
||||||
sortBy: 'taken_at',
|
sortBy: 'taken_at',
|
||||||
sortOrder: 'desc',
|
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) => ({
|
export const useFilterStore = create<FilterStore>((set) => ({
|
||||||
...INITIAL_FILTERS,
|
...INITIAL_FILTERS,
|
||||||
filterBarOpen: false,
|
currentSection: ALL_PHOTOS_SECTION,
|
||||||
|
sectionFilters: {},
|
||||||
|
sectionPresets: { [ALL_PHOTOS_SECTION]: {} },
|
||||||
|
|
||||||
setQ: (q) => set({ q }),
|
setQ: (q) => set({ q }),
|
||||||
setDateFrom: (dateFrom) => set({ dateFrom }),
|
setDateFrom: (dateFrom) => set({ dateFrom }),
|
||||||
@@ -83,16 +145,54 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
|||||||
setFlag: (flag) => set({ flag }),
|
setFlag: (flag) => set({ flag }),
|
||||||
setHeapId: (heapId) => set({ heapId }),
|
setHeapId: (heapId) => set({ heapId }),
|
||||||
setFolderId: (folderId) => set({ folderId }),
|
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 }),
|
setSortBy: (sortBy) => set({ sortBy }),
|
||||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
setSortOrder: (sortOrder) => set({ sortOrder }),
|
||||||
toggleSortOrder: () =>
|
toggleSortOrder: () =>
|
||||||
set((s) => ({ sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' })),
|
set((s) => ({ sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' })),
|
||||||
|
|
||||||
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
|
navigateToSection: (sectionId, presetOverrides = {}) =>
|
||||||
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })),
|
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),
|
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.
|
/** 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.flag === 'discarded') params.is_discarded = 'true'
|
||||||
if (f.heapId) params.heap_id = f.heapId
|
if (f.heapId) params.heap_id = f.heapId
|
||||||
if (f.folderId) params.folder_id = f.folderId
|
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.sort = f.sortBy
|
||||||
params.order = f.sortOrder
|
params.order = f.sortOrder
|
||||||
return params
|
return params
|
||||||
@@ -124,6 +226,8 @@ export function hasActiveFilters(f: FilterState): boolean {
|
|||||||
f.colorLabel !== null ||
|
f.colorLabel !== null ||
|
||||||
f.flag !== 'any' ||
|
f.flag !== 'any' ||
|
||||||
f.heapId !== null ||
|
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 {
|
export interface Photo {
|
||||||
id: string
|
id: string
|
||||||
filepath: string
|
filepath: string
|
||||||
@@ -8,8 +14,10 @@ export interface Photo {
|
|||||||
taken_at: string | null
|
taken_at: string | null
|
||||||
rating: number
|
rating: number
|
||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
|
is_duplicate: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
thumb_medium?: string
|
thumb_medium?: string
|
||||||
thumb_large?: string
|
thumb_large?: string
|
||||||
|
tags?: PhotoTagSummary[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user