feat(library): tighten duplicates scope, drop in-app upload, polish loaders
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import os
|
|||||||
|
|
||||||
from app.config import settings
|
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, 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.scanner import start_initial_scan, bootstrap_default_source_root
|
||||||
from app.services.cleanup import cleanup_data_integrity
|
from app.services.cleanup import cleanup_data_integrity
|
||||||
from app.services.nextcloud_dav import init_preview_client, close_preview_client
|
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(discard.router, prefix="/api/v1/discard", tags=["discard"])
|
||||||
app.include_router(library.router, prefix="/api/v1/library", tags=["library"])
|
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(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(download.router, prefix="/api/v1/download", tags=["download"])
|
||||||
app.include_router(features.router, prefix="/api/v1/features", tags=["features"])
|
app.include_router(features.router, prefix="/api/v1/features", tags=["features"])
|
||||||
app.include_router(nextcloud.router, prefix="/api/v1/nextcloud", tags=["nextcloud"])
|
app.include_router(nextcloud.router, prefix="/api/v1/nextcloud", tags=["nextcloud"])
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, Field
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo
|
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.models.user import User
|
||||||
from app.dependencies import get_current_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
|
* "similar" — members differ at the byte level but match perceptually
|
||||||
"""
|
"""
|
||||||
owner = _owner_filter(current_user, scope)
|
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 = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(
|
select(
|
||||||
@@ -746,10 +773,12 @@ async def get_duplicate_groups(
|
|||||||
Photo.media_type,
|
Photo.media_type,
|
||||||
Photo.duplicate_group_id,
|
Photo.duplicate_group_id,
|
||||||
)
|
)
|
||||||
|
.join(Folder, Folder.id == Photo.folder_id)
|
||||||
.where(owner)
|
.where(owner)
|
||||||
.where(Photo.duplicate_group_id.is_not(None))
|
.where(Photo.duplicate_group_id.is_not(None))
|
||||||
.where(Photo.is_discarded.is_(False))
|
.where(Photo.is_discarded.is_(False))
|
||||||
.where(Photo.is_hidden.is_(False))
|
.where(Photo.is_hidden.is_(False))
|
||||||
|
.where(folder_in_scope)
|
||||||
.order_by(Photo.duplicate_group_id)
|
.order_by(Photo.duplicate_group_id)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
@@ -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/<nc_user>/files/<rel>` 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,
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -16,6 +17,10 @@ interface ConfirmDialogProps {
|
|||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
/** When true, the confirm button uses the destructive accent. */
|
/** When true, the confirm button uses the destructive accent. */
|
||||||
destructive?: boolean
|
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
|
onConfirm: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
@@ -32,11 +37,12 @@ export function ConfirmDialog({
|
|||||||
confirmLabel = 'Confirm',
|
confirmLabel = 'Confirm',
|
||||||
cancelLabel = 'Cancel',
|
cancelLabel = 'Cancel',
|
||||||
destructive = false,
|
destructive = false,
|
||||||
|
isLoading = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
}: ConfirmDialogProps) {
|
}: ConfirmDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(o) => !o && !isLoading && onClose()}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
@@ -45,13 +51,15 @@ export function ConfirmDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={destructive ? 'destructive' : 'default'}
|
variant={destructive ? 'destructive' : 'default'}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
|
{isLoading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
@@ -139,7 +139,11 @@ export function DiscardActionBar() {
|
|||||||
className="bg-reject/20 text-reject hover:bg-reject/30"
|
className="bg-reject/20 text-reject hover:bg-reject/30"
|
||||||
title="Permanently delete selected"
|
title="Permanently delete selected"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
{deleteSelectedMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
Delete {selected}
|
Delete {selected}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -151,7 +155,11 @@ export function DiscardActionBar() {
|
|||||||
className="bg-reject/20 text-reject hover:bg-reject/30"
|
className="bg-reject/20 text-reject hover:bg-reject/30"
|
||||||
title="Permanently delete all discarded photos and files"
|
title="Permanently delete all discarded photos and files"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
{emptyMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
Empty discard pile
|
Empty discard pile
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,8 +175,9 @@ export function DiscardActionBar() {
|
|||||||
{selected === 1 ? '' : 's'} from disk. This cannot be undone.
|
{selected === 1 ? '' : 's'} from disk. This cannot be undone.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
confirmLabel="Delete"
|
confirmLabel={deleteSelectedMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||||
destructive
|
destructive
|
||||||
|
isLoading={deleteSelectedMutation.isPending}
|
||||||
onConfirm={() => deleteSelectedMutation.mutate(selectedPhotos)}
|
onConfirm={() => deleteSelectedMutation.mutate(selectedPhotos)}
|
||||||
onClose={() => setDeleteSelectedOpen(false)}
|
onClose={() => setDeleteSelectedOpen(false)}
|
||||||
/>
|
/>
|
||||||
@@ -183,8 +192,9 @@ export function DiscardActionBar() {
|
|||||||
{total === 1 ? '' : 's'} from disk. This cannot be undone.
|
{total === 1 ? '' : 's'} from disk. This cannot be undone.
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
confirmLabel="Empty pile"
|
confirmLabel={emptyMutation.isPending ? 'Emptying…' : 'Empty pile'}
|
||||||
destructive
|
destructive
|
||||||
|
isLoading={emptyMutation.isPending}
|
||||||
onConfirm={() => emptyMutation.mutate()}
|
onConfirm={() => emptyMutation.mutate()}
|
||||||
onClose={() => setConfirmOpen(false)}
|
onClose={() => setConfirmOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Shield,
|
Shield,
|
||||||
Clock,
|
Clock,
|
||||||
Upload as UploadIcon,
|
|
||||||
Download as DownloadIcon,
|
Download as DownloadIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { DateRangePicker } from '../filter/DateRangePicker'
|
import { DateRangePicker } from '../filter/DateRangePicker'
|
||||||
@@ -48,7 +47,6 @@ import { formatApiError } from '../../lib/apiError'
|
|||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
||||||
import { ShareDialog } from '../sharing/ShareDialog'
|
import { ShareDialog } from '../sharing/ShareDialog'
|
||||||
import { UploadModal } from '../upload/UploadModal'
|
|
||||||
import { sharing as sharingApi } from '../../services/api'
|
import { sharing as sharingApi } from '../../services/api'
|
||||||
import {
|
import {
|
||||||
useSharedFoldersQuery,
|
useSharedFoldersQuery,
|
||||||
@@ -134,14 +132,6 @@ export function LeftSidebar() {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
} | null>(null)
|
} | 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()
|
const { data: sharedFolders = [] } = useSharedFoldersQuery()
|
||||||
|
|
||||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||||
@@ -715,14 +705,6 @@ export function LeftSidebar() {
|
|||||||
className="min-w-[180px]"
|
className="min-w-[180px]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
setUploadTarget({ open: true, folderId })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<UploadIcon className="h-3.5 w-3.5 text-text-muted" />
|
|
||||||
Upload here…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreatingUnder(folderId)
|
setCreatingUnder(folderId)
|
||||||
@@ -861,14 +843,6 @@ export function LeftSidebar() {
|
|||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="group flex h-9 flex-shrink-0 items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
<div className="group flex h-9 flex-shrink-0 items-center gap-2 border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||||
<span className="flex-1 truncate">Library</span>
|
<span className="flex-1 truncate">Library</span>
|
||||||
<button
|
|
||||||
onClick={() => setUploadTarget({ open: true, folderId: null })}
|
|
||||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Upload photos"
|
|
||||||
aria-label="Upload photos"
|
|
||||||
>
|
|
||||||
<UploadIcon className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
@@ -1027,11 +1001,6 @@ export function LeftSidebar() {
|
|||||||
targetName={sharingFolder?.name ?? ''}
|
targetName={sharingFolder?.name ?? ''}
|
||||||
onClose={() => setSharingFolder(null)}
|
onClose={() => setSharingFolder(null)}
|
||||||
/>
|
/>
|
||||||
<UploadModal
|
|
||||||
isOpen={uploadTarget.open}
|
|
||||||
initialFolderId={uploadTarget.folderId}
|
|
||||||
onClose={() => setUploadTarget({ open: false, folderId: null })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import { usePhotoStore } from '../../store/photoStore'
|
|||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||||
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery, usePhotosLoadingMore } from '../../hooks/usePhotosQuery'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ImageOff } from 'lucide-react'
|
import { ImageOff, Loader2 } from 'lucide-react'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
// Layout constants for the grid + grouped headers. The cell size is
|
// 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
|
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||||
// they share one cache entry, regardless of filter state.
|
// they share one cache entry, regardless of filter state.
|
||||||
const { data: photos = [], isLoading } = usePhotosQuery()
|
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
|
// Tracks the previous viewMode so the "preview just closed" scroll
|
||||||
// effect (defined further down, after photoRows) only fires on the
|
// effect (defined further down, after photoRows) only fires on the
|
||||||
@@ -789,6 +794,20 @@ export function Timeline() {
|
|||||||
position: 'relative',
|
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 && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-0 right-0 flex items-center justify-center gap-2 py-4 text-xs text-text-muted"
|
||||||
|
style={{ top: `${virtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Loading more photos…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const item = items[virtualItem.index]
|
const item = items[virtualItem.index]
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
@@ -922,7 +941,7 @@ function sectionEmptyCopy(
|
|||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
title: 'Library is empty',
|
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.',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<QueuedFile[]>([])
|
|
||||||
const [destFolderId, setDestFolderId] = useState<string | null>(null)
|
|
||||||
const [destHeapId, setDestHeapId] = useState<string | null>(null)
|
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
|
||||||
const [dragOver, setDragOver] = useState(false)
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const dirInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const abortRef = useRef<AbortController | null>(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<HTMLInputElement>) => {
|
|
||||||
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<HTMLInputElement>) => {
|
|
||||||
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<void> => {
|
|
||||||
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<File, string>(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<void>[] = []
|
|
||||||
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 (
|
|
||||||
<Dialog
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(o) => {
|
|
||||||
if (!o && !isUploading) onClose()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="flex max-h-[85vh] w-[760px] max-w-[760px] flex-col p-0">
|
|
||||||
<DialogHeader className="border-b border-border px-5 py-3">
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<UploadIcon className="h-4 w-4 text-text-muted" />
|
|
||||||
Upload photos
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden p-5">
|
|
||||||
{/* Left: destination picker */}
|
|
||||||
<div className="flex w-64 flex-col gap-4 overflow-hidden">
|
|
||||||
<div className="flex flex-col gap-2 overflow-hidden">
|
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
|
||||||
Destination folder
|
|
||||||
</label>
|
|
||||||
<div className="flex-1 overflow-auto rounded border border-border bg-bg p-1 text-sm">
|
|
||||||
{folderTree && folderTree.length > 0 ? (
|
|
||||||
folderTree.map((n) => (
|
|
||||||
<FolderTreeRow
|
|
||||||
key={n.id}
|
|
||||||
node={n}
|
|
||||||
depth={0}
|
|
||||||
selectedId={destFolderId}
|
|
||||||
onSelect={setDestFolderId}
|
|
||||||
expanded={expandedFolders}
|
|
||||||
onToggle={(id) =>
|
|
||||||
setExpandedFolders((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="p-3 text-xs text-text-muted">No folders yet.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
|
||||||
Also add to heap (optional)
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={destHeapId ?? ''}
|
|
||||||
onChange={(e) => setDestHeapId(e.target.value || null)}
|
|
||||||
className="rounded border border-border bg-bg px-2 py-1.5 text-sm text-text"
|
|
||||||
>
|
|
||||||
<option value="">— none —</option>
|
|
||||||
{allHeaps.map((h) => (
|
|
||||||
<option key={h.id} value={h.id}>
|
|
||||||
{h.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: drop zone + queue */}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
onDragOver={(e) => {
|
|
||||||
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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<UploadIcon className="mb-2 h-6 w-6 text-text-muted" />
|
|
||||||
<div className="text-sm text-text">
|
|
||||||
Drop files or folders here
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-text-muted">
|
|
||||||
Folder structure is preserved under the destination.
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
Select files
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => dirInputRef.current?.click()}
|
|
||||||
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
Select folder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="image/*,video/*,.heic,.heif,.cr2,.cr3,.nef,.arw,.raf,.dng,.orf,.rw2,.pef,.srw"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFilePick}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={dirInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
// @ts-expect-error — non-standard but supported in Chromium/WebKit
|
|
||||||
webkitdirectory=""
|
|
||||||
directory=""
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleDirPick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Queue */}
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border bg-bg">
|
|
||||||
{queue.length === 0 ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-xs text-text-muted">
|
|
||||||
No files added yet.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-border">
|
|
||||||
{queue.map((item) => (
|
|
||||||
<li key={item.key} className="flex items-center gap-2 px-3 py-2 text-sm">
|
|
||||||
<FileImage className="h-4 w-4 shrink-0 text-text-muted" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate text-text">
|
|
||||||
{item.relativePath || item.file.name}
|
|
||||||
</div>
|
|
||||||
{item.status === 'uploading' && (
|
|
||||||
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-surface-2">
|
|
||||||
<div
|
|
||||||
className="h-full bg-primary transition-all"
|
|
||||||
style={{ width: `${Math.round(item.progress * 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.status === 'error' && item.error && (
|
|
||||||
<div className="mt-0.5 truncate text-xs text-reject">{item.error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
{item.status === 'done' && <CheckCircle2 className="h-4 w-4 text-pick" />}
|
|
||||||
{item.status === 'error' && <AlertCircle className="h-4 w-4 text-reject" />}
|
|
||||||
{item.status !== 'done' && !isUploading && (
|
|
||||||
<button
|
|
||||||
onClick={() => removeFromQueue(item.key)}
|
|
||||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
aria-label="Remove"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{queue.length > 0 && (
|
|
||||||
<div className="text-xs text-text-muted">
|
|
||||||
{queue.length} file{queue.length === 1 ? '' : 's'} •{' '}
|
|
||||||
{(totalBytes / 1024 / 1024).toFixed(1)} MB
|
|
||||||
{isUploading && ` • ${overallPct}% uploaded`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
|
|
||||||
<button
|
|
||||||
onClick={isUploading ? cancelUpload : onClose}
|
|
||||||
className="rounded border border-border px-3 py-1.5 text-sm text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
{isUploading ? 'Cancel' : 'Close'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={startUpload}
|
|
||||||
disabled={isUploading || queue.length === 0 || !destFolderId}
|
|
||||||
className="flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<UploadIcon className="h-3.5 w-3.5" />
|
|
||||||
{isUploading ? 'Uploading…' : `Upload ${queue.length || ''}`.trim()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FolderTreeRowProps {
|
|
||||||
node: FolderTreeNode
|
|
||||||
depth: number
|
|
||||||
selectedId: string | null
|
|
||||||
onSelect: (id: string) => void
|
|
||||||
expanded: Set<string>
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex cursor-pointer items-center gap-1 rounded px-1 py-1 text-sm',
|
|
||||||
isSelected ? 'bg-primary/20 text-text' : 'text-text-muted hover:bg-surface-2 hover:text-text'
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
|
||||||
onClick={() => onSelect(node.id)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (hasChildren) onToggle(node.id)
|
|
||||||
}}
|
|
||||||
className="flex h-4 w-4 items-center justify-center"
|
|
||||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
isExpanded ? (
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
<FolderIcon className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{node.name}</span>
|
|
||||||
</div>
|
|
||||||
{isExpanded &&
|
|
||||||
node.children?.map((c) => (
|
|
||||||
<FolderTreeRow
|
|
||||||
key={c.id}
|
|
||||||
node={c}
|
|
||||||
depth={depth + 1}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={onSelect}
|
|
||||||
expanded={expanded}
|
|
||||||
onToggle={onToggle}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
||||||
|
import { usePhotosBackgroundLoadingStore } from '../store/photosBackgroundLoadingStore'
|
||||||
import api from '../services/api'
|
import api from '../services/api'
|
||||||
import type { Photo } from '../types/photo'
|
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
|
* Optimistically strip the supplied photo ids from every cached
|
||||||
* timeline list. Used by discard / delete flows so the photos vanish
|
* timeline list. Used by discard / delete flows so the photos vanish
|
||||||
@@ -100,29 +108,39 @@ export function usePhotosQuery() {
|
|||||||
|
|
||||||
if (nextCursor) {
|
if (nextCursor) {
|
||||||
// Fire-and-forget background loop using cursor chaining.
|
// 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 () => {
|
void (async () => {
|
||||||
for (let i = 0; i < MAX_PAGES && nextCursor; i++) {
|
try {
|
||||||
if (signal?.aborted) return
|
for (let i = 0; i < MAX_PAGES && nextCursor; i++) {
|
||||||
try {
|
|
||||||
const page = await fetchCursorPage(
|
|
||||||
{ per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams },
|
|
||||||
signal,
|
|
||||||
)
|
|
||||||
if (signal?.aborted) return
|
if (signal?.aborted) return
|
||||||
const more = page.photos || []
|
try {
|
||||||
nextCursor = page.next_cursor
|
const page = await fetchCursorPage(
|
||||||
queryClient.setQueryData<Photo[]>(
|
{ per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams },
|
||||||
['photos', filterParams],
|
signal,
|
||||||
(prev) => (prev ? [...prev, ...more] : more)
|
)
|
||||||
)
|
if (signal?.aborted) return
|
||||||
if (!nextCursor || more.length < PER_PAGE_BACKGROUND) return
|
const more = page.photos || []
|
||||||
// Yield a beat between pages so the main thread stays
|
nextCursor = page.next_cursor
|
||||||
// responsive (thumbnail decode, scroll handling) while
|
queryClient.setQueryData<Photo[]>(
|
||||||
// we're back-filling in the background.
|
['photos', filterParams],
|
||||||
await new Promise((r) => setTimeout(r, INTER_PAGE_DELAY_MS))
|
(prev) => (prev ? [...prev, ...more] : more)
|
||||||
} catch {
|
)
|
||||||
return
|
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()
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Download helpers — build a URL the browser can pull directly via an
|
||||||
// <a href>. The backend accepts `?token=` so an <a> works without a
|
// <a href>. The backend accepts `?token=` so an <a> works without a
|
||||||
// custom fetch + save-blob dance; the Authorization header is not
|
// custom fetch + save-blob dance; the Authorization header is not
|
||||||
|
|||||||
34
frontend/src/store/photosBackgroundLoadingStore.ts
Normal file
34
frontend/src/store/photosBackgroundLoadingStore.ts
Normal file
@@ -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<PhotosBackgroundLoadingState>((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 }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user