8 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
b2b6060872 feat(move): "move to folder" for grid selections, folders, and m shortcut
Extends the heap-only "move to folder" action to grid single/bulk
selections, sidebar folders, and an `m` keyboard shortcut — all through
one shared dialog driven by a moveDialog store.

Backend (sidecar):
- Extract the heap move/copy + reindex loop into a reusable movePhotoFiles
  helper plus resolveMoveTarget
- POST /photos/move: move/copy an arbitrary UID list into a folder
- POST /folders/:rel/move: reparent a folder dir (whole subtree) under a
  new parent, guarding against moving into itself/a descendant

Frontend:
- moveDialog store + generalized MoveToFolderDialog (heap | photos | folder
  subjects); mounted once in +layout.svelte. Replaces HeapConvertDialog
- movePhotosToFolder / moveFolder service fns
- Entry points: BulkActionBar button, gridKeyNav `m`, FolderTree kebab,
  heap kebab — all call openMove()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:10:19 +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
5be6fd9047 feat(archive): red-cross flash + instant tile removal on archive/delete
Archive/delete now flash a red cross then drop tiles from the grid
immediately, instead of a green check that lingered until the slow
server-reconcile refetch landed. Keyboard `x` archive previously never
called markRemoved, so tiles only vanished on refetch — that lag is gone.

- Add 'removed' bulk state + removedBulk() helper (red cross overlay)
- gridKeyNav archive/delete: removedBulk -> 500ms flash -> markRemoved,
  clearRemoved once refetch settles; restore stays green check
- BulkActionBar: BulkConfig.removing routes archive/delete through the
  red flash; approve/restore/label/note unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:45:38 +02:00
17 changed files with 1029 additions and 430 deletions

View File

@@ -108,6 +108,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) {
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.
func itoa(n int) string {
if n == 0 {

View File

@@ -83,32 +83,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
subfolder = s
}
// Resolve destination. resolveUnderRoot ensures the target lives
// inside ORIGINALS_ROOT and that its parent is a real directory.
// Empty / "/" / "." are valid here — they mean "drop these into
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
// rejects those for safety, so handle the root case explicitly.
var targetAbs string
trimmed := strings.Trim(body.TargetFolder, "/")
if trimmed == "" || trimmed == "." {
targetAbs = cfg.OriginalsRoot
} else {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
targetAbs = abs
// Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
// "drop these into originals/ itself" (the modal's "Root" option).
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
destAbs := targetAbs
if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder)
if err := os.MkdirAll(destAbs, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
// Pull the heap's photos via the q=album:UID query. count=1000 covers
// every realistic heap; merged=true expands stacked variants so we
// move the JPG/HEIC sibling alongside the primary.
@@ -129,107 +110,10 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
return
}
sourceParents := map[string]struct{}{}
errs := []heapErr{}
moved, copied := 0, 0
for _, photo := range photos {
// Pick the file to physically move. PhotoPrism's "primary" file
// for a HEIC photo is the generated `.HEIC.jpg` preview that
// lives in storage/sidecar (Root=="sidecar"), not in originals
// — moving that path would fail "file missing on disk" every
// time. Prefer the primary that lives in originals (Root=="/")
// and fall back to the first originals-rooted file. PhotoPrism
// regenerates sidecars on reindex, so they don't need to follow.
var file ppFile
found := false
for _, f := range photo.Files {
if f.Root == "/" && f.Primary {
file, found = f, true
break
}
}
if !found {
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"})
continue
}
srcRel := file.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
continue
}
st, err := os.Stat(srcAbs)
if err != nil || !st.Mode().IsRegular() {
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
continue
}
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if err := os.Rename(srcAbs, dstAbs); err != nil {
// Cross-device renames fail with EXDEV — fall back to
// copy+remove so a library that spans filesystems still
// works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
if err2 := os.Remove(srcAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
continue
}
}
moved++
} else {
if err := copyFile(srcAbs, dstAbs); err != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
copied++
}
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
// Reindex the destination + every source parent so PhotoPrism's
// DB catches up. We block on these so the response only goes out
// after the index reflects the move — callers (the frontend's
// invalidateQueries refetch in particular) need the next /photos
// fetch to return the moved files, otherwise the folder view
// looks unchanged. PhotoPrism's index endpoint serialises calls
// internally; running them sequentially matches that contract
// without surprising the server.
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
paths := map[string]struct{}{destRel: {}}
for p := range sourceParents {
paths[p] = struct{}{}
}
if subfolder != "" {
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
paths[parent] = struct{}{}
}
for p := range paths {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
heapDeleted := false
@@ -260,3 +144,180 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
})
}
}
// movePhotoFiles moves (or copies) each photo's originals-rooted primary file
// into targetAbs — optionally into `subfolder` under it — then blocks on a
// PhotoPrism reindex of the destination plus every source parent so the next
// /photos fetch reflects the move. Shared by handleHeapConvert (album-scoped)
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
// but move them identically. Returns per-photo errors in `errs`; the returned
// top-level error is only for a fatal precondition (subfolder mkdir failed).
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
destAbs := targetAbs
if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder)
if e := os.MkdirAll(destAbs, 0o755); e != nil {
return 0, 0, nil, e
}
}
sourceParents := map[string]struct{}{}
errs = []heapErr{}
for _, photo := range photos {
// Gather *every* originals-rooted file of the photo, not just the
// primary. A video, Live Photo, or RAW+JPG pair keeps several files
// under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
// must travel together — moving only the primary orphans the rest, so
// the photo looks "moved" in PhotoPrism (the poster defines its path)
// while the actual video is left behind and silently breaks. Sidecar-
// rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
// on reindex and intentionally skipped. Pick the stem from the primary
// (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 {
if f.Root != "/" {
continue
}
group = append(group, f)
if f.Primary && !havePrimary {
primary, havePrimary = f, true
}
}
if len(group) == 0 {
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
continue
}
if !havePrimary {
primary = group[0]
}
// Choose one collision-free stem for the whole group up front, so the
// siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
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)
}
}
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
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)
if mode == "move" {
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
// Cross-device renames fail with EXDEV — fall back to
// copy+remove so a library that spans filesystems still
// works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
failure = mvErr.Error()
break
}
if err2 := os.Remove(srcAbs); err2 != nil {
failure = "rename ok, source remove failed: " + err2.Error()
break
}
}
} else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
failure = cpErr.Error()
break
}
}
movedAny = true
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
if failure != "" {
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
continue
}
if !movedAny {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
if mode == "move" {
moved++
} else {
copied++
}
}
// Reindex the destination + every source parent so PhotoPrism's DB
// catches up. We block on these so the response only goes out after the
// index reflects the move — the frontend's invalidateQueries refetch
// needs the next /photos fetch to return the moved files, otherwise the
// folder view looks unchanged. PhotoPrism's index endpoint serialises
// calls internally; running them sequentially matches that contract.
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
paths := map[string]struct{}{destRel: {}}
for p := range sourceParents {
paths[p] = struct{}{}
}
if subfolder != "" {
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
paths[parent] = struct{}{}
}
for p := range paths {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
}
return moved, copied, errs, nil
}
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
// mean the Originals root itself) into a validated absolute path under the
// root. Shared by the heap-convert and photos-move destination handling.
func resolveMoveTarget(cfg *Config, targetFolder string) (string, error) {
trimmed := strings.Trim(targetFolder, "/")
if trimmed == "" || trimmed == "." {
return cfg.OriginalsRoot, nil
}
return resolveUnderRoot(cfg.OriginalsRoot, targetFolder, true)
}

