4 Commits

Author SHA1 Message Date
485b60ff20 feat: drag photos onto a heap row to add them
Lightroom-style direct manipulation: pick up a photo (or a multi-
selection) and drop it on a heap to add it. Complements the P
shortcut without replacing it.

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:59:29 +02:00
9729391dcc chore(docker): mount ~/Pictures read-write so file ops work; document
The first phase-11 file op (inline rename) returns EROFS today
because docker-compose mounts ~/Pictures read-only by default.
Lightroom-style file operations (rename, move, discard-pile empty)
all need to mutate the filesystem, so the right default is :rw.

Flips both the backend and worker mounts to :rw with an inline
comment explaining the trade-off, and adds a "Photo directory
mounts and permissions" section to the README that:
- States the default is now :rw
- Explains exactly which endpoints fail under :ro (rename, empty
  discard pile, future move/copy)
- Notes the implication: Mulita has full write access to whatever
  host directory ends up at /host/Pictures, same trust model as
  Lightroom's catalog folder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:56:27 +02:00
7c003bc92e feat: editable filename in RightSidebar (renames file on disk)
The first phase-11 file operation. Lightroom-style inline rename
of a single photo, in place, in its current directory.

Backend (PATCH /photos/{id})
- PhotoUpdate schema accepts an optional `filename`.
- When set, the handler validates: non-empty, no path separators,
  no `..`/`.`, target name doesn't already exist in the directory,
  source file exists on disk.
- os.renames the file inside its current directory, then updates
  photo.filename + photo.filepath atomically. The DB only changes
  after a successful rename — a filesystem failure leaves the
  rest of the row untouched.
- Other PhotoUpdate fields still apply afterwards in the same
  request.

Frontend (RightSidebar)
- Filename is now an editable monospace input above the Title
  input. Same draft + commit pattern as title/notes (local draft,
  resync on photo.id change, on-blur or Enter commits).
- Esc reverts to the server value.
- Client-side validation mirrors the backend (rejects path
  separators and dot-segments) and shows a toast on backend
  errors with the FastAPI detail message, then rolls the draft
  back so the input matches the still-on-disk filename.
- Removed the old read-only Filename Field from the Basic Info
  section to avoid showing the same value twice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:48:31 +02:00
cf7c72d437 fix: deduplicate scanner-created source_roots and folders
Two related fixes:

1. Prevention — scanner now normalizes paths before lookup/insert
   in get_or_create_source_root and get_or_create_folder. Trailing
   slashes, redundant separators, and `.` segments all collapse to
   the same row. _normalize_path uses os.path.normpath; symlinks
   are intentionally NOT resolved so mount paths stay intact for
   cross-machine portability.

2. Cleanup — new app/services/cleanup.py runs on backend startup
   (idempotent) and merges any pre-existing duplicates left over
   from older scanner versions:
   - Groups source_roots by normalized path. Picks the canonical
     row (preferring one with a non-empty name and the earliest
     added_at), re-points child Folder rows via UPDATE, and
     deletes the duplicates.
   - Same for folders, with photo_count as the tiebreaker. Photos
     get re-pointed to the canonical folder via UPDATE.
   - Recomputes folder.photo_count from the actual non-discarded
     photo membership so the sidebar count matches reality.

Wired into main.py's lifespan handler. On the dev DB this merged
the empty-name "/host/Pictures/MulitaTest/" duplicate that was
showing up alongside the canonical MulitaTest source root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:43:53 +02:00
11 changed files with 406 additions and 35 deletions

View File

@@ -57,6 +57,31 @@ docker-compose up -d
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
The application consists of 5 Docker services:

View File

@@ -13,6 +13,7 @@ from app.config import settings
from app.database import init_db
from app.routers import photos, folders, heaps, tags, discard, library
from app.services.scanner import start_initial_scan
from app.services.cleanup import cleanup_data_integrity
# Configure logging
logging.basicConfig(
@@ -25,17 +26,24 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI):
"""Manage application lifecycle"""
logger.info("Starting Mulita application...")
# Initialize database
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
if settings.scanner.initial_scan_on_start:
logger.info("Starting initial library scan...")
await start_initial_scan()
yield
logger.info("Shutting down Mulita application...")
# Create FastAPI app

View File

