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