174
sidecar/handlers_move.go Normal file
View File

@@ -0,0 +1,174 @@
package main
import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
type photosMoveBody struct {
UIDs []string `json:"uids"`
TargetFolder string `json:"targetFolder"`
Mode string `json:"mode"` // "move" or "copy"
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
}
// handlePhotosMove moves/copies an arbitrary list of photos (by UID) into a
// folder under originals/. Mirrors handleHeapConvert but resolves the photos
// from a UID list instead of an album query, then shares movePhotoFiles for
// the on-disk work + reindex. Backs the grid's "Move to folder" action.
func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body photosMoveBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
if len(body.UIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no uids"})
return
}
mode := body.Mode
if mode != "copy" {
mode = "move"
}
var subfolder string
if body.Subfolder != "" {
s, ok := sanitizeFilename(body.Subfolder)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
return
}
subfolder = s
}
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
// Resolve the photos via a single q=uid:a|b|c query. PhotoPrism's
// search treats `|` as OR within a filter value, so one round-trip
// covers the whole selection; merged=true pulls stacked variants so
// the JPG/HEIC sibling travels with its primary.
q := url.QueryEscape("uid:" + strings.Join(body.UIDs, "|"))
listURL := "/api/v1/photos?q=" + q + "&count=" + itoa(len(body.UIDs)) + "&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return
}
var photos []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("photos.move",
"requested", len(body.UIDs),
"mode", mode,
"moved", moved,
"copied", copied,
"errors", len(errs),
)
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"errors": errs,
})
}
}
type folderMoveBody struct {
// Originals-relative destination parent. ""/"/"/"." mean the root.
TargetParent string `json:"targetParent"`
}
// handleFolderMove reparents a folder: moves the directory (and everything in
// it) under a different parent, keeping its own name. Mirrors
// handleFolderRename but the destination is a parent folder rather than a new
// name. A whole-tree os.Rename preserves subfolder structure.
func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
var body folderMoveBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
targetParentAbs, err := resolveMoveTarget(cfg, body.TargetParent)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
return
}
// Can't move a folder into itself or one of its own descendants.
if sameOrUnder(targetParentAbs, oldAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
return
}
newAbs := filepath.Join(targetParentAbs, filepath.Base(oldAbs))
if newAbs == oldAbs {
c.JSON(http.StatusBadRequest, gin.H{"error": "already in that folder"})
return
}
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
return
}
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
return
}
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
slog.Info("folder.move", "from", oldRel, "to", newRel)
// Reindex both the old and new parents so PhotoPrism drops the moved
// rows from the source view and picks them up under the destination.
fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
fireReindex(cfg, pp, token, "/"+filepath.Dir(newRel))
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldPath": oldRel,
"newPath": newRel,
})
}
}