@@ -410,20 +410,53 @@ async def update_photo(
update: PhotoUpdate,
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(
select(Photo).where(Photo.id == photo_id)
)
photo = result.scalar_one_or_none()
if not photo:
raise HTTPException(status_code=404, detail="Photo not found")
# Apply updates
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():
setattr(photo, field, value)
await db.commit()
await db.refresh(photo)

View File

@@ -46,6 +46,7 @@ class PhotoResponse(PhotoBase):
class PhotoUpdate(BaseModel):
"""Photo update schema"""
filename: Optional[str] = None
user_title: Optional[str] = None
user_notes: Optional[str] = None
rating: Optional[int] = Field(None, ge=0, le=5)

View 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

View File

@@ -181,39 +181,49 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
await session.rollback()
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:
"""Get or create a source root entry"""
"""Get or create a source root entry, matching by normalized path."""
from sqlalchemy import select
norm = _normalize_path(path)
result = await session.execute(
select(SourceRoot).where(SourceRoot.path == path)
select(SourceRoot).where(SourceRoot.path == norm)
)
source_root = result.scalar_one_or_none()
if not source_root:
source_root = SourceRoot(
name=Path(path).name,
path=path
name=Path(norm).name,
path=norm,
)
session.add(source_root)
await session.flush()
return source_root
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
norm = _normalize_path(path)
result = await session.execute(
select(Folder).where(Folder.path == path)
select(Folder).where(Folder.path == norm)
)
folder = result.scalar_one_or_none()
if not folder:
parent_path = str(Path(path).parent)
parent = None
if parent_path != path: # Not root folder
parent_path = _normalize_path(str(Path(norm).parent))
if parent_path != norm: # Not the filesystem root
parent_result = await session.execute(
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
else:
parent_id = None
folder = Folder(
name=Path(path).name,
path=path,
name=Path(norm).name,
path=norm,
parent_id=parent_id,
source_root_id=source_root_id
source_root_id=source_root_id,
)
session.add(folder)
await session.flush()
return folder
@shared_task(name='scan_all_source_roots')

View File

@@ -24,7 +24,11 @@ services:
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${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
- proxies_data:/data/proxies
- db_data:/data/db
@@ -50,7 +54,8 @@ services:
volumes:
- ./mulita.yml:/app/config/mulita.yml:ro
- ${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
- proxies_data:/data/proxies
- db_data:/data/db

View File

@@ -13,6 +13,7 @@ import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { heaps as heapsApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
/**
* 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 [creating, setCreating] = useState(false)
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 = () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -71,6 +75,47 @@ export function HeapsPanel() {
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 name = newName.trim()
if (!name) return
@@ -151,15 +196,45 @@ export function HeapsPanel() {
{heaps.map((heap) => {
const isFiltered = filterHeapId === heap.id
const isActive = heap.is_active
const isDropTarget = dropTargetId === heap.id
return (
<div
key={heap.id}
className={clsx(
'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' }}
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
className={clsx(

View File

@@ -18,6 +18,7 @@ import { usePhotoStore } from '../../store/photoStore'
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { toast } from '../ToastContainer'
interface PhotoDetails {
id: string
@@ -122,6 +123,7 @@ export function RightSidebar() {
// change too.
const updateMutation = useMutation({
mutationFn: (data: {
filename?: string
rating?: number
is_discarded?: boolean
user_title?: string | null
@@ -175,13 +177,42 @@ export function RightSidebar() {
// Local drafts for the editable text fields. These mirror the server value
// but stay independent while the user is typing, so we don't fight focus or
// clobber edits with stale refetches.
const [filenameDraft, setFilenameDraft] = useState('')
const [titleDraft, setTitleDraft] = useState('')
const [notesDraft, setNotesDraft] = useState('')
useEffect(() => {
setFilenameDraft(photo?.filename ?? '')
setTitleDraft(photo?.user_title ?? '')
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 next = titleDraft.trim()
@@ -240,6 +271,26 @@ export function RightSidebar() {
{/* Quick Actions — operate on the active photo */}
{photo && !multipleSelected && (
<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) */}
<div>
<label className="mb-1 block text-xs text-text-muted">Title</label>
@@ -385,7 +436,6 @@ export function RightSidebar() {
onToggle={() => toggleSection('basic')}
>
<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="Dimensions"

View File

@@ -3,6 +3,10 @@ import { Star, ShoppingBasket, Trash2, RefreshCw, Check } from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
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
// first hit often 404s. Try a few times with backoff before giving up.
@@ -94,6 +98,22 @@ export function PhotoThumbnail({
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 (
<div
className={clsx(
@@ -108,7 +128,9 @@ export function PhotoThumbnail({
}}
onClick={onClick}
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 */}
{!imageError ? (

View File

@@ -56,6 +56,7 @@ export const photos = {
},
update: async (photoId: string, data: {
filename?: string
rating?: number
user_title?: string | null
user_notes?: string | null