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.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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog open={isOpen} onOpenChange={(o) => !o && !isLoading && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
@@ -45,13 +51,15 @@ export function ConfirmDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<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}
|
||||
</Button>
|
||||
</>
|
||||
@@ -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"
|
||||
>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setUploadTarget({ open: true, folderId })
|
||||
}
|
||||
>
|
||||
<UploadIcon className="h-3.5 w-3.5 text-text-muted" />
|
||||
Upload here…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCreatingUnder(folderId)
|
||||
@@ -861,14 +843,6 @@ export function LeftSidebar() {
|
||||
<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">
|
||||
<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 className="min-h-0 flex-1 overflow-y-auto pb-2">
|
||||
{libraryTree.map((item) => renderTreeItem(item))}
|
||||
@@ -1027,11 +1001,6 @@ export function LeftSidebar() {
|
||||
targetName={sharingFolder?.name ?? ''}
|
||||
onClose={() => setSharingFolder(null)}
|
||||
/>
|
||||
<UploadModal
|
||||
isOpen={uploadTarget.open}
|
||||
initialFolderId={uploadTarget.folderId}
|
||||
onClose={() => setUploadTarget({ open: false, folderId: null })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<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) => {
|
||||
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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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<Photo[]>(
|
||||
['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<Photo[]>(
|
||||
['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()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// <a href>. The backend accepts `?token=` so an <a> works without a
|
||||
// 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