View File

@@ -91,9 +91,11 @@ func main() {
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))

View File

@@ -15,6 +15,7 @@ import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism';
import { queryClient } from '$lib/queryClient';
import { filters } from '$lib/stores/filters.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import {
clearBulkToFirst,
clearSelection,
@@ -27,7 +28,14 @@ import {
toggle
} from '$lib/stores/selection.svelte';
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
import { startBulk, doneBulk, failBulk, setDetail } from '$lib/stores/bulkAction.svelte';
import {
startBulk,
doneBulk,
removedBulk,
failBulk,
setDetail,
markRemoved
} from '$lib/stores/bulkAction.svelte';
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
/**
@@ -163,6 +171,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return [];
}
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
const ids = cullTargets();
if (ids.length === 0) {
@@ -193,11 +203,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid });
return;
}
doneBulk(doneLabel, ids);
focusAfter(ids);
clearSelection();
invalidatePhotos(ids);
void queryClient.invalidateQueries({ queryKey: ['marks'] });
if (target) {
// Destructive removal: flash a red cross, then pull the tiles out of
// the grid immediately (markRemoved) rather than waiting on the slow
// server-reconcile refetch. The grid reconciles `removedIds` against
// 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);
focusAfter(ids);
clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
void queryClient.invalidateQueries({ queryKey: ['photos'] });
void queryClient.invalidateQueries({ queryKey: ['marks'] });
} else {
doneBulk(doneLabel, ids);
focusAfter(ids);
clearSelection();
invalidatePhotos(ids);
void queryClient.invalidateQueries({ queryKey: ['marks'] });
}
toast.success(doneLabel, { id: tid });
pushUndo(doneLabel, async () => {
if (target) await batchRestore(ids);
@@ -233,10 +261,16 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
return;
}
doneBulk(`Deleted ${ids.length}`, ids);
// Destructive removal — same red-cross flash then immediate hide as archive.
removedBulk(`Deleted ${ids.length}`, ids);
focusAfter(ids);
clearSelection();
await delay(500);
markRemoved(ids);
invalidatePhotos(ids);
// 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'] });
toast.success(`Deleted ${ids.length}`, { id: tid });
}
@@ -522,6 +556,23 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault();
void toggleArchive('restore');
return;
case 'm':
case 'M': {
if (meta || shift) return;
e.preventDefault();
// Move the cull targets to a folder — opens the shared
// move-to-folder dialog (same one the bar button and the
// heap/folder kebabs use).
const moveIds = cullTargets();
if (moveIds.length === 0) {
toast.message('Nothing to move', {
description: 'Click a photo or select some first'
});
return;
}
openMove({ kind: 'photos', uids: moveIds });
return;
}
case 's':
case 'S':
if (meta || shift) return;

View File

@@ -41,7 +41,7 @@
import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import { ChevronRight, FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
@@ -50,10 +50,13 @@
depth?: number;
onPick: (path: string) => void;
/** Mutating callbacks are only required when readonly !== true. The
* picker (HeapConvertDialog) reuses the tree just for `onPick`. */
* picker (MoveToFolderDialog) reuses the tree just for `onPick`. */
onRename?: (path: string) => void;
onDelete?: (path: string) => void;
onCreateChild?: (parent: string) => void;
/** Reparent this folder under a chosen destination (opens the shared
* move-to-folder dialog). Sidebar only; the readonly picker omits it. */
onMove?: (path: string) => void;
/** Read-only mode: hides the kebab menu and disables double-click
* rename, so the tree can be reused as a folder picker. */
readonly?: boolean;
@@ -74,6 +77,7 @@
onRename,
onDelete,
onCreateChild,
onMove,
readonly = false,
selectedPath,
counts
@@ -160,19 +164,21 @@
>
{#if hasChildren}
<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}
onclick={() => toggle(node.path)}
title={open ? 'Collapse' : 'Expand'}
aria-label={open ? 'Collapse' : 'Expand'}
>
{open ? '▾' : '▸'}
<ChevronRight
class="h-4 w-4 transition-transform duration-150 {open ? 'rotate-90' : ''}"
/>
</button>
{:else}
<!-- Spacer keeps childless siblings aligned with their chevroned
peers at every depth, so labels share a common left edge
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}
<!--
Count badge lives INSIDE the button so the entire row (label
@@ -219,6 +225,13 @@
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
Rename
</Item>
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => onMove?.(node.path)}
>
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
Move to folder…
</Item>
<Separator class="my-1 h-px bg-border" />
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] text-destructive outline-none hover:bg-destructive/10 focus:bg-destructive/10"
@@ -239,6 +252,7 @@
{onRename}
{onDelete}
{onCreateChild}
{onMove}
{readonly}
{selectedPath}
{counts}

