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:
claudio
2026-05-13 20:44:36 +02:00
parent 99edd7d395
commit 5e2823ae94
11 changed files with 151 additions and 1059 deletions

View File

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

View File

@@ -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)}
/>

View File

@@ -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>
)
}

View File

@@ -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.',
}
}
}

View File

@@ -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}
/>
))}
</>
)
}

View File

@@ -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()
}
})()
}

View File

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

View 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 }
}),
}))