Compare commits
4 Commits
7dcfa8f30d
...
485b60ff20
| Author | SHA1 | Date | |
|---|---|---|---|
| 485b60ff20 | |||
| 9729391dcc | |||
| 7c003bc92e | |||
| cf7c72d437 |
25
README.md
25
README.md
@@ -57,6 +57,31 @@ docker-compose up -d
|
|||||||
|
|
||||||
4. Access the application at `http://localhost:3000`
|
4. Access the application at `http://localhost:3000`
|
||||||
|
|
||||||
|
### Photo directory mounts and permissions
|
||||||
|
|
||||||
|
Mulita is a Lightroom-style manager — file operations (rename, move,
|
||||||
|
discard, empty discard pile) need to mutate the filesystem under your
|
||||||
|
photo mounts. The default `docker-compose.yml` mounts:
|
||||||
|
|
||||||
|
- `${PHOTO_DIRS}` → `/photos` (read-write)
|
||||||
|
- `~/Pictures` → `/host/Pictures` (**read-write** by default so file
|
||||||
|
operations work on your system Pictures folder out of the box)
|
||||||
|
|
||||||
|
If you want a strict read-only library — for example pointing at a
|
||||||
|
network share or your authoritative archive — change `:rw` to `:ro`
|
||||||
|
on the mount in `docker-compose.yml`. Mulita will keep working for
|
||||||
|
browsing, rating, color labels, picks, heaps, and the discard flag,
|
||||||
|
but the following endpoints will return an error from the OS
|
||||||
|
(`EROFS` / `Read-only file system`):
|
||||||
|
|
||||||
|
- `PATCH /photos/{id}` with a new `filename` (rename)
|
||||||
|
- `DELETE /discard/empty` (file unlinks)
|
||||||
|
- Future move / copy endpoints
|
||||||
|
|
||||||
|
**Heads up**: with `:rw`, Mulita has full write access to whatever
|
||||||
|
host directory you mount under `~/Pictures`. Treat the same way you
|
||||||
|
would Lightroom's catalog folder.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The application consists of 5 Docker services:
|
The application consists of 5 Docker services:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.config import settings
|
|||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.routers import photos, folders, heaps, tags, discard, library
|
from app.routers import photos, folders, heaps, tags, discard, library
|
||||||
from app.services.scanner import start_initial_scan
|
from app.services.scanner import start_initial_scan
|
||||||
|
from app.services.cleanup import cleanup_data_integrity
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -25,17 +26,24 @@ logger = logging.getLogger(__name__)
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Manage application lifecycle"""
|
"""Manage application lifecycle"""
|
||||||
logger.info("Starting Mulita application...")
|
logger.info("Starting Mulita application...")
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
|
# One-shot cleanup of duplicate source_roots / folders left over from
|
||||||
|
# earlier scanner versions that didn't normalize paths. Idempotent.
|
||||||
|
try:
|
||||||
|
await cleanup_data_integrity()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Startup cleanup failed (continuing): {e}")
|
||||||
|
|
||||||
# Start initial scan if configured
|
# Start initial scan if configured
|
||||||
if settings.scanner.initial_scan_on_start:
|
if settings.scanner.initial_scan_on_start:
|
||||||
logger.info("Starting initial library scan...")
|
logger.info("Starting initial library scan...")
|
||||||
await start_initial_scan()
|
await start_initial_scan()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
logger.info("Shutting down Mulita application...")
|
logger.info("Shutting down Mulita application...")
|
||||||
|
|
||||||
# Create FastAPI app
|
# Create FastAPI app
|
||||||
|
|||||||
@@ -410,20 +410,53 @@ async def update_photo(
|
|||||||
update: PhotoUpdate,
|
update: PhotoUpdate,
|
||||||
db: AsyncSession = Depends(get_db)
|
db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Update photo metadata"""
|
"""Update photo metadata. If `filename` is included, also rename the
|
||||||
|
file on disk in its current directory (no cross-folder moves through
|
||||||
|
this endpoint).
|
||||||
|
"""
|
||||||
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")
|
||||||
|
|
||||||
# Apply updates
|
|
||||||
update_data = update.dict(exclude_unset=True)
|
update_data = update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Filename rename: validate, rename on disk, then update both filename
|
||||||
|
# and filepath atomically. Done before any other field changes so a
|
||||||
|
# filesystem failure leaves the rest of the row untouched.
|
||||||
|
if 'filename' in update_data:
|
||||||
|
new_name = (update_data.pop('filename') or '').strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(status_code=400, detail="Filename cannot be empty")
|
||||||
|
# Reject path separators and parent traversal — same-directory only.
|
||||||
|
if '/' in new_name or '\\' in new_name or new_name in ('.', '..'):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
if new_name != photo.filename:
|
||||||
|
current_dir = os.path.dirname(photo.filepath)
|
||||||
|
new_path = os.path.join(current_dir, new_name)
|
||||||
|
|
||||||
|
if not os.path.exists(photo.filepath):
|
||||||
|
raise HTTPException(status_code=404, detail="Source file missing on disk")
|
||||||
|
if os.path.exists(new_path):
|
||||||
|
raise HTTPException(status_code=409, detail="A file with that name already exists")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(photo.filepath, new_path)
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Failed to rename {photo.filepath} -> {new_path}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Rename failed: {e}")
|
||||||
|
|
||||||
|
photo.filename = new_name
|
||||||
|
photo.filepath = new_path
|
||||||
|
|
||||||
|
# Apply remaining updates
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(photo, field, value)
|
setattr(photo, field, value)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(photo)
|
await db.refresh(photo)
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class PhotoResponse(PhotoBase):
|
|||||||
|
|
||||||
class PhotoUpdate(BaseModel):
|
class PhotoUpdate(BaseModel):
|
||||||
"""Photo update schema"""
|
"""Photo update schema"""
|
||||||
|
filename: Optional[str] = None
|
||||||
user_title: Optional[str] = None
|
user_title: Optional[str] = None
|
||||||
user_notes: Optional[str] = None
|
user_notes: Optional[str] = None
|
||||||
rating: Optional[int] = Field(None, ge=0, le=5)
|
rating: Optional[int] = Field(None, ge=0, le=5)
|
||||||
|
|||||||
141
backend/app/services/cleanup.py
Normal file
141
backend/app/services/cleanup.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
One-shot data integrity cleanup for source_roots / folders / photos.
|
||||||
|
|
||||||
|
Earlier versions of the scanner stored paths verbatim, so trailing slashes
|
||||||
|
and redundant separators produced duplicate SourceRoot and Folder rows for
|
||||||
|
the same physical directory. The watcher also auto-created source roots
|
||||||
|
when fired with a parent dir. This module merges the duplicates and
|
||||||
|
re-points photos to the canonical folder so the data lines up with the
|
||||||
|
post-fix scanner.
|
||||||
|
|
||||||
|
Idempotent: safe to run on every backend startup.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select, update, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models import Photo, Folder, SourceRoot
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(path: str) -> str:
|
||||||
|
return os.path.normpath(path)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dedupe_source_roots(session: AsyncSession) -> int:
|
||||||
|
"""Group source roots by normalized path and merge duplicates. Returns
|
||||||
|
the number of rows deleted."""
|
||||||
|
result = await session.execute(select(SourceRoot))
|
||||||
|
rows = result.scalars().all()
|
||||||
|
|
||||||
|
groups: dict[str, list[SourceRoot]] = {}
|
||||||
|
for sr in rows:
|
||||||
|
norm = _normalize_path(sr.path)
|
||||||
|
groups.setdefault(norm, []).append(sr)
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for norm, srs in groups.items():
|
||||||
|
if len(srs) == 1:
|
||||||
|
# Make sure the canonical row's path is normalized too.
|
||||||
|
if srs[0].path != norm:
|
||||||
|
srs[0].path = norm
|
||||||
|
continue
|
||||||
|
# Pick the canonical row: prefer one with a non-empty name and the
|
||||||
|
# earliest added_at (most likely the original).
|
||||||
|
canonical = sorted(
|
||||||
|
srs,
|
||||||
|
key=lambda s: (not bool(s.name), s.added_at or datetime.max),
|
||||||
|
)[0]
|
||||||
|
canonical.path = norm
|
||||||
|
for sr in srs:
|
||||||
|
if sr.id == canonical.id:
|
||||||
|
continue
|
||||||
|
# Re-point folders that referenced the duplicate root.
|
||||||
|
await session.execute(
|
||||||
|
update(Folder)
|
||||||
|
.where(Folder.source_root_id == sr.id)
|
||||||
|
.values(source_root_id=canonical.id)
|
||||||
|
)
|
||||||
|
await session.delete(sr)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def _dedupe_folders(session: AsyncSession) -> int:
|
||||||
|
"""Group folders by normalized path and merge duplicates. Returns the
|
||||||
|
number of rows deleted."""
|
||||||
|
result = await session.execute(select(Folder))
|
||||||
|
rows = result.scalars().all()
|
||||||
|
|
||||||
|
groups: dict[str, list[Folder]] = {}
|
||||||
|
for f in rows:
|
||||||
|
norm = _normalize_path(f.path)
|
||||||
|
groups.setdefault(norm, []).append(f)
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
for norm, folders in groups.items():
|
||||||
|
if len(folders) == 1:
|
||||||
|
if folders[0].path != norm:
|
||||||
|
folders[0].path = norm
|
||||||
|
continue
|
||||||
|
# Canonical = the one with the most photos already attached, then
|
||||||
|
# the lowest-id (deterministic tiebreaker).
|
||||||
|
canonical = sorted(
|
||||||
|
folders,
|
||||||
|
key=lambda f: (-(f.photo_count or 0), f.id),
|
||||||
|
)[0]
|
||||||
|
canonical.path = norm
|
||||||
|
for f in folders:
|
||||||
|
if f.id == canonical.id:
|
||||||
|
continue
|
||||||
|
# Re-point photos to the canonical folder.
|
||||||
|
await session.execute(
|
||||||
|
update(Photo)
|
||||||
|
.where(Photo.folder_id == f.id)
|
||||||
|
.values(folder_id=canonical.id)
|
||||||
|
)
|
||||||
|
await session.delete(f)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def _recompute_folder_counts(session: AsyncSession) -> None:
|
||||||
|
"""Set folder.photo_count to the actual non-discarded photo count."""
|
||||||
|
result = await session.execute(select(Folder))
|
||||||
|
folders = result.scalars().all()
|
||||||
|
for f in folders:
|
||||||
|
count_result = await session.execute(
|
||||||
|
select(func.count(Photo.id)).where(
|
||||||
|
Photo.folder_id == f.id,
|
||||||
|
Photo.is_discarded == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
f.photo_count = int(count_result.scalar() or 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_data_integrity() -> dict:
|
||||||
|
"""Top-level entry point. Runs the dedupe + count refresh in a single
|
||||||
|
transaction. Returns a small summary dict for logging."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
sr_deleted = await _dedupe_source_roots(session)
|
||||||
|
f_deleted = await _dedupe_folders(session)
|
||||||
|
await _recompute_folder_counts(session)
|
||||||
|
await session.commit()
|
||||||
|
summary = {
|
||||||
|
"source_roots_merged": sr_deleted,
|
||||||
|
"folders_merged": f_deleted,
|
||||||
|
}
|
||||||
|
if sr_deleted or f_deleted:
|
||||||
|
logger.info(f"Cleanup merged duplicates: {summary}")
|
||||||
|
return summary
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Cleanup failed: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -181,39 +181,49 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _normalize_path(path: str) -> str:
|
||||||
|
"""Canonicalise a filesystem path so we don't get duplicate DB rows for
|
||||||
|
the same physical directory due to trailing slashes, redundant separators,
|
||||||
|
or `.` segments. Symlinks are NOT resolved (we want to keep mount paths
|
||||||
|
intact for cross-machine portability)."""
|
||||||
|
return os.path.normpath(path)
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceRoot:
|
async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceRoot:
|
||||||
"""Get or create a source root entry"""
|
"""Get or create a source root entry, matching by normalized path."""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
norm = _normalize_path(path)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SourceRoot).where(SourceRoot.path == path)
|
select(SourceRoot).where(SourceRoot.path == norm)
|
||||||
)
|
)
|
||||||
source_root = result.scalar_one_or_none()
|
source_root = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not source_root:
|
if not source_root:
|
||||||
source_root = SourceRoot(
|
source_root = SourceRoot(
|
||||||
name=Path(path).name,
|
name=Path(norm).name,
|
||||||
path=path
|
path=norm,
|
||||||
)
|
)
|
||||||
session.add(source_root)
|
session.add(source_root)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
return source_root
|
return source_root
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder:
|
async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder:
|
||||||
"""Get or create a folder entry"""
|
"""Get or create a folder entry, matching by normalized path."""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
norm = _normalize_path(path)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Folder).where(Folder.path == path)
|
select(Folder).where(Folder.path == norm)
|
||||||
)
|
)
|
||||||
folder = result.scalar_one_or_none()
|
folder = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not folder:
|
if not folder:
|
||||||
parent_path = str(Path(path).parent)
|
parent_path = _normalize_path(str(Path(norm).parent))
|
||||||
parent = None
|
|
||||||
|
if parent_path != norm: # Not the filesystem root
|
||||||
if parent_path != path: # Not root folder
|
|
||||||
parent_result = await session.execute(
|
parent_result = await session.execute(
|
||||||
select(Folder).where(Folder.path == parent_path)
|
select(Folder).where(Folder.path == parent_path)
|
||||||
)
|
)
|
||||||
@@ -226,16 +236,16 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
|
|||||||
parent_id = parent.id
|
parent_id = parent.id
|
||||||
else:
|
else:
|
||||||
parent_id = None
|
parent_id = None
|
||||||
|
|
||||||
folder = Folder(
|
folder = Folder(
|
||||||
name=Path(path).name,
|
name=Path(norm).name,
|
||||||
path=path,
|
path=norm,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
source_root_id=source_root_id
|
source_root_id=source_root_id,
|
||||||
)
|
)
|
||||||
session.add(folder)
|
session.add(folder)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
return folder
|
return folder
|
||||||
|
|
||||||
@shared_task(name='scan_all_source_roots')
|
@shared_task(name='scan_all_source_roots')
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
- ~/Pictures:/host/Pictures:ro
|
# NOTE: read-write — file operations (rename, move, discard,
|
||||||
|
# empty discard pile) need to mutate the filesystem. Flip to :ro
|
||||||
|
# if you want a strict read-only library; the rename / move /
|
||||||
|
# delete endpoints will then return EROFS.
|
||||||
|
- ~/Pictures:/host/Pictures:rw
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- proxies_data:/data/proxies
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
@@ -50,7 +54,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./mulita.yml:/app/config/mulita.yml:ro
|
- ./mulita.yml:/app/config/mulita.yml:ro
|
||||||
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
- ${PHOTO_DIRS:-./photos}:/photos:rw
|
||||||
- ~/Pictures:/host/Pictures:ro
|
# See backend service for the rationale on :rw.
|
||||||
|
- ~/Pictures:/host/Pictures:rw
|
||||||
- thumbs_data:/data/thumbs
|
- thumbs_data:/data/thumbs
|
||||||
- proxies_data:/data/proxies
|
- proxies_data:/data/proxies
|
||||||
- db_data:/data/db
|
- db_data:/data/db
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
|||||||
import { heaps as heapsApi } from '../../services/api'
|
import { heaps as heapsApi } 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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -33,6 +34,9 @@ export function HeapsPanel() {
|
|||||||
const [expanded, setExpanded] = useState(true)
|
const [expanded, setExpanded] = useState(true)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
|
// Which heap row is currently being hovered with a drag — used to render
|
||||||
|
// the drop highlight ring. Only one heap can be the target at a time.
|
||||||
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
@@ -71,6 +75,47 @@ export function HeapsPanel() {
|
|||||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Drop handler: add the dragged photos to the target heap. Optimistically
|
||||||
|
// updates the membership cache so the basket affordance flips immediately,
|
||||||
|
// mirroring the keyboard P-toggle pattern.
|
||||||
|
const dropMutation = useMutation({
|
||||||
|
mutationFn: ({ heapId, photoIds }: { heapId: string; photoIds: string[] }) =>
|
||||||
|
heapsApi.addPhotos(heapId, photoIds),
|
||||||
|
onMutate: ({ heapId, photoIds }) => {
|
||||||
|
const key = ['heap-photo-ids', heapId] as const
|
||||||
|
const previous = queryClient.getQueryData<string[]>(key)
|
||||||
|
const set = new Set(previous ?? [])
|
||||||
|
photoIds.forEach((id) => set.add(id))
|
||||||
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (e: any, vars, ctx) => {
|
||||||
|
if (ctx?.previous) {
|
||||||
|
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
|
||||||
|
}
|
||||||
|
toast.error('Failed to add to heap', e.message || 'Unknown error')
|
||||||
|
},
|
||||||
|
onSuccess: (data, vars) => {
|
||||||
|
const heap = heaps.find((h) => h.id === vars.heapId)
|
||||||
|
const heapName = heap?.name ?? 'heap'
|
||||||
|
const added = data?.added ?? 0
|
||||||
|
const already = data?.already_present ?? 0
|
||||||
|
if (added > 0) {
|
||||||
|
toast.success(
|
||||||
|
`Added to ${heapName}`,
|
||||||
|
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
||||||
|
)
|
||||||
|
} else if (already > 0) {
|
||||||
|
toast.info(`Already in ${heapName}`, `${already} photo${already > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: (_d, _e, vars) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
const name = newName.trim()
|
const name = newName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
@@ -151,15 +196,45 @@ export function HeapsPanel() {
|
|||||||
{heaps.map((heap) => {
|
{heaps.map((heap) => {
|
||||||
const isFiltered = filterHeapId === heap.id
|
const isFiltered = filterHeapId === heap.id
|
||||||
const isActive = heap.is_active
|
const isActive = heap.is_active
|
||||||
|
const isDropTarget = dropTargetId === heap.id
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={heap.id}
|
key={heap.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-[13px]',
|
||||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2'
|
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||||
|
isDropTarget && 'ring-2 ring-primary bg-primary/10'
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: '32px' }}
|
style={{ paddingLeft: '32px' }}
|
||||||
onClick={() => setFilterHeapId(heap.id)}
|
onClick={() => setFilterHeapId(heap.id)}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
if (dropTargetId !== heap.id) setDropTargetId(heap.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
// Only clear if we're actually leaving this row, not just
|
||||||
|
// moving over a child element.
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
if (dropTargetId === heap.id) setDropTargetId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDropTargetId(null)
|
||||||
|
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
||||||
|
if (!raw) return
|
||||||
|
try {
|
||||||
|
const ids = JSON.parse(raw) as string[]
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
dropMutation.mutate({ heapId: heap.id, photoIds: ids })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Bad payload — ignore.
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ShoppingBasket
|
<ShoppingBasket
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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 { toast } from '../ToastContainer'
|
||||||
|
|
||||||
interface PhotoDetails {
|
interface PhotoDetails {
|
||||||
id: string
|
id: string
|
||||||
@@ -122,6 +123,7 @@ export function RightSidebar() {
|
|||||||
// change too.
|
// change too.
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
|
filename?: string
|
||||||
rating?: number
|
rating?: number
|
||||||
is_discarded?: boolean
|
is_discarded?: boolean
|
||||||
user_title?: string | null
|
user_title?: string | null
|
||||||
@@ -175,13 +177,42 @@ export function RightSidebar() {
|
|||||||
// 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.
|
||||||
|
const [filenameDraft, setFilenameDraft] = useState('')
|
||||||
const [titleDraft, setTitleDraft] = useState('')
|
const [titleDraft, setTitleDraft] = useState('')
|
||||||
const [notesDraft, setNotesDraft] = useState('')
|
const [notesDraft, setNotesDraft] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setFilenameDraft(photo?.filename ?? '')
|
||||||
setTitleDraft(photo?.user_title ?? '')
|
setTitleDraft(photo?.user_title ?? '')
|
||||||
setNotesDraft(photo?.user_notes ?? '')
|
setNotesDraft(photo?.user_notes ?? '')
|
||||||
}, [photo?.id, photo?.user_title, photo?.user_notes])
|
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
|
||||||
|
|
||||||
|
const commitFilename = () => {
|
||||||
|
const next = filenameDraft.trim()
|
||||||
|
const current = photo?.filename ?? ''
|
||||||
|
if (!next || next === current) {
|
||||||
|
// Reset draft if user cleared it; we never send an empty filename.
|
||||||
|
setFilenameDraft(current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
|
||||||
|
toast.error('Invalid filename', 'No path separators allowed')
|
||||||
|
setFilenameDraft(current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ filename: next },
|
||||||
|
{
|
||||||
|
onError: (e: any) => {
|
||||||
|
toast.error(
|
||||||
|
'Rename failed',
|
||||||
|
e?.response?.data?.detail || e.message || 'Unknown error'
|
||||||
|
)
|
||||||
|
setFilenameDraft(current)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const commitTitle = () => {
|
const commitTitle = () => {
|
||||||
const next = titleDraft.trim()
|
const next = titleDraft.trim()
|
||||||
@@ -240,6 +271,26 @@ export function RightSidebar() {
|
|||||||
{/* Quick Actions — operate on the active photo */}
|
{/* Quick Actions — operate on the active photo */}
|
||||||
{photo && !multipleSelected && (
|
{photo && !multipleSelected && (
|
||||||
<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) */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filenameDraft}
|
||||||
|
onChange={(e) => setFilenameDraft(e.target.value)}
|
||||||
|
onBlur={commitFilename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setFilenameDraft(photo.filename ?? '')
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Title (editable) */}
|
{/* Title (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">Title</label>
|
||||||
@@ -385,7 +436,6 @@ export function RightSidebar() {
|
|||||||
onToggle={() => toggleSection('basic')}
|
onToggle={() => toggleSection('basic')}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
<Field label="Filename" value={photo.filename} />
|
|
||||||
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
||||||
<Field
|
<Field
|
||||||
label="Dimensions"
|
label="Dimensions"
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { Star, ShoppingBasket, Trash2, RefreshCw, Check } 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'
|
||||||
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
|
||||||
|
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
|
||||||
|
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
|
||||||
|
|
||||||
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
// Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so
|
||||||
// first hit often 404s. Try a few times with backoff before giving up.
|
// first hit often 404s. Try a few times with backoff before giving up.
|
||||||
@@ -94,6 +98,22 @@ export function PhotoThumbnail({
|
|||||||
return () => clearRetryTimer()
|
return () => clearRetryTimer()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Build the drag payload at fire time so multi-selection drags carry the
|
||||||
|
// current selection. If the dragged photo isn't part of the selection,
|
||||||
|
// drag just that one photo (matches Finder semantics).
|
||||||
|
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
const state = usePhotoStore.getState()
|
||||||
|
const ids =
|
||||||
|
state.selectedPhotos.includes(photo.id) && state.selectedPhotos.length > 0
|
||||||
|
? state.selectedPhotos
|
||||||
|
: [photo.id]
|
||||||
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
|
e.dataTransfer.setData(PHOTO_DRAG_MIME, JSON.stringify(ids))
|
||||||
|
// A plain text fallback so the OS shows something sensible if the user
|
||||||
|
// drops outside the app.
|
||||||
|
e.dataTransfer.setData('text/plain', `${ids.length} photo${ids.length > 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -108,7 +128,9 @@ export function PhotoThumbnail({
|
|||||||
}}
|
}}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add"
|
draggable
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add"
|
||||||
>
|
>
|
||||||
{/* Thumbnail Image */}
|
{/* Thumbnail Image */}
|
||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const photos = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
update: async (photoId: string, data: {
|
update: async (photoId: string, data: {
|
||||||
|
filename?: string
|
||||||
rating?: number
|
rating?: number
|
||||||
user_title?: string | null
|
user_title?: string | null
|
||||||
user_notes?: string | null
|
user_notes?: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user