View File

@@ -1,249 +0,0 @@
<!--
Move/copy every photo in a heap into a folder under originals/.
Picker reuses the existing FolderTree in readonly mode; the dialog owns
the selection (`pickedPath`) so it doesn't conflict with the global
folderPath filter the sidebar drives.
Submit goes to the sidecar's POST /albums/:uid/convert. On success we
invalidate the photos / folders / heaps queries so the timeline and
sidebar refresh; if the heap was deleted and was active, route home.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Dialog } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
convertHeap,
listFolders,
type HeapConvertBody,
type HeapConvertResult,
type PpAlbum,
type PpFolder
} from '$lib/services/photoprism';
import { filters, setSection } from '$lib/stores/filters.svelte';
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props {
heap: PpAlbum | null;
onClose: () => void;
}
let { heap, onClose }: Props = $props();
const qc = useQueryClient();
// Reuse the same folders cache the sidebar uses — same key so we share
// the in-flight request, and the picker invalidates it on success.
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryFn: listFolders,
enabled: isAuthenticated()
}));
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
// Reset draft state whenever a new heap is picked (or the dialog closes
// and reopens). $effect runs after the prop change, so the form is
// blank on every fresh open.
$effect(() => {
void heap;
pickedPath = null;
mode = 'move';
subfolder = '';
deleteHeap = false;
});
const convertMut = createMutation(() => ({
mutationFn: (args: { uid: string; body: HeapConvertBody }) =>
convertHeap(args.uid, args.body),
onSuccess: (result: HeapConvertResult, vars) => {
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] });
const verb = mode === 'copy' ? 'Copied' : 'Moved';
const count = mode === 'copy' ? result.copied : result.moved;
const tail =
result.errors.length > 0
? ` · ${result.errors.length} skipped`
: '';
toast.success(`${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`);
// If the heap got deleted and we were viewing it, fall back home.
if (
result.heap_deleted &&
filters.section === 'heap' &&
filters.heapUid === vars.uid
) {
setSection('all-photos');
void goto('/', { keepFocus: true, noScroll: true });
}
onClose();
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Convert failed')
}));
function submit() {
// pickedPath === '' is the root selection; falsy check would
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
if (!heap || pickedPath === null) return;
// pickedPath is user-relative (listFolders strips BasePath). The
// sidecar moves files on disk so it needs a server-absolute path —
// translate before submitting.
convertMut.mutate({
uid: heap.UID,
body: {
targetFolder: toOriginalsPath(pickedPath),
mode,
subfolder: subfolder.trim() || null,
deleteHeap: mode === 'move' && deleteHeap
}
});
}
// Copy mode doesn't change membership, so "delete heap after" is
// meaningless. Force-clear it when the user flips back to copy.
$effect(() => {
if (mode === 'copy' && deleteHeap) deleteHeap = false;
});
const open = $derived(heap !== null);
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
{mode === 'copy' ? 'Copy' : 'Move'} heap to folder
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
{heap?.Title ?? ''} · {heap?.PhotoCount ?? 0} photo{heap?.PhotoCount === 1
? ''
: 's'}
</Dialog.Description>
</div>
</div>
<!-- Folder picker. Readonly FolderTree so the user can't kebab/
rename their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Destination
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty
string is the sidecar's "root" sentinel — matches
resolveUnderRoot's special case in handlers_heap. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
/>
{/if}
</div>
</div>
<!-- Mode + options. Plain radio + checkbox; bits-ui has dedicated
primitives but inline form controls keep the dialog small. -->
<div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]">
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" />
Move
</label>
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="copy" />
Copy
</label>
</div>
<label class="flex flex-col gap-1 text-[12px]">
<span class="text-muted-foreground">
New subfolder (optional)
</span>
<input
type="text"
placeholder="e.g. {heap?.Title ?? 'My heap'}"
bind:value={subfolder}
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex items-center gap-1.5 text-[12px]">
<input
type="checkbox"
bind:checked={deleteHeap}
disabled={mode === 'copy'}
/>
<span class:text-muted-foreground={mode === 'copy'}>
Delete heap after move
</span>
</label>
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={onClose}
disabled={convertMut.isPending}
>
Cancel
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={pickedPath === null || convertMut.isPending}
>
{#if convertMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{mode === 'copy' ? 'Copy' : 'Move'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -19,6 +19,7 @@
renameFolder,
renameHeap,
scanCrossFolderDuplicates,
startIndex,
triggerDownload,
type CrossFolderScanResult,
type PpAlbum,
@@ -42,14 +43,22 @@
type Section,
type TagCategory
} 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 { indexer } from '$lib/stores/indexer.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
import SettingsDialog from './SettingsDialog.svelte';
import UsersDialog from './UsersDialog.svelte';
import {
ChevronRight,
Copy,
Download,
FolderInput,
@@ -59,6 +68,7 @@
LogOut,
Moon,
Pencil,
RefreshCw,
Settings,
Sun,
Trash2,
@@ -141,9 +151,6 @@
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap')
}));
// Heap currently being converted (move/copy to folder). Setting this
// mounts <HeapConvertDialog>; the dialog clears it on close.
let convertingHeap = $state<PpAlbum | null>(null);
// Library/admin settings dialog visibility.
let settingsOpen = $state(false);
@@ -284,10 +291,15 @@
}
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) => {
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(`Folder created: ${r.path}`);
toast.success(`Folder created: ${toUserPath(r.path)}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not create folder')
@@ -295,36 +307,57 @@
const renameFolderMut = createMutation(() => ({
mutationFn: (args: { rel: string; newName: string }) =>
renameFolder(args.rel, args.newName),
renameFolder(toOriginalsPath(args.rel), args.newName),
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ['folders'] });
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 (filters.folderPath === r.oldPath) {
setFolderPath(r.newPath);
const params = new URLSearchParams({ folder: r.newPath });
if (filters.folderPath === oldUi) {
setFolderPath(newUi);
const params = new URLSearchParams({ folder: newUi });
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
}
toast.success(`Renamed: ${r.oldPath}${r.newPath}`);
toast.success(`Renamed: ${oldUi}${newUi}`);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Rename failed')
}));
const deleteFolderMut = createMutation(() => ({
mutationFn: (rel: string) => deleteFolder(rel),
mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
onSuccess: (r) => {
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);
void goto('/', { keepFocus: true, noScroll: true });
}
toast.success(`Folder deleted: ${r.path}`);
toast.success(`Folder deleted: ${ui}`);
},
onError: (err) =>
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) {
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
if (!name) return;
@@ -487,6 +520,17 @@
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</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
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
@@ -521,18 +565,20 @@
{#if hasSubfolders}
<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}
onclick={toggleRoot}
title={rootExpanded ? 'Collapse' : 'Expand'}
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
>
{rootExpanded ? '▾' : '▸'}
<ChevronRight
class="h-4 w-4 transition-transform duration-150 {rootExpanded ? 'rotate-90' : ''}"
/>
</button>
{:else}
<!-- Spacer keeps chevronless rows aligned with their chevroned
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}
<button
type="button"
@@ -578,6 +624,7 @@
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
onMove={(path) => openMove({ kind: 'folder', path })}
/>
{/if}
</div>
@@ -653,7 +700,7 @@
</Item>
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => (convertingHeap = heap)}
onSelect={() => openMove({ kind: 'heap', heap })}
>
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
Move to folder…
@@ -700,9 +747,11 @@
aria-expanded={tagsExpanded}
>
<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 class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Tags</span>
@@ -768,9 +817,11 @@
aria-expanded={reviewExpanded}
>
<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 class="flex min-w-0 flex-1 items-center pl-1">
<span class="truncate">Review</span>
@@ -875,7 +926,6 @@
</footer>
</div>
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
<GeneralSettingsDialog
open={generalSettingsOpen}

View File

@@ -0,0 +1,311 @@
<!--
Move/copy photos into a folder under originals/ — the single dialog behind
every "move to folder" entry point (heap kebab, folder kebab, the grid's
BulkActionBar button, and the `m` shortcut). Driven by the moveDialog store
so the picker UI and the move/copy wiring live in exactly one place.
Three subjects:
• heap — move/copy an album's photos into a folder (optional subfolder,
optional delete-heap-after). The original behaviour.
• photos — move/copy a UID selection from the grid. Same options minus
delete-heap.
• folder — reparent a folder: move the directory (and its subfolders)
under a chosen destination parent. Move-only, no subfolder; the
folder keeps its own name. The picker excludes the folder
itself and its descendants.
Picker reuses the readonly FolderTree; the dialog owns the selection
(`pickedPath`) so it never fights the global folderPath filter.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { Dialog } from 'bits-ui';
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import {
convertHeap,
movePhotosToFolder,
moveFolder,
listFolders,
type PpFolder
} from '$lib/services/photoprism';
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte';
const qc = useQueryClient();
// Reuse the same folders cache the sidebar uses — same key so we share the
// in-flight request, and the picker invalidates it on success.
const foldersQuery = createQuery<PpFolder[]>(() => ({
queryKey: ['folders'],
queryFn: listFolders,
enabled: isAuthenticated()
}));
const subject = $derived(moveDialog.subject);
const kind = $derived(subject?.kind);
const open = $derived(subject !== null);
// For folder reparent, exclude the folder itself and everything under it —
// you can't move a directory into its own subtree.
const folderTree = $derived.by(() => {
const paths = (foldersQuery.data ?? []).map((f) => f.Path);
if (subject?.kind === 'folder') {
const self = subject.path;
return buildTree(paths.filter((p) => p !== self && !p.startsWith(self + '/')));
}
return buildTree(paths);
});
const showOptions = $derived(kind === 'heap' || kind === 'photos');
const showDeleteHeap = $derived(kind === 'heap');
const folderName = $derived(
subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : ''
);
const headerTitle = $derived.by(() => {
if (subject?.kind === 'folder') return 'Move folder';
const verb = mode === 'copy' ? 'Copy' : 'Move';
if (subject?.kind === 'heap') return `${verb} heap to folder`;
return `${verb} photos to folder`;
});
const headerDesc = $derived.by(() => {
if (subject?.kind === 'heap') {
const n = subject.heap.PhotoCount ?? 0;
return `${subject.heap.Title ?? ''} · ${n} photo${n === 1 ? '' : 's'}`;
}
if (subject?.kind === 'photos') {
const n = subject.uids.length;
return `${n} photo${n === 1 ? '' : 's'} selected`;
}
if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
return '';
});
let pickedPath = $state<string | null>(null);
let mode = $state<'move' | 'copy'>('move');
let subfolder = $state('');
let deleteHeap = $state(false);
let submitting = $state(false);
// Reset draft state whenever a new subject is picked (or the dialog closes
// and reopens), so the form is blank on every fresh open.
$effect(() => {
void subject;
pickedPath = null;
mode = 'move';
subfolder = '';
deleteHeap = false;
submitting = false;
});
// Copy mode doesn't change membership, so "delete heap after" is
// meaningless. Force-clear it when the user flips back to copy.
$effect(() => {
if (mode === 'copy' && deleteHeap) deleteHeap = false;
});
function moveSummary(verb: string, count: number, errors: number): string {
const tail = errors > 0 ? ` · ${errors} skipped` : '';
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`;
}
async function submit() {
const s = moveDialog.subject;
// pickedPath === '' is the root selection; distinguish it from `null`
// (nothing picked) so a falsy check doesn't wrongly block root.
if (!s || pickedPath === null || submitting) return;
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 {
if (s.kind === 'heap') {
const r = await convertHeap(s.heap.UID, {
targetFolder: toOriginalsPath(dest),
mode: opMode,
subfolder: sub,
deleteHeap: delHeap
});
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] });
toast.success(
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) {
setSection('all-photos');
void goto('/', { keepFocus: true, noScroll: true });
}
} else if (s.kind === 'photos') {
const r = await movePhotosToFolder({
uids: s.uids,
targetFolder: toOriginalsPath(dest),
mode: opMode,
subfolder: sub
});
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid }
);
} else {
// Folder reparent (move only). Translate both the folder's own
// path and the destination parent to originals-relative for the
// sidecar, which moves real directories on disk.
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, { id: tid });
// If we just moved the folder the timeline is showing, follow it.
if (filters.folderPath === s.path) setFolderPath(newUiPath);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
}
}
</script>
<Dialog.Root
{open}
onOpenChange={(o) => {
if (!o) closeMove();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
<div class="flex-1">
<Dialog.Title class="text-sm font-semibold leading-tight">
{headerTitle}
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
{headerDesc}
</Dialog.Description>
</div>
</div>
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{kind === 'folder' ? 'Destination parent' : 'Destination'}
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: drop straight into originals/ (the user's root)
without picking a subfolder. Empty string is the
sidecar's "root" sentinel. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
/>
{/if}
</div>
</div>
<!-- Move/copy + subfolder, hidden for folder reparent (always a move
that keeps the folder's own name). -->
{#if showOptions}
<div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]">
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" />
Move
</label>
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="copy" />
Copy
</label>
</div>
<label class="flex flex-col gap-1 text-[12px]">
<span class="text-muted-foreground">New subfolder (optional)</span>
<input
type="text"
placeholder="e.g. 2024-summer"
bind:value={subfolder}
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
/>
</label>
{#if showDeleteHeap}
<label class="flex items-center gap-1.5 text-[12px]">
<input type="checkbox" bind:checked={deleteHeap} disabled={mode === 'copy'} />
<span class:text-muted-foreground={mode === 'copy'}>Delete heap after move</span>
</label>
{/if}
</div>
{/if}
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={closeMove}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={pickedPath === null || submitting}
>
{#if submitting}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
{kind === 'folder' ? 'Move' : mode === 'copy' ? 'Copy' : 'Move'}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -26,13 +26,14 @@
import { filters } from '$lib/stores/filters.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
import { openMove } from '$lib/stores/moveDialog.svelte';
import {
startBulk,
setDetail,
doneBulk,
removedBulk,
failBulk,
markRemoved,
clearRemoved
markRemoved
} from '$lib/stores/bulkAction.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Layers } from 'lucide-svelte';
@@ -127,6 +128,9 @@
ids: string[];
label: string;
doneLabel: string;
/** Destructive removal (archive / delete): flash a red cross, then hide
* the tiles via markRemoved after the flash instead of green check. */
removing?: boolean;
}
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
@@ -135,8 +139,15 @@
try {
const result = await fn();
if (bulk) {
doneBulk(bulk.doneLabel, bulk.ids);
await delay(1000);
if (bulk.removing) {
// Destructive: red-cross flash, then pull tiles from the grid.
removedBulk(bulk.doneLabel, bulk.ids);
await delay(500);
markRemoved(bulk.ids);
} else {
doneBulk(bulk.doneLabel, bulk.ids);
await delay(1000);
}
}
return result;
} catch (e) {
@@ -144,15 +155,14 @@
throw e;
} finally {
busy = false;
const settled = Promise.all([
qc.invalidateQueries({ queryKey: ['photos'] }),
qc.invalidateQueries({ queryKey: ['marks'] }),
qc.invalidateQueries({ queryKey: ['review-groups'] })
]);
// Clear the optimistic-removal overlay only once the refetch has
// landed, so tiles never flash back in before the fresh (archived-
// filtered) page replaces the old one.
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
void qc.invalidateQueries({ queryKey: ['photos'] });
void qc.invalidateQueries({ queryKey: ['marks'] });
void 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-
// filtered page has actually replaced it. Clearing here off this
// action's own settle raced other in-flight removals and flashed
// tiles back in.
}
}
@@ -196,7 +206,6 @@
await withBusy(async () => {
try {
await batchArchive(ids);
markRemoved(ids);
pushUndo(`Archived ${ids.length}`, async () => {
await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
@@ -207,7 +216,7 @@
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
}
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}`, removing: true });
}
async function onDelete() {
@@ -222,14 +231,13 @@
await withBusy(async () => {
try {
await batchDelete(ids);
markRemoved(ids);
focusAfter(ids);
clearSelection();
toast.success(`Deleted ${ids.length}`, { id: tid });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
}
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}`, removing: true });
}
async function onRestore() {
@@ -446,6 +454,15 @@
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={() => openMove({ kind: 'photos', uids: snapshotIds() })}
title="Move selected photos to a folder"
>
Move to folder
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">M</kbd>
</button>
{/if}
<button
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"

View File

@@ -166,6 +166,13 @@
>
<Check class="h-7 w-7 text-white drop-shadow-md" />
</div>
{:else if bulkState === 'removed'}
<div
transition:fade={{ duration: 200 }}
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/70"
>
<X class="h-7 w-7 text-white drop-shadow-md" />
</div>
{:else if bulkState === 'error'}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
<X class="h-7 w-7 text-white drop-shadow-md" />

View File

@@ -1006,6 +1006,43 @@ export async function convertHeap(
return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
}
// ── Move arbitrary photos (by UID) to a folder ──────────────────────────────
// Same on-disk move/copy + reindex as convertHeap, but the sidecar resolves the
// photos from a UID list instead of an album. Backs the grid's move-to-folder.
export interface PhotosMoveBody {
uids: string[];
/** Originals-relative target folder. Empty string = originals root. */
targetFolder: string;
mode: 'move' | 'copy';
/** Optional subfolder to create under `targetFolder` and place files into. */
subfolder?: string | null;
}
export interface PhotosMoveResult {
moved: number;
copied: number;
errors: { uid: string; reason: string }[];
}
export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMoveResult> {
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
}
// ── Reparent a folder (move the directory under a different parent) ──────────
export interface FolderMoveResult {
ok: boolean;
oldPath: string;
newPath: string;
}
export async function moveFolder(rel: string, targetParent: string): Promise<FolderMoveResult> {
return callSidecar('POST', `/folders/${encodeURIComponent(rel)}/move`, {
targetParent
}) as Promise<FolderMoveResult>;
}
// ── Photo marks (rating + color) ─────────────────────────────────────────────
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
// internal fields). We store them in mule-sidecar instead.

View File

@@ -6,6 +6,8 @@
* startBulk → pill spins, all target tiles go "pending"
* setDetail → pill shows the filename currently being processed (fan-out ops)
* doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s
* removedBulk→ destructive completion (archive / delete): tiles flash a red cross,
* then the caller hides them via markRemoved; map auto-clears after 3 s
* failBulk → tiles flash red, auto-clears after 2 s
*/
@@ -21,7 +23,7 @@ export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
// SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile
// reliably re-runs when the entry flips — the plain-Map proxy form wasn't
// re-rendering the timeline tiles' overlay.
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error'>();
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error' | 'removed'>();
/**
* UIDs hidden from the timeline grid the instant a removing action (archive /
@@ -71,6 +73,24 @@ export function doneBulk(label: string, ids: string[]): void {
}, 3000);
}
/**
* Destructive completion (archive / permanent delete): flash a red cross on the
* target tiles instead of the green check. The caller hides the tiles via
* markRemoved shortly after the flash; this timer only cleans up the state map.
*/
export function removedBulk(label: string, ids: string[]): void {
for (const id of ids) bulkPhotoStates.set(id, 'removed');
bulkAction.active = false;
bulkAction.label = label;
bulkAction.detail = undefined;
if (doneTimer !== null) clearTimeout(doneTimer);
doneTimer = setTimeout(() => {
bulkAction.label = '';
bulkPhotoStates.clear();
doneTimer = null;
}, 3000);
}
export function failBulk(ids: string[]): void {
for (const id of ids) bulkPhotoStates.set(id, 'error');
bulkAction.active = false;

View File

@@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import { queryClient } from '$lib/queryClient';
import { isAuthenticated, session } from './session.svelte';
/**
@@ -44,6 +45,27 @@ let lastFileUpdateAt = 0;
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
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 {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/api/v1/ws`;
@@ -134,6 +156,8 @@ function handleMessage(raw: string): void {
const fileName =
(data.fileName as string | undefined) ?? (data.baseName as string | undefined);
setActiveThrottled('Indexing', fileName);
// Stream newly indexed files into the grid as the scan runs.
invalidatePhotosGridThrottled();
return;
}
case 'index.updating': {
@@ -148,6 +172,8 @@ function handleMessage(raw: string): void {
case 'index.completed': {
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
// Final refetch so the grid lands on the fully-indexed result.
invalidatePhotosGrid();
return;
}
default:

View File

@@ -0,0 +1,28 @@
/**
* Global "move to folder" dialog state. A single MoveToFolderDialog (mounted
* once in the root layout) renders whenever `subject` is non-null. Every entry
* point — heap kebab, folder kebab, the grid's BulkActionBar button, and the
* `m` keyboard shortcut — opens it through openMove(), so the picker UI and
* the move/copy logic live in exactly one place.
*/
import type { PpAlbum } from '$lib/services/photoprism';
export type MoveSubject =
| { kind: 'heap'; heap: PpAlbum }
| { kind: 'photos'; uids: string[] }
| { kind: 'folder'; path: string };
interface MoveDialogState {
subject: MoveSubject | null;
}
export const moveDialog = $state<MoveDialogState>({ subject: null });
export function openMove(subject: MoveSubject): void {
moveDialog.subject = subject;
}
export function closeMove(): void {
moveDialog.subject = null;
}

View File

@@ -19,6 +19,7 @@
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
let { children } = $props();
@@ -117,6 +118,10 @@
helper (called by the timeline / PhotoGrid dblclick paths and
by gridKeyNav's Space handler). -->
<PreviewModal />
<!-- Single shared move-to-folder dialog, driven by the moveDialog
store. Opened from the heap/folder kebabs, the BulkActionBar
button, and the `m` shortcut — all through openMove(). -->
<MoveToFolderDialog />
{:else}
{@render children?.()}
{/if}

View File

@@ -37,7 +37,7 @@
setFocused,
setOrder,
} from "$lib/stores/selection.svelte";
import { removedIds } from "$lib/stores/bulkAction.svelte";
import { removedIds, clearRemoved } from "$lib/stores/bulkAction.svelte";
import {
openPreview,
setRightSidebarWidth,
@@ -283,6 +283,20 @@
}
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(() => {
setOrder(photos.map((p) => p.UID));
});