6 Commits

Author SHA1 Message Date
a52f171946 fix(folders): translate BasePath for create/rename/delete to fix "invalid path"
The sidebar shows user-relative paths (BasePath stripped) but the sidecar
operates on originals-relative paths. Folder create/rename/delete passed the
stripped path straight through, so a BasePath user's ops resolved to the wrong
directory and the sidecar returned "invalid path". Wrap outgoing paths with
toOriginalsPath and map returned paths back with toUserPath, matching the move
flow. Identity for admin accounts (empty BasePath).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
e124809ad5 fix(move): move every originals file of a photo, not just the primary
Videos, Live Photos, and RAW+JPG pairs keep several files under Root "/". The
old movePhotoFiles moved only the primary (often the poster JPG), orphaning
the .mov: PhotoPrism then saw the photo as moved (dropped from the grid) while
the video stayed behind and broke. Move the whole originals group under one
shared stem (new uniqueStem helper) so siblings re-stack after reindex; fail
the photo and report it if any sibling can't move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
ad6e733622 fix(move): close the dialog when a move starts so header progress shows
The move dialog held its full-screen overlay open for the whole operation,
hiding exactly the header reindex/status pill the user waits on. Snapshot the
draft state, closeMove() up front, and run the move in the background with a
toast.loading→success/error — mirrors the archive flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:06:18 +02:00
6d9b236ef6 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>
2026-06-21 23:48:18 +02:00
669e5fde33 Merge pull request 'feat(move): "move to folder" for grid selections, folders, and m shortcut' (#3) from claude/infallible-sammet-3471fa into main
Reviewed-on: #3
2026-06-18 00:40:25 +02:00
277fdc5a53 Merge pull request 'feat(archive): red-cross flash + instant tile removal on archive/delete' (#2) from claude/infallible-sammet-3471fa into main
Reviewed-on: #2
2026-06-17 23:58:21 +02:00
9 changed files with 302 additions and 112 deletions

View File

@@ -108,6 +108,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) {
return "", "", false return "", "", false
} }
// uniqueStem finds a base name (extension stripped) that is free for *every*
// extension in `exts` under destDir, appending `-1`, `-2`, … on collision —
// the multi-file analogue of uniqueName. Moving a photo's originals siblings
// (e.g. IMG_1234.JPG + IMG_1234.MOV) under a single shared stem keeps
// PhotoPrism stacking them as one photo after reindex; picking the stem once
// for the whole group is what stops the video from being orphaned under a
// differently-suffixed name than its poster. Caps at 1000 attempts to match
// uniqueName. The passed extensions keep their on-disk case (we compare
// case-sensitively via os.Stat, which is correct on the case-sensitive
// volumes PhotoPrism targets).
func uniqueStem(destDir, primaryBase string, exts []string) (stem string, ok bool) {
base := strings.TrimSuffix(primaryBase, filepath.Ext(primaryBase))
for i := 0; i < 1000; i++ {
candidate := base
if i > 0 {
candidate = base + "-" + itoa(i)
}
free := true
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(destDir, candidate+ext)); !errors.Is(err, os.ErrNotExist) {
free = false
break
}
}
if free {
return candidate, true
}
}
return "", false
}
// itoa is the tiny stdlib-free formatter we use inside hot loops. // itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string { func itoa(n int) string {
if n == 0 { if n == 0 {

View File

@@ -165,53 +165,87 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
errs = []heapErr{} errs = []heapErr{}
for _, photo := range photos { for _, photo := range photos {
// Pick the file to physically move. PhotoPrism's "primary" file // Gather *every* originals-rooted file of the photo, not just the
// for a HEIC photo is the generated `.HEIC.jpg` preview that // primary. A video, Live Photo, or RAW+JPG pair keeps several files
// lives in storage/sidecar (Root=="sidecar"), not in originals // under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
// — moving that path would fail "file missing on disk" every // must travel together — moving only the primary orphans the rest, so
// time. Prefer the primary that lives in originals (Root=="/") // the photo looks "moved" in PhotoPrism (the poster defines its path)
// and fall back to the first originals-rooted file. PhotoPrism // while the actual video is left behind and silently breaks. Sidecar-
// regenerates sidecars on reindex, so they don't need to follow. // rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
var file ppFile // on reindex and intentionally skipped. Pick the stem from the primary
found := false // (or the first originals file) so the siblings re-stack under one name.
var group []ppFile
var primary ppFile
havePrimary := false
for _, f := range photo.Files { for _, f := range photo.Files {
if f.Root == "/" && f.Primary { if f.Root != "/" {
file, found = f, true continue
break }
group = append(group, f)
if f.Primary && !havePrimary {
primary, havePrimary = f, true
} }
} }
if !found { if len(group) == 0 {
for _, f := range photo.Files {
if f.Root == "/" {
file, found = f, true
break
}
}
}
if !found {
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"}) errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
continue continue
} }
srcRel := file.Name if !havePrimary {
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel) primary = group[0]
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
continue
} }
st, statErr := os.Stat(srcAbs)
if statErr != nil || !st.Mode().IsRegular() { // Choose one collision-free stem for the whole group up front, so the
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"}) // siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
continue exts := make([]string, 0, len(group))
extSeen := map[string]struct{}{}
for _, f := range group {
ext := filepath.Ext(f.Name)
if _, dup := extSeen[ext]; !dup {
extSeen[ext] = struct{}{}
exts = append(exts, ext)
} }
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
} }
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs)) stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
if !ok { if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"}) errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue continue
} }
// Move/copy each sibling. A failure on any one fails the whole photo
// (surfaced in errs) rather than leaving a half-moved stack unreported.
var failure string
movedAny := false
usedNames := map[string]struct{}{}
for _, f := range group {
srcRel := f.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
failure = "path escapes originals"
break
}
st, statErr := os.Stat(srcAbs)
if statErr != nil || !st.Mode().IsRegular() {
failure = "file missing on disk"
break
}
if filepath.Dir(srcAbs) == destAbs {
// Already in the target folder — nothing to do for this sibling,
// but the photo isn't an error just because one file is in place.
continue
}
name := stem + filepath.Ext(srcAbs)
// Two originals files sharing an extension (rare) would collide on
// the shared stem; keep the extra one's own unique name so neither
// overwrites the other.
if _, clash := usedNames[name]; clash {
_, n, uok := uniqueName(destAbs, filepath.Base(srcAbs))
if !uok {
failure = "too many collisions"
break
}
name = n
}
usedNames[name] = struct{}{}
dstAbs := filepath.Join(destAbs, name) dstAbs := filepath.Join(destAbs, name)
if mode == "move" { if mode == "move" {
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil { if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
@@ -219,23 +253,36 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
// copy+remove so a library that spans filesystems still // copy+remove so a library that spans filesystems still
// works. // works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil { if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.Error()}) failure = mvErr.Error()
continue break
} }
if err2 := os.Remove(srcAbs); err2 != nil { if err2 := os.Remove(srcAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()}) failure = "rename ok, source remove failed: " + err2.Error()
continue break
} }
} }
moved++
} else { } else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil { if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()}) failure = cpErr.Error()
break
}
}
movedAny = true
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
if failure != "" {
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
continue continue
} }
if !movedAny {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
if mode == "move" {
moved++
} else {
copied++ copied++
} }
sourceParents[filepath.Dir(srcRel)] = struct{}{}
} }
// Reindex the destination + every source parent so PhotoPrism's DB // Reindex the destination + every source parent so PhotoPrism's DB

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,
@@ -42,14 +43,22 @@
type Section, type Section,
type TagCategory type TagCategory
} from '$lib/stores/filters.svelte'; } from '$lib/stores/filters.svelte';
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte'; import {
isAuthenticated,
session,
userBasePath,
toOriginalsPath,
toUserPath
} 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 +68,7 @@
LogOut, LogOut,
Moon, Moon,
Pencil, Pencil,
RefreshCw,
Settings, Settings,
Sun, Sun,
Trash2, Trash2,
@@ -281,10 +291,15 @@
} }
const createFolderMut = createMutation(() => ({ const createFolderMut = createMutation(() => ({
mutationFn: (relPath: string) => createFolder(relPath), // The sidebar deals in user-relative paths (BasePath stripped); the
// sidecar operates on originals-relative paths. Translate on the way
// out (toOriginalsPath) and back for display (toUserPath), exactly like
// the move flow — otherwise a BasePath user's folder ops resolve to the
// wrong directory and the sidecar returns "invalid path".
mutationFn: (relPath: string) => createFolder(toOriginalsPath(relPath)),
onSuccess: (r) => { onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`); toast.success(`Folder created: ${toUserPath(r.path)}`);
}, },
onError: (err) => onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder') toast.error(err instanceof Error ? err.message : 'Could not create folder')
@@ -292,36 +307,57 @@
const renameFolderMut = createMutation(() => ({ const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) => mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName), renameFolder(toOriginalsPath(args.rel), args.newName),
onSuccess: (r) => { onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
// Handler returns originals-relative paths; map back to the UI's
// user-relative space before comparing/navigating.
const oldUi = toUserPath(r.oldPath);
const newUi = toUserPath(r.newPath);
// If the active folder filter was on this folder, follow the rename. // If the active folder filter was on this folder, follow the rename.
if (filters.folderPath === r.oldPath) { if (filters.folderPath === oldUi) {
setFolderPath(r.newPath); setFolderPath(newUi);
const params = new URLSearchParams({ folder: r.newPath }); const params = new URLSearchParams({ folder: newUi });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true }); void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
} }
toast.success(`Renamed: ${r.oldPath}${r.newPath}`); toast.success(`Renamed: ${oldUi}${newUi}`);
}, },
onError: (err) => onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed') toast.error(err instanceof Error ? err.message : 'Rename failed')
})); }));
const deleteFolderMut = createMutation(() => ({ const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel), mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
onSuccess: (r) => { onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
if (filters.folderPath && filters.folderPath.startsWith(r.path)) { const ui = toUserPath(r.path);
if (filters.folderPath && filters.folderPath.startsWith(ui)) {
setFolderPath(null); setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true }); void goto('/', { keepFocus: true, noScroll: true });
} }
toast.success(`Folder deleted: ${r.path}`); toast.success(`Folder deleted: ${ui}`);
}, },
onError: (err) => onError: (err) =>
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 +520,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 +565,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 +747,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 +817,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

@@ -120,19 +120,38 @@
// (nothing picked) so a falsy check doesn't wrongly block root. // (nothing picked) so a falsy check doesn't wrongly block root.
if (!s || pickedPath === null || submitting) return; if (!s || pickedPath === null || submitting) return;
submitting = true; submitting = true;
// Snapshot the draft before closing — closeMove() nulls the subject,
// which the reset effect uses to wipe pickedPath/mode/subfolder.
const dest = pickedPath;
const opMode = mode;
const sub = subfolder.trim() || null;
const delHeap = mode === 'move' && deleteHeap;
const labelName = folderName;
// Close the dialog immediately and run the move in the background. The
// move can be slow (a folder/heap with many files triggers a real
// disk move + reindex) and its progress surfaces in the header pill;
// keeping the modal + overlay up would hide exactly the feedback the
// user is waiting on. Mirrors the archive flow (toast + header pill).
closeMove();
const verbing = opMode === 'copy' ? 'Copying' : 'Moving';
const tid = toast.loading(`${verbing}…`);
try { try {
if (s.kind === 'heap') { if (s.kind === 'heap') {
const r = await convertHeap(s.heap.UID, { const r = await convertHeap(s.heap.UID, {
targetFolder: toOriginalsPath(pickedPath), targetFolder: toOriginalsPath(dest),
mode, mode: opMode,
subfolder: subfolder.trim() || null, subfolder: sub,
deleteHeap: mode === 'move' && deleteHeap deleteHeap: delHeap
}); });
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] }); qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success( toast.success(
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length) moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid }
); );
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) { if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
setSection('all-photos'); setSection('all-photos');
@@ -141,32 +160,30 @@
} else if (s.kind === 'photos') { } else if (s.kind === 'photos') {
const r = await movePhotosToFolder({ const r = await movePhotosToFolder({
uids: s.uids, uids: s.uids,
targetFolder: toOriginalsPath(pickedPath), targetFolder: toOriginalsPath(dest),
mode, mode: opMode,
subfolder: subfolder.trim() || null subfolder: sub
}); });
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
toast.success( toast.success(
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length) moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid }
); );
} else { } else {
// Folder reparent (move only). Translate both the folder's own // Folder reparent (move only). Translate both the folder's own
// path and the destination parent to originals-relative for the // path and the destination parent to originals-relative for the
// sidecar, which moves real directories on disk. // sidecar, which moves real directories on disk.
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(pickedPath)); await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = pickedPath === '' ? folderName : `${pickedPath}/${folderName}`; const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
toast.success(`Moved ${folderName}${pickedPath === '' ? '/' : pickedPath}`); toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, { id: tid });
// If we just moved the folder the timeline is showing, follow it. // If we just moved the folder the timeline is showing, follow it.
if (filters.folderPath === s.path) setFolderPath(newUiPath); if (filters.folderPath === s.path) setFolderPath(newUiPath);
} }
closeMove();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed'); toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
} finally {
submitting = false;
} }
} }
</script> </script>

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