feat(library): reindex button, live grid, archive-reappear fix, bigger carets

- Add one-click "reindex new files" button to the Library sidebar header
  (RefreshCw, calls startIndex rescan:false), spins + disables while active.
- Refresh the photos grid from the indexer WS stream (throttled during the
  scan + once on completion) so newly indexed files appear live.
- Fix archived photos flashing back into the grid when archiving others:
  drop the per-action settle-driven clearRemoved and reconcile removedIds
  against the actual cache instead (clears an id only once it's gone from
  the deduped pages). Covers archive, delete, and bulk-bar removals.
- Replace the tiny Unicode caret triangles with a 16px Lucide ChevronRight
  that rotates 90deg on expand, across folder tree rows, root, Tags, Review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:48:18 +02:00
parent 669e5fde33
commit 6d9b236ef6
6 changed files with 110 additions and 31 deletions

View File

@@ -34,8 +34,7 @@ import {
removedBulk, removedBulk,
failBulk, failBulk,
setDetail, setDetail,
markRemoved, markRemoved
clearRemoved
} from '$lib/stores/bulkAction.svelte'; } from '$lib/stores/bulkAction.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
@@ -207,17 +206,19 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
if (target) { if (target) {
// Destructive removal: flash a red cross, then pull the tiles out of // Destructive removal: flash a red cross, then pull the tiles out of
// the grid immediately (markRemoved) rather than waiting on the slow // the grid immediately (markRemoved) rather than waiting on the slow
// server-reconcile refetch. clearRemoved once the refetch settles so // server-reconcile refetch. The grid reconciles `removedIds` against
// the archived-filtered page replaces the optimistic hide. // the cache and drops each id once the archived-filtered page has
// actually replaced it (see +page.svelte), so we don't clear here —
// clearing on this action's own settle raced other in-flight archives
// and flashed photos back in.
removedBulk(doneLabel, ids); removedBulk(doneLabel, ids);
focusAfter(ids); focusAfter(ids);
clearSelection(); clearSelection();
await delay(500); await delay(500);
markRemoved(ids); markRemoved(ids);
invalidatePhotos(ids); invalidatePhotos(ids);
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] }); void queryClient.invalidateQueries({ queryKey: ['marks'] });
void settled.then(() => clearRemoved(ids));
} else { } else {
doneBulk(doneLabel, ids); doneBulk(doneLabel, ids);
focusAfter(ids); focusAfter(ids);
@@ -267,9 +268,10 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
await delay(500); await delay(500);
markRemoved(ids); markRemoved(ids);
invalidatePhotos(ids); invalidatePhotos(ids);
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] }); // removedIds is reconciled against the cache in +page.svelte; no
// settle-driven clear here (see toggleArchive note above).
void queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] }); void queryClient.invalidateQueries({ queryKey: ['marks'] });
void settled.then(() => clearRemoved(ids));
toast.success(`Deleted ${ids.length}`, { id: tid }); toast.success(`Deleted ${ids.length}`, { id: tid });
} }

View File

