From 5e2823ae943d3d5131f4bcdb99ccd30dcccf885e Mon Sep 17 00:00:00 2001 From: claudio Date: Wed, 13 May 2026 20:44:36 +0200 Subject: [PATCH] feat(library): tighten duplicates scope, drop in-app upload, polish loaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - duplicates: restrict the /library/duplicates/groups query to photos whose folder path actually lives under an active SourceRoot in the user's settings. Nextcloud's "move to trash" flow was leaving .delete/purge-1 Folder rows wired to the original source_root_id, leaking those entries into the Duplicates view as ghost paths that the user never opted into. - discard: add a spinner to the "Delete N" and "Empty discard pile" buttons (and their confirm dialogs) while the destructive mutation is in flight, so the user gets immediate feedback for a slow operation. - timeline: render a bottom-of-grid "Loading more photos…" indicator while usePhotosQuery's background cursor loop is still pulling pages. Backed by a tiny Zustand store the loop drives via a balanced start/stop (counter, not boolean, so rapid filter changes can't flip the flag false while a fresh loop is alive). - remove client-side upload UI + /upload endpoint. Nextcloud is the authoritative ingress now; the duplicate path created confusion and the backend route is gone too. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/main.py | 3 +- backend/app/routers/library.py | 33 +- backend/app/routers/upload.py | 361 ----------- .../src/components/dialogs/ConfirmDialog.tsx | 12 +- .../components/discard/DiscardActionBar.tsx | 20 +- .../src/components/layout/LeftSidebar.tsx | 31 - frontend/src/components/timeline/Timeline.tsx | 25 +- .../src/components/upload/UploadModal.tsx | 595 ------------------ frontend/src/hooks/usePhotosQuery.ts | 58 +- frontend/src/services/api.ts | 38 -- .../src/store/photosBackgroundLoadingStore.ts | 34 + 11 files changed, 151 insertions(+), 1059 deletions(-) delete mode 100644 backend/app/routers/upload.py delete mode 100644 frontend/src/components/upload/UploadModal.tsx create mode 100644 frontend/src/store/photosBackgroundLoadingStore.ts diff --git a/backend/app/main.py b/backend/app/main.py index 425d0e9..9ca5872 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,7 @@ import os from app.config import settings from app.database import init_db -from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, upload, download, features, nextcloud, nc_webhook +from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin, sharing, download, features, nextcloud, nc_webhook from app.services.scanner import start_initial_scan, bootstrap_default_source_root from app.services.cleanup import cleanup_data_integrity from app.services.nextcloud_dav import init_preview_client, close_preview_client @@ -116,7 +116,6 @@ app.include_router(tags.router, prefix="/api/v1/tags", tags=["tags"]) app.include_router(discard.router, prefix="/api/v1/discard", tags=["discard"]) app.include_router(library.router, prefix="/api/v1/library", tags=["library"]) app.include_router(search.router, prefix="/api/v1/photos/search", tags=["search"]) -app.include_router(upload.router, prefix="/api/v1/upload", tags=["upload"]) app.include_router(download.router, prefix="/api/v1/download", tags=["download"]) app.include_router(features.router, prefix="/api/v1/features", tags=["features"]) app.include_router(nextcloud.router, prefix="/api/v1/nextcloud", tags=["nextcloud"]) diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 6cf4470..5c3acb3 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -13,12 +13,12 @@ from typing import List, Optional from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field -from sqlalchemy import select, func, update, true as sa_true +from sqlalchemy import select, func, update, or_, true as sa_true from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Photo -from app.models.folders import SourceRoot +from app.models.folders import SourceRoot, Folder from app.models.user import User from app.dependencies import get_current_user @@ -730,6 +730,33 @@ async def get_duplicate_groups( * "similar" — members differ at the byte level but match perceptually """ owner = _owner_filter(current_user, scope) + + # Restrict to photos that actually live under an active SourceRoot + # in the user's settings. The folder→source_root link alone is not + # enough: Nextcloud's "move to trash" flow can leave a Folder row at + # a path like `…/files/.delete/purge-1` still wired to the original + # source_root_id, which then leaks its photos into the duplicates + # view even though that path is outside everything the user + # configured. Filtering on the folder's path being a descendant of + # the active root's path matches what the user actually expects to + # see in Settings → Source folders. + sr_path_query = select(SourceRoot.path).where(SourceRoot.is_active.is_(True)) + if not (scope == "global" and current_user.role == "admin"): + sr_path_query = sr_path_query.where(SourceRoot.user_id == current_user.id) + active_root_paths = (await db.execute(sr_path_query)).scalars().all() + if not active_root_paths: + return {"groups": [], "total_groups": 0, "total_members": 0} + + # Match the source root path itself OR a strict child (path + '/'). + # `startswith` alone would accept `/files/Photos2` for a `/files/Photos` + # root. + folder_in_scope = or_( + *[ + (Folder.path == p) | (Folder.path.like(p.rstrip('/') + '/%')) + for p in active_root_paths + ] + ) + rows = ( await db.execute( select( @@ -746,10 +773,12 @@ async def get_duplicate_groups( Photo.media_type, Photo.duplicate_group_id, ) + .join(Folder, Folder.id == Photo.folder_id) .where(owner) .where(Photo.duplicate_group_id.is_not(None)) .where(Photo.is_discarded.is_(False)) .where(Photo.is_hidden.is_(False)) + .where(folder_in_scope) .order_by(Photo.duplicate_group_id) ) ).all() diff --git a/backend/app/routers/upload.py b/backend/app/routers/upload.py deleted file mode 100644 index 0e6ff4d..0000000 --- a/backend/app/routers/upload.py +++ /dev/null @@ -1,361 +0,0 @@ -""" -Upload router — lets users drop files (or whole folders) from their -desktop into a destination Folder, preserving any sub-folder structure -they bring with them. - -Each POST handles one file. The frontend fans out many parallel requests -per drop, giving it per-file progress without the server having to -invent a chunking protocol. For folder uploads, the browser passes -`webkitRelativePath` under the `relative_path` field; any leading -sub-directories there are materialised on disk (and as Folder rows) -under the destination. - -Uploaded files are placed under the destination folder on the owner's -media mount, indexed immediately (Photo row created), and queued for -the same thumb + metadata pipeline that the scanner uses. An optional -`heap_id` also drops them into a heap in the same request. -""" -import hashlib -import logging -import os -import tempfile -from pathlib import Path -from datetime import datetime -from typing import Optional - -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile -from sqlalchemy import insert, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.database import get_db -from app.dependencies import get_current_user -from app.models import Folder, Heap, Photo, SourceRoot -from app.models.heaps import heap_photos -from app.models.user import User -from app.services.date_guess import has_date_warning -from app.services.nextcloud_dav import ( - ensure_parents_for_user, - is_nextcloud_path, - put_for_user, -) -from app.tasks.scan import SUPPORTED_EXTENSIONS, get_media_type -from app.tasks.thumbs import generate_thumbnails -from app.services.metadata import extract_metadata - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -MAX_UPLOAD_BYTES = 500 * 1024 * 1024 # 500 MB per file cap. - - -def _validate_segment(segment: str) -> str: - """Reject path segments that would escape the destination directory.""" - segment = segment.strip() - if not segment or segment in ('.', '..') or '/' in segment or '\\' in segment: - raise HTTPException(status_code=400, detail=f"Invalid path segment: {segment!r}") - return segment - - -def _sanitize_relative_path(rel: Optional[str]) -> list[str]: - """Split `relative_path` into safe segments (dirs + filename). - - Empty or missing → []. Any absolute path, backslash, or `..` segment - raises 400 — we never want an upload to escape the destination. - """ - if not rel: - return [] - # Normalise backslashes to forward slashes; browsers on Windows send - # webkitRelativePath with forward slashes anyway, but defend in depth. - rel = rel.replace('\\', '/').strip('/') - if not rel: - return [] - segs = [_validate_segment(s) for s in rel.split('/') if s] - return segs - - -async def _resolve_destination( - folder_id: str, - user: User, - db: AsyncSession, -) -> Folder: - """Resolve `folder_id` to a concrete Folder row the user owns. - - Accepts both Folder ids and SourceRoot ids (for source roots, we - return the Folder row at the mount path — the scanner creates one - for every source root it walks). Raises 404 if neither matches. - """ - folder = (await db.execute( - select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id) - )).scalar_one_or_none() - if folder is not None: - return folder - - sr = (await db.execute( - select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == user.id) - )).scalar_one_or_none() - if sr is None: - raise HTTPException(status_code=404, detail="Destination folder not found") - - root_folder = (await db.execute( - select(Folder).where( - Folder.source_root_id == sr.id, - Folder.user_id == user.id, - Folder.path == os.path.normpath(sr.path), - ) - )).scalar_one_or_none() - if root_folder is None: - # First-time source root with no walk yet — create the row now so - # uploads work even before the initial scan has run. - root_folder = Folder( - name=sr.name or os.path.basename(sr.path), - path=os.path.normpath(sr.path), - source_root_id=sr.id, - user_id=user.id, - ) - os.makedirs(root_folder.path, exist_ok=True) - db.add(root_folder) - await db.flush() - return root_folder - - -async def _ensure_subfolder( - parent: Folder, - name: str, - user: User, - db: AsyncSession, -) -> Folder: - """Return (or create) a Folder row named `name` under `parent`. - - Also mkdirs the directory on disk. For Nextcloud-rooted paths the - directory is created via WebDAV MKCOL so Nextcloud's `oc_filecache` - knows about it; otherwise plain `os.makedirs`. Idempotent — safe - to call for a path segment that already exists as a Folder row or - directory. - """ - child_path = os.path.normpath(os.path.join(parent.path, name)) - - existing = (await db.execute( - select(Folder).where( - Folder.path == child_path, - Folder.user_id == user.id, - ) - )).scalar_one_or_none() - if existing is not None: - # Materialise the directory if it wasn't already. - if is_nextcloud_path(child_path): - ensure_parents_for_user(user, child_path) - # Also ensure the leaf collection exists; ensure_parents - # only handles intermediate dirs. - from app.services.nextcloud_dav import mkcol_for_user - mkcol_for_user(user, child_path) - else: - os.makedirs(child_path, exist_ok=True) - return existing - - if is_nextcloud_path(child_path): - ensure_parents_for_user(user, child_path) - from app.services.nextcloud_dav import mkcol_for_user - mkcol_for_user(user, child_path) - else: - os.makedirs(child_path, exist_ok=True) - child = Folder( - name=name, - path=child_path, - parent_id=parent.id, - source_root_id=parent.source_root_id, - user_id=user.id, - is_hidden=parent.is_hidden, - ) - db.add(child) - await db.flush() - return child - - -def _unique_path(target_dir: str, filename: str) -> tuple[str, str]: - """Return a (filepath, filename) that doesn't collide with an - existing file on disk. Suffixes " (2)", " (3)", ... until a free - slot is found. Prevents upload-over-existing and keeps the user's - original file intact. - """ - base, ext = os.path.splitext(filename) - candidate = os.path.join(target_dir, filename) - n = 2 - while os.path.exists(candidate): - new_name = f"{base} ({n}){ext}" - candidate = os.path.join(target_dir, new_name) - n += 1 - return candidate, os.path.basename(candidate) - - -@router.post("") -async def upload_file( - file: UploadFile = File(...), - destination_folder_id: str = Form(...), - relative_path: Optional[str] = Form(None), - heap_id: Optional[str] = Form(None), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Upload a single file into a destination folder (and optionally a - heap). For folder uploads, `relative_path` carries the sub-folder - chain from the browser's `webkitRelativePath`, and we materialise - it under the destination on disk + as Folder rows. - - Returns the created photo's id on success. 4xx on unsupported file - type, bad path, missing destination, or too-large file. - """ - # --- validate inputs ------------------------------------------------- - raw_name = file.filename or '' - if not raw_name: - raise HTTPException(status_code=400, detail="Missing filename") - - # Prefer the leaf of relative_path when present (it contains the - # original filename as the browser saw it inside the picked folder). - segs = _sanitize_relative_path(relative_path) - if segs: - leaf = segs[-1] - subdirs = segs[:-1] - else: - leaf = _validate_segment(os.path.basename(raw_name)) - subdirs = [] - - ext = Path(leaf).suffix.lower() - if ext not in SUPPORTED_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"Unsupported file type: {ext or '(none)'}", - ) - - dest_folder = await _resolve_destination(destination_folder_id, current_user, db) - - target_folder = dest_folder - for seg in subdirs: - target_folder = await _ensure_subfolder(target_folder, seg, current_user, db) - - target_dir = target_folder.path - nc_managed = is_nextcloud_path(target_dir) - if not nc_managed: - os.makedirs(target_dir, exist_ok=True) - # else: target_dir was created via WebDAV MKCOL by _ensure_subfolder - filepath, final_name = _unique_path(target_dir, leaf) - - # --- stream the upload to a tempfile, hashing as we go --------------- - # For Nextcloud-managed destinations we then PUT the tempfile to - # WebDAV so Nextcloud's oc_filecache + sharing/comment metadata stay - # consistent. For local destinations we just rename the tempfile - # into place. Either way the hash + size are computed once. - hasher = hashlib.sha256() - total = 0 - tmp_dir = os.path.dirname(filepath) if not nc_managed else None - # NamedTemporaryFile in the same directory as filepath when local - # (so the final rename is atomic on the same filesystem). For NC, - # use the system tmpdir — we re-upload via HTTP either way. - tmp = tempfile.NamedTemporaryFile( - delete=False, dir=tmp_dir, suffix=".part" - ) - tmp_path = tmp.name - try: - try: - while True: - chunk = await file.read(1024 * 1024) - if not chunk: - break - total += len(chunk) - if total > MAX_UPLOAD_BYTES: - raise HTTPException( - status_code=413, - detail=f"File exceeds {MAX_UPLOAD_BYTES // (1024*1024)}MB limit", - ) - hasher.update(chunk) - tmp.write(chunk) - finally: - tmp.close() - - if nc_managed: - # PUT to Nextcloud WebDAV. The PUT lands the bytes on - # `/mnt/library/homecloud//files/` AND - # registers the file in oc_filecache, so the desktop sync - # client and Nextcloud's web UI both see it. - with open(tmp_path, "rb") as body: - put_for_user(current_user, filepath, body) - else: - os.replace(tmp_path, filepath) - tmp_path = None # consumed - except HTTPException: - raise - except Exception as e: - logger.error(f"Upload write failed for {filepath}: {e}") - raise HTTPException(status_code=500, detail=f"Upload failed: {e}") - finally: - if tmp_path and os.path.exists(tmp_path): - try: - os.unlink(tmp_path) - except OSError: - pass - - if not os.path.exists(filepath): - # WebDAV wrote it; the bind mount should reflect it. If it - # doesn't, surface a clean error rather than building a Photo - # row that points at a missing file. - raise HTTPException( - status_code=502, - detail="Nextcloud accepted the upload but the file isn't visible on the mount yet.", - ) - - file_hash = hasher.hexdigest() - - # --- validate heap before committing the DB row --------------------- - if heap_id: - heap = (await db.execute( - select(Heap).where(Heap.id == heap_id, Heap.user_id == current_user.id) - )).scalar_one_or_none() - if heap is None: - # Destination heap vanished — still keep the file + photo row, - # but tell the caller so the UI can surface the mismatch. - heap_id = None - - # --- create Photo row ------------------------------------------------ - mtime_dt = datetime.fromtimestamp(os.stat(filepath).st_mtime) - photo = Photo( - filepath=filepath, - filename=final_name, - folder_id=target_folder.id, - user_id=current_user.id, - file_hash=file_hash, - media_type=get_media_type(filepath), - original_format=Path(filepath).suffix.upper()[1:], - file_size=total, - taken_at=mtime_dt, - taken_at_source='filesystem', - has_date_warning=has_date_warning(filepath, mtime_dt), - is_hidden=bool(target_folder.is_hidden), - processing_status='pending', - ) - db.add(photo) - await db.flush() - - if heap_id: - await db.execute( - insert(heap_photos), - [{"heap_id": heap_id, "photo_id": photo.id}], - ) - - await db.commit() - - # Queue the same background work the scanner does so thumbnails + - # EXIF show up without the user having to trigger a rescan. - try: - generate_thumbnails.delay(photo.id) - extract_metadata.delay(photo.id) - except Exception as e: - logger.warning(f"Failed to queue post-upload tasks for {photo.id}: {e}") - - return { - "photo_id": photo.id, - "filename": final_name, - "folder_id": target_folder.id, - "folder_path": target_folder.path, - "heap_id": heap_id, - } diff --git a/frontend/src/components/dialogs/ConfirmDialog.tsx b/frontend/src/components/dialogs/ConfirmDialog.tsx index e0a6648..74db067 100644 --- a/frontend/src/components/dialogs/ConfirmDialog.tsx +++ b/frontend/src/components/dialogs/ConfirmDialog.tsx @@ -1,3 +1,4 @@ +import { Loader2 } from 'lucide-react' import { Dialog, DialogContent, @@ -16,6 +17,10 @@ interface ConfirmDialogProps { cancelLabel?: string /** When true, the confirm button uses the destructive accent. */ destructive?: boolean + /** Shows a spinner on the confirm button and disables both buttons. + * Use while the mutation kicked off by onConfirm is still in flight + * so the user can't double-fire (or escape) until it resolves. */ + isLoading?: boolean onConfirm: () => void onClose: () => void } @@ -32,11 +37,12 @@ export function ConfirmDialog({ confirmLabel = 'Confirm', cancelLabel = 'Cancel', destructive = false, + isLoading = false, onConfirm, onClose, }: ConfirmDialogProps) { return ( - !o && onClose()}> + !o && !isLoading && onClose()}> {title} @@ -45,13 +51,15 @@ export function ConfirmDialog({ - diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx index b5a2dc0..ceaa56d 100644 --- a/frontend/src/components/discard/DiscardActionBar.tsx +++ b/frontend/src/components/discard/DiscardActionBar.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { RotateCcw, Trash2 } from 'lucide-react' +import { Loader2, RotateCcw, Trash2 } from 'lucide-react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { usePhotoStore } from '../../store/photoStore' import { useFilterStore } from '../../store/filterStore' @@ -139,7 +139,11 @@ export function DiscardActionBar() { className="bg-reject/20 text-reject hover:bg-reject/30" title="Permanently delete selected" > - + {deleteSelectedMutation.isPending ? ( + + ) : ( + + )} Delete {selected} @@ -151,7 +155,11 @@ export function DiscardActionBar() { className="bg-reject/20 text-reject hover:bg-reject/30" title="Permanently delete all discarded photos and files" > - + {emptyMutation.isPending ? ( + + ) : ( + + )} Empty discard pile @@ -167,8 +175,9 @@ export function DiscardActionBar() { {selected === 1 ? '' : 's'} from disk. This cannot be undone. } - confirmLabel="Delete" + confirmLabel={deleteSelectedMutation.isPending ? 'Deleting…' : 'Delete'} destructive + isLoading={deleteSelectedMutation.isPending} onConfirm={() => deleteSelectedMutation.mutate(selectedPhotos)} onClose={() => setDeleteSelectedOpen(false)} /> @@ -183,8 +192,9 @@ export function DiscardActionBar() { {total === 1 ? '' : 's'} from disk. This cannot be undone. } - confirmLabel="Empty pile" + confirmLabel={emptyMutation.isPending ? 'Emptying…' : 'Empty pile'} destructive + isLoading={emptyMutation.isPending} onConfirm={() => emptyMutation.mutate()} onClose={() => setConfirmOpen(false)} /> diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 82c79ac..8fcd368 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -23,7 +23,6 @@ import { LogOut, Shield, Clock, - Upload as UploadIcon, Download as DownloadIcon, } from 'lucide-react' import { DateRangePicker } from '../filter/DateRangePicker' @@ -48,7 +47,6 @@ import { formatApiError } from '../../lib/apiError' import type { Photo } from '../../types/photo' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' import { ShareDialog } from '../sharing/ShareDialog' -import { UploadModal } from '../upload/UploadModal' import { sharing as sharingApi } from '../../services/api' import { useSharedFoldersQuery, @@ -134,14 +132,6 @@ export function LeftSidebar() { id: string name: string } | null>(null) - // Upload modal state. `uploadTarget` stores the pre-selected - // destination Folder/SourceRoot id so "Upload here…" on a folder row - // drops files straight into that folder; null means the general - // Library-level button (defaults to the first source root). - const [uploadTarget, setUploadTarget] = useState<{ open: boolean; folderId: string | null }>({ - open: false, - folderId: null, - }) const { data: sharedFolders = [] } = useSharedFoldersQuery() // Bulk discard mutation for the drag-onto-Discarded interaction. @@ -715,14 +705,6 @@ export function LeftSidebar() { className="min-w-[180px]" onClick={(e) => e.stopPropagation()} > - - setUploadTarget({ open: true, folderId }) - } - > - - Upload here… - { setCreatingUnder(folderId) @@ -861,14 +843,6 @@ export function LeftSidebar() {
Library -
{libraryTree.map((item) => renderTreeItem(item))} @@ -1027,11 +1001,6 @@ export function LeftSidebar() { targetName={sharingFolder?.name ?? ''} onClose={() => setSharingFolder(null)} /> - setUploadTarget({ open: false, folderId: null })} - />
) } diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 399c47b..b0716fb 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -6,11 +6,11 @@ import { usePhotoStore } from '../../store/photoStore' import { useFilterStore, hasActiveFilters } from '../../store/filterStore' import { useViewSettingsStore } from '../../store/viewSettingsStore' import { PhotoThumbnail } from './PhotoThumbnail' -import { usePhotosQuery } from '../../hooks/usePhotosQuery' +import { usePhotosQuery, usePhotosLoadingMore } from '../../hooks/usePhotosQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useGridKeyNav } from '../../hooks/useGridKeyNav' import { Button } from '@/components/ui/button' -import { ImageOff } from 'lucide-react' +import { ImageOff, Loader2 } from 'lucide-react' import type { Photo } from '../../types/photo' // Layout constants for the grid + grouped headers. The cell size is @@ -199,6 +199,11 @@ export function Timeline() { // Shared photos query — both Timeline and PreviewView use the same hook so // they share one cache entry, regardless of filter state. const { data: photos = [], isLoading } = usePhotosQuery() + // Background cursor loop (after the initial page resolves) still + // streaming more photos? Drives the bottom-of-grid spinner so the + // user has a signal that more results are on the way and a sudden + // "end of list" is real rather than mid-fetch. + const isLoadingMore = usePhotosLoadingMore() // Tracks the previous viewMode so the "preview just closed" scroll // effect (defined further down, after photoRows) only fires on the @@ -789,6 +794,20 @@ export function Timeline() { position: 'relative', }} > + {/* Bottom loading indicator. Positioned absolutely just below + * the virtualizer's last row so a mid-grid spinner doesn't + * jitter the layout when pages flip. Sits inside the same + * positioned wrapper that hosts the virtual items so the + * parent's translate space stays self-contained. */} + {isLoadingMore && photos.length > 0 && ( +
+ + Loading more photos… +
+ )} {virtualizer.getVirtualItems().map((virtualItem) => { const item = items[virtualItem.index] if (!item) return null @@ -922,7 +941,7 @@ function sectionEmptyCopy( default: return { title: 'Library is empty', - hint: 'Add photos via the upload button, or point the PHOTO_DIRS volume at a folder with existing images.', + hint: 'Add photos through Nextcloud, or point the PHOTO_DIRS volume at a folder with existing images.', } } } diff --git a/frontend/src/components/upload/UploadModal.tsx b/frontend/src/components/upload/UploadModal.tsx deleted file mode 100644 index 782b067..0000000 --- a/frontend/src/components/upload/UploadModal.tsx +++ /dev/null @@ -1,595 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { - ChevronDown, - ChevronRight, - Folder as FolderIcon, - Upload as UploadIcon, - X, - FileImage, - CheckCircle2, - AlertCircle, -} from 'lucide-react' -import { cn } from '@/lib/utils' -import { useQueryClient } from '@tanstack/react-query' -import { uploads, type FolderTreeNode } from '../../services/api' -import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' -import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' -import { FOLDER_TREE_QUERY_KEY } from '../../hooks/useFolderTreeQuery' -import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' -import { toast } from '../ToastContainer' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' - -interface UploadModalProps { - isOpen: boolean - onClose: () => void - /** Optional pre-selected destination. Accepts a Folder id or SourceRoot - * id — the backend resolves source roots to their root Folder row. */ - initialFolderId?: string | null -} - -interface QueuedFile { - /** Stable key; the browser may give us multiple files with the same - * name from different subfolders, so we key on index + path. */ - key: string - file: File - relativePath: string - status: 'pending' | 'uploading' | 'done' | 'error' - progress: number - error?: string -} - -const SUPPORTED_EXTENSIONS = new Set([ - '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp', - '.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw', - '.heic', '.heif', - '.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv', -]) - -function extOf(name: string): string { - const i = name.lastIndexOf('.') - return i === -1 ? '' : name.slice(i).toLowerCase() -} - -function isSupported(name: string): boolean { - return SUPPORTED_EXTENSIONS.has(extOf(name)) -} - -const MAX_PARALLEL = 4 - -/** Walk the tree to collect the ids on the path from root to `targetId`, - * excluding the target itself — used to expand ancestor rows so a - * pre-selected destination is visible. */ -function collectAncestors(tree: FolderTreeNode[], targetId: string): string[] { - const path: string[] = [] - const walk = (nodes: FolderTreeNode[], chain: string[]): boolean => { - for (const n of nodes) { - if (n.id === targetId) { - path.push(...chain) - return true - } - if (n.children && walk(n.children, [...chain, n.id])) return true - } - return false - } - walk(tree, []) - return path -} - -/** - * Upload from desktop. Supports: - * - Dropping files or folders onto the drop zone - * - Picking files with "Select files" - * - Picking a whole folder with "Select folder" (webkitdirectory); - * every file's webkitRelativePath is sent to the backend so sub- - * folder structure is preserved under the chosen destination. - * - * Destination is a Folder row (or a source root, which the backend - * resolves to its root folder). An optional heap can also be chosen — - * uploaded photos are added to that heap in the same request. - */ -export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalProps) { - const queryClient = useQueryClient() - const { data: folderTree } = useFolderTreeQuery() - const { data: allHeaps = [] } = useHeapsQuery() - - const [queue, setQueue] = useState([]) - const [destFolderId, setDestFolderId] = useState(null) - const [destHeapId, setDestHeapId] = useState(null) - const [isUploading, setIsUploading] = useState(false) - const [dragOver, setDragOver] = useState(false) - const [expandedFolders, setExpandedFolders] = useState>(new Set()) - - const fileInputRef = useRef(null) - const dirInputRef = useRef(null) - const abortRef = useRef(null) - - // Default destination: caller-provided initialFolderId wins; otherwise - // first source root in the tree. Re-runs when the modal is re-opened - // with a different initial target so the right row is highlighted. - useEffect(() => { - if (!isOpen) return - if (initialFolderId) { - setDestFolderId(initialFolderId) - // Expand every ancestor of the pre-selected folder so the row is - // actually visible in the tree. - if (folderTree) { - const ancestors = collectAncestors(folderTree, initialFolderId) - setExpandedFolders((prev) => new Set([...prev, ...ancestors])) - } - return - } - if (!destFolderId && folderTree && folderTree.length > 0) { - setDestFolderId(folderTree[0].id) - setExpandedFolders(new Set([folderTree[0].id])) - } - }, [isOpen, initialFolderId, folderTree, destFolderId]) - - // Esc / overlay-click dismissal lives on the Dialog primitive below. - // We only need to guard against closing while an upload is in flight. - - // Reset transient state on open so a previous session's queue doesn't - // bleed into a fresh one. - useEffect(() => { - if (isOpen) { - setQueue([]) - setIsUploading(false) - } - }, [isOpen]) - - const addFiles = (incoming: File[], relPathFn?: (f: File) => string) => { - const next: QueuedFile[] = [] - let skipped = 0 - for (const file of incoming) { - const relPath = (relPathFn?.(file) ?? '').replace(/\\/g, '/').replace(/^\/+/, '') - const filename = relPath || file.name - if (!isSupported(filename)) { - skipped++ - continue - } - next.push({ - key: `${relPath || file.name}::${file.size}::${file.lastModified}::${next.length}`, - file, - relativePath: relPath, - status: 'pending', - progress: 0, - }) - } - if (skipped > 0) { - toast.info?.(`Skipped ${skipped} unsupported file${skipped === 1 ? '' : 's'}`) - } - setQueue((prev) => [...prev, ...next]) - } - - const handleFilePick = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []) - addFiles(files, (f) => f.name) // no relative path for single files - e.target.value = '' // allow re-picking the same file - } - - const handleDirPick = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []) - addFiles(files, (f) => (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name) - e.target.value = '' - } - - // Drag-and-drop handler. We walk the DataTransferItemList recursively - // with webkitGetAsEntry so dropped folders contribute every nested - // file, with relative paths reconstructed from the entry chain. - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault() - setDragOver(false) - const items = Array.from(e.dataTransfer.items) - const collected: { file: File; relativePath: string }[] = [] - - const walkEntry = (entry: any, pathPrefix: string): Promise => { - return new Promise((resolve) => { - if (!entry) return resolve() - if (entry.isFile) { - entry.file((f: File) => { - collected.push({ - file: f, - relativePath: pathPrefix ? `${pathPrefix}/${entry.name}` : '', - }) - resolve() - }, () => resolve()) - } else if (entry.isDirectory) { - const reader = entry.createReader() - const readBatch = () => { - reader.readEntries(async (entries: any[]) => { - if (!entries.length) return resolve() - const childPrefix = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name - await Promise.all(entries.map((c) => walkEntry(c, childPrefix))) - // readEntries only returns a batch at a time; loop until empty. - readBatch() - }, () => resolve()) - } - readBatch() - } else { - resolve() - } - }) - } - - await Promise.all( - items.map((it) => { - const entry = (it as DataTransferItem & { webkitGetAsEntry?: () => any }).webkitGetAsEntry?.() - return walkEntry(entry, '') - }) - ) - - if (collected.length === 0) { - // Fallback for browsers without webkitGetAsEntry — use plain files. - const files = Array.from(e.dataTransfer.files) - addFiles(files, (f) => f.name) - return - } - - const files = collected.map((c) => c.file) - const pathMap = new Map(collected.map((c) => [c.file, c.relativePath])) - addFiles(files, (f) => pathMap.get(f) || f.name) - } - - const removeFromQueue = (key: string) => { - setQueue((prev) => prev.filter((q) => q.key !== key)) - } - - const startUpload = async () => { - if (!destFolderId || queue.length === 0) return - setIsUploading(true) - const ctrl = new AbortController() - abortRef.current = ctrl - - // Simple worker-pool: up to MAX_PARALLEL concurrent uploads. - const pending = queue.filter((q) => q.status === 'pending' || q.status === 'error') - let cursor = 0 - let successCount = 0 - let failCount = 0 - - const uploadOne = async (item: QueuedFile) => { - setQueue((prev) => - prev.map((q) => (q.key === item.key ? { ...q, status: 'uploading', progress: 0, error: undefined } : q)) - ) - try { - await uploads.uploadFile(item.file, destFolderId, { - relativePath: item.relativePath || undefined, - heapId: destHeapId, - signal: ctrl.signal, - onProgress: (loaded, total) => { - const pct = total > 0 ? loaded / total : 0 - setQueue((prev) => - prev.map((q) => (q.key === item.key ? { ...q, progress: pct } : q)) - ) - }, - }) - successCount++ - setQueue((prev) => - prev.map((q) => (q.key === item.key ? { ...q, status: 'done', progress: 1 } : q)) - ) - } catch (err: any) { - failCount++ - const msg = err?.response?.data?.detail || err?.message || 'Upload failed' - setQueue((prev) => - prev.map((q) => (q.key === item.key ? { ...q, status: 'error', error: msg } : q)) - ) - } - } - - const workers: Promise[] = [] - for (let i = 0; i < Math.min(MAX_PARALLEL, pending.length); i++) { - workers.push( - (async () => { - while (cursor < pending.length && !ctrl.signal.aborted) { - const idx = cursor++ - await uploadOne(pending[idx]) - } - })() - ) - } - await Promise.all(workers) - - setIsUploading(false) - abortRef.current = null - - // Refresh everything affected by new photos. - queryClient.invalidateQueries({ queryKey: FOLDER_TREE_QUERY_KEY }) - queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) - queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) - queryClient.invalidateQueries({ queryKey: ['photos'] }) - - if (successCount > 0) { - toast.success( - `Uploaded ${successCount} file${successCount === 1 ? '' : 's'}`, - failCount > 0 ? `${failCount} failed — see list` : undefined - ) - } - if (successCount === 0 && failCount > 0) { - toast.error('Upload failed', `${failCount} file${failCount === 1 ? '' : 's'} errored`) - } - } - - const cancelUpload = () => { - abortRef.current?.abort() - } - - const totalBytes = useMemo(() => queue.reduce((s, q) => s + q.file.size, 0), [queue]) - const uploadedBytes = useMemo( - () => queue.reduce((s, q) => s + q.file.size * (q.status === 'done' ? 1 : q.progress), 0), - [queue] - ) - const overallPct = totalBytes > 0 ? Math.round((uploadedBytes / totalBytes) * 100) : 0 - - return ( - { - if (!o && !isUploading) onClose() - }} - > - - - - - Upload photos - - - - {/* Body */} -
- {/* Left: destination picker */} -
-
- -
- {folderTree && folderTree.length > 0 ? ( - folderTree.map((n) => ( - - setExpandedFolders((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } - /> - )) - ) : ( -
No folders yet.
- )} -
-
- -
- - -
-
- - {/* Right: drop zone + queue */} -
-
{ - e.preventDefault() - setDragOver(true) - }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} - className={cn( - 'flex flex-col items-center justify-center rounded border-2 border-dashed px-4 py-6 text-center transition-colors', - dragOver - ? 'border-primary bg-primary/10' - : 'border-border bg-bg' - )} - > - -
- Drop files or folders here -
-
- Folder structure is preserved under the destination. -
-
- - -
- - -
- - {/* Queue */} -
- {queue.length === 0 ? ( -
- No files added yet. -
- ) : ( -
    - {queue.map((item) => ( -
  • - -
    -
    - {item.relativePath || item.file.name} -
    - {item.status === 'uploading' && ( -
    -
    -
    - )} - {item.status === 'error' && item.error && ( -
    {item.error}
    - )} -
    -
    - {item.status === 'done' && } - {item.status === 'error' && } - {item.status !== 'done' && !isUploading && ( - - )} -
    -
  • - ))} -
- )} -
- - {queue.length > 0 && ( -
- {queue.length} file{queue.length === 1 ? '' : 's'} •{' '} - {(totalBytes / 1024 / 1024).toFixed(1)} MB - {isUploading && ` • ${overallPct}% uploaded`} -
- )} -
-
- - {/* Footer */} -
- - -
-
-
- ) -} - -interface FolderTreeRowProps { - node: FolderTreeNode - depth: number - selectedId: string | null - onSelect: (id: string) => void - expanded: Set - onToggle: (id: string) => void -} - -function FolderTreeRow({ - node, - depth, - selectedId, - onSelect, - expanded, - onToggle, -}: FolderTreeRowProps) { - const isExpanded = expanded.has(node.id) - const hasChildren = node.children && node.children.length > 0 - const isSelected = selectedId === node.id - - return ( - <> -
onSelect(node.id)} - > - - - {node.name} -
- {isExpanded && - node.children?.map((c) => ( - - ))} - - ) -} diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index a894c8f..304bf9b 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -1,9 +1,17 @@ import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useShallow } from 'zustand/react/shallow' import { useFilterStore, filtersToParams } from '../store/filterStore' +import { usePhotosBackgroundLoadingStore } from '../store/photosBackgroundLoadingStore' import api from '../services/api' import type { Photo } from '../types/photo' +/** Reactive selector for the "more pages still streaming" flag. Used by + * thumbnail views to render a bottom-of-list spinner while + * usePhotosQuery's background cursor loop is still pulling pages. */ +export function usePhotosLoadingMore(): boolean { + return usePhotosBackgroundLoadingStore((s) => s.isLoadingMore) +} + /** * Optimistically strip the supplied photo ids from every cached * timeline list. Used by discard / delete flows so the photos vanish @@ -100,29 +108,39 @@ export function usePhotosQuery() { if (nextCursor) { // Fire-and-forget background loop using cursor chaining. + // The background store is reactive — Timeline et al. subscribe + // to render a "loading more…" indicator at the bottom of the + // grid while pages keep arriving. start/stop is balanced in a + // try/finally so an aborted/erroring loop can't leak the flag. + const bg = usePhotosBackgroundLoadingStore.getState() + bg.start() void (async () => { - for (let i = 0; i < MAX_PAGES && nextCursor; i++) { - if (signal?.aborted) return - try { - const page = await fetchCursorPage( - { per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams }, - signal, - ) + try { + for (let i = 0; i < MAX_PAGES && nextCursor; i++) { if (signal?.aborted) return - const more = page.photos || [] - nextCursor = page.next_cursor - queryClient.setQueryData( - ['photos', filterParams], - (prev) => (prev ? [...prev, ...more] : more) - ) - if (!nextCursor || more.length < PER_PAGE_BACKGROUND) return - // Yield a beat between pages so the main thread stays - // responsive (thumbnail decode, scroll handling) while - // we're back-filling in the background. - await new Promise((r) => setTimeout(r, INTER_PAGE_DELAY_MS)) - } catch { - return + try { + const page = await fetchCursorPage( + { per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams }, + signal, + ) + if (signal?.aborted) return + const more = page.photos || [] + nextCursor = page.next_cursor + queryClient.setQueryData( + ['photos', filterParams], + (prev) => (prev ? [...prev, ...more] : more) + ) + if (!nextCursor || more.length < PER_PAGE_BACKGROUND) return + // Yield a beat between pages so the main thread stays + // responsive (thumbnail decode, scroll handling) while + // we're back-filling in the background. + await new Promise((r) => setTimeout(r, INTER_PAGE_DELAY_MS)) + } catch { + return + } } + } finally { + usePhotosBackgroundLoadingStore.getState().stop() } })() } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5226208..be5618e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -704,44 +704,6 @@ export const heaps = { }, } -// Upload API — single file per request so the browser can fan out many -// POSTs in parallel with per-file progress. For folder uploads the -// caller passes each File's webkitRelativePath so the backend can -// materialise the folder structure under the destination. -export const uploads = { - uploadFile: async ( - file: File, - destinationFolderId: string, - opts: { - relativePath?: string - heapId?: string | null - onProgress?: (loadedBytes: number, totalBytes: number) => void - signal?: AbortSignal - } = {} - ) => { - const form = new FormData() - form.append('file', file) - form.append('destination_folder_id', destinationFolderId) - if (opts.relativePath) form.append('relative_path', opts.relativePath) - if (opts.heapId) form.append('heap_id', opts.heapId) - - const response = await api.post('/upload', form, { - headers: { 'Content-Type': 'multipart/form-data' }, - signal: opts.signal, - onUploadProgress: (evt) => { - if (opts.onProgress && evt.total) opts.onProgress(evt.loaded, evt.total) - }, - }) - return response.data as { - photo_id: string - filename: string - folder_id: string - folder_path: string - heap_id: string | null - } - }, -} - // Download helpers — build a URL the browser can pull directly via an // . The backend accepts `?token=` so an works without a // custom fetch + save-blob dance; the Authorization header is not diff --git a/frontend/src/store/photosBackgroundLoadingStore.ts b/frontend/src/store/photosBackgroundLoadingStore.ts new file mode 100644 index 0000000..9affcf9 --- /dev/null +++ b/frontend/src/store/photosBackgroundLoadingStore.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand' + +/** + * Tracks whether usePhotosQuery's background cursor-chasing loop is + * still pulling pages. Lives outside react-query because the loop runs + * after the initial fetch resolves and react-query's isFetching only + * covers fetches it owns. Consumers (Timeline, RatedView, ColorsView…) + * use isLoadingMore to decide whether to render the "loading more + * photos…" indicator at the bottom of the grid. + */ +interface PhotosBackgroundLoadingState { + /** Count of in-flight background pagers. Stored as a counter so an + * aborted-then-restarted loop on rapid filter changes can't flip the + * flag false while a fresh loop is still active. */ + inFlight: number + isLoadingMore: boolean + start: () => void + stop: () => void +} + +export const usePhotosBackgroundLoadingStore = create((set) => ({ + inFlight: 0, + isLoadingMore: false, + start: () => + set((s) => ({ + inFlight: s.inFlight + 1, + isLoadingMore: true, + })), + stop: () => + set((s) => { + const next = Math.max(0, s.inFlight - 1) + return { inFlight: next, isLoadingMore: next > 0 } + }), +}))