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:
@@ -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