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

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