@@ -41,7 +41,7 @@
import { filters } from '$lib/stores/filters.svelte'; import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte'; import { ChevronRight, FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte'; import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte'; import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -164,19 +164,21 @@
> >
{#if hasChildren} {#if hasChildren}
<button <button
class="flex h-[18px] w-4 items-center justify-center text-[10px]" class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
class:text-muted-foreground={!active} class:text-muted-foreground={!active}
onclick={() => toggle(node.path)} onclick={() => toggle(node.path)}
title={open ? 'Collapse' : 'Expand'} title={open ? 'Collapse' : 'Expand'}
aria-label={open ? 'Collapse' : 'Expand'} aria-label={open ? 'Collapse' : 'Expand'}
> >
{open ? '▾' : '▸'} <ChevronRight
class="h-4 w-4 transition-transform duration-150 {open ? 'rotate-90' : ''}"
/>
</button> </button>
{:else} {:else}
<!-- Spacer keeps childless siblings aligned with their chevroned <!-- Spacer keeps childless siblings aligned with their chevroned
peers at every depth, so labels share a common left edge peers at every depth, so labels share a common left edge
across the sidebar (folders, heaps, views, manage). --> across the sidebar (folders, heaps, views, manage). -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span> <span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
{/if} {/if}
<!-- <!--
Count badge lives INSIDE the button so the entire row (label Count badge lives INSIDE the button so the entire row (label

View File

@@ -19,6 +19,7 @@
renameFolder, renameFolder,
renameHeap, renameHeap,
scanCrossFolderDuplicates, scanCrossFolderDuplicates,
startIndex,
triggerDownload, triggerDownload,
type CrossFolderScanResult, type CrossFolderScanResult,
type PpAlbum, type PpAlbum,
@@ -44,12 +45,14 @@
} from '$lib/stores/filters.svelte'; } from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte'; import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte'; import { openMove } from '$lib/stores/moveDialog.svelte';
import { indexer } from '$lib/stores/indexer.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte'; import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte'; import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte'; import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte'; import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte'; import UsersDialog from './UsersDialog.svelte';
import { import {
ChevronRight,
Copy, Copy,
Download, Download,
FolderInput, FolderInput,
@@ -59,6 +62,7 @@
LogOut, LogOut,
Moon, Moon,
Pencil, Pencil,
RefreshCw,
Settings, Settings,
Sun, Sun,
Trash2, Trash2,
@@ -322,6 +326,22 @@
toast.error(err instanceof Error ? err.message : 'Delete failed') toast.error(err instanceof Error ? err.message : 'Delete failed')
})); }));
// One-click "reindex new files": kicks off a scan of the whole library
// with rescan off, so PhotoPrism only picks up files it hasn't indexed
// yet. Progress streams in via the WebSocket indexer pill, and the grid
// auto-refreshes as new tiles land (see indexer store). Guarded against
// double-trigger while a scan is already running.
async function onReindex() {
if (indexer.active) return;
const tid = toast.loading('Starting reindex…');
try {
await startIndex({ path: '/', rescan: false, cleanup: false });
toast.success('Reindex started — new files will appear as theyre found', { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid });
}
}
function onCreateFolder(parent: string | null = null) { function onCreateFolder(parent: string | null = null) {
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim(); const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
if (!name) return; if (!name) return;
@@ -484,6 +504,17 @@
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library Library
</span> </span>
<button
class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
class:opacity-0={!indexer.active}
class:opacity-100={indexer.active}
onclick={onReindex}
disabled={indexer.active}
title="Reindex new files"
aria-label="Reindex new files"
>
<RefreshCw class="h-3 w-3 {indexer.active ? 'animate-spin' : ''}" />
</button>
<button <button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100" class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)} onclick={() => (settingsOpen = true)}
@@ -518,18 +549,20 @@
{#if hasSubfolders} {#if hasSubfolders}
<button <button
type="button" type="button"
class="flex h-[18px] w-4 items-center justify-center text-[10px]" class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
class:text-muted-foreground={!rootActive} class:text-muted-foreground={!rootActive}
onclick={toggleRoot} onclick={toggleRoot}
title={rootExpanded ? 'Collapse' : 'Expand'} title={rootExpanded ? 'Collapse' : 'Expand'}
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'} aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
> >
{rootExpanded ? '▾' : '▸'} <ChevronRight
class="h-4 w-4 transition-transform duration-150 {rootExpanded ? 'rotate-90' : ''}"
/>
</button> </button>
{:else} {:else}
<!-- Spacer keeps chevronless rows aligned with their chevroned <!-- Spacer keeps chevronless rows aligned with their chevroned
peers, so labels share a common left edge across the sidebar. --> peers, so labels share a common left edge across the sidebar. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span> <span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
{/if} {/if}
<button <button
type="button" type="button"
@@ -698,9 +731,11 @@
aria-expanded={tagsExpanded} aria-expanded={tagsExpanded}
> >
<span <span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground" class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
> >
{tagsExpanded ? '▾' : '▸'} <ChevronRight
class="h-4 w-4 transition-transform duration-150 {tagsExpanded ? 'rotate-90' : ''}"
/>
</span> </span>
<span class="flex min-w-0 flex-1 items-center pl-1"> <span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span> <span class="truncate">Tags</span>
@@ -766,9 +801,11 @@
aria-expanded={reviewExpanded} aria-expanded={reviewExpanded}
> >
<span <span
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground" class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
> >
{reviewExpanded ? '▾' : '▸'} <ChevronRight
class="h-4 w-4 transition-transform duration-150 {reviewExpanded ? 'rotate-90' : ''}"
/>
</span> </span>
<span class="flex min-w-0 flex-1 items-center pl-1"> <span class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Review</span> <span class="truncate">Review</span>

View File

@@ -33,8 +33,7 @@
doneBulk, doneBulk,
removedBulk, removedBulk,
failBulk, failBulk,
markRemoved, markRemoved
clearRemoved
} from '$lib/stores/bulkAction.svelte'; } from '$lib/stores/bulkAction.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte'; import { Layers } from 'lucide-svelte';
@@ -156,15 +155,14 @@
throw e; throw e;
} finally { } finally {
busy = false; busy = false;
const settled = Promise.all([ void qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['photos'] }), void qc.invalidateQueries({ queryKey: ['marks'] });
qc.invalidateQueries({ queryKey: ['marks'] }), void qc.invalidateQueries({ queryKey: ['review-groups'] });
qc.invalidateQueries({ queryKey: ['review-groups'] }) // The optimistic-removal overlay (removedIds) is reconciled against
]); // the cache in +page.svelte — each id drops once the fresh, archived-
// Clear the optimistic-removal overlay only once the refetch has // filtered page has actually replaced it. Clearing here off this
// landed, so tiles never flash back in before the fresh (archived- // action's own settle raced other in-flight removals and flashed
// filtered) page replaces the old one. // tiles back in.
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
} }
} }

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { queryClient } from '$lib/queryClient';
import { isAuthenticated, session } from './session.svelte'; import { isAuthenticated, session } from './session.svelte';
/** /**
@@ -44,6 +45,27 @@ let lastFileUpdateAt = 0;
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null; let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
let pendingFileName: string | undefined; let pendingFileName: string | undefined;
// Newly indexed photos sort newest-first, so they land at the top of the
// timeline. Refetch the photos query as files stream in so the user watches
// new tiles arrive without a manual reload — but on a much coarser cadence
// than the per-file pill throttle, since a timeline refetch is far heavier
// than a label swap. Tracked independently of `lastFileUpdateAt` so the two
// throttles don't interfere.
const PHOTOS_REFETCH_THROTTLE_MS = 2000;
let lastPhotosInvalidateAt = 0;
function invalidatePhotosGrid(): void {
if (!browser || !isAuthenticated()) return;
void queryClient.invalidateQueries({ queryKey: ['photos'] });
}
function invalidatePhotosGridThrottled(): void {
const now = Date.now();
if (now - lastPhotosInvalidateAt < PHOTOS_REFETCH_THROTTLE_MS) return;
lastPhotosInvalidateAt = now;
invalidatePhotosGrid();
}
function url(): string { function url(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/api/v1/ws`; return `${proto}//${location.host}/api/v1/ws`;
@@ -134,6 +156,8 @@ function handleMessage(raw: string): void {
const fileName = const fileName =
(data.fileName as string | undefined) ?? (data.baseName as string | undefined); (data.fileName as string | undefined) ?? (data.baseName as string | undefined);
setActiveThrottled('Indexing', fileName); setActiveThrottled('Indexing', fileName);
// Stream newly indexed files into the grid as the scan runs.
invalidatePhotosGridThrottled();
return; return;
} }
case 'index.updating': { case 'index.updating': {
@@ -148,6 +172,8 @@ function handleMessage(raw: string): void {
case 'index.completed': { case 'index.completed': {
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined; const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete'); setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
// Final refetch so the grid lands on the fully-indexed result.
invalidatePhotosGrid();
return; return;
} }
default: default:

View File

@@ -37,7 +37,7 @@
setFocused, setFocused,
setOrder, setOrder,
} from "$lib/stores/selection.svelte"; } from "$lib/stores/selection.svelte";
import { removedIds } from "$lib/stores/bulkAction.svelte"; import { removedIds, clearRemoved } from "$lib/stores/bulkAction.svelte";
import { import {
openPreview, openPreview,
setRightSidebarWidth, setRightSidebarWidth,
@@ -283,6 +283,20 @@
} }
const pageCount = $derived(photosQuery.data?.pages.length ?? 0); const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
// Reconcile the optimistic-removal overlay against the actual cache.
// `removedIds` hides a tile while its photo is still present in a loaded
// page; we drop an id from the set only once it has genuinely left the
// freshly-deduped cache (i.e. every page that held it has refetched
// without it). Driving the clear from the data — rather than from each
// archive action's invalidation promise — removes the race where settling
// one action's refetch un-hid a photo that other, still-stale pages
// continued to carry, making archived tiles flash back into the grid.
$effect(() => {
const present = new Set(dedupedAll.map((p) => p.UID));
const gone = [...removedIds].filter((id) => !present.has(id));
if (gone.length) clearRemoved(gone);
});
$effect(() => { $effect(() => {
setOrder(photos.map((p) => p.UID)); setOrder(photos.map((p) => p.UID));
}); });