Files
mule-image/web/src/lib/components/layout/FolderTree.svelte
dtoro 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

264 lines
9.0 KiB
Svelte

<script lang="ts" module>
/**
* Build a nested folder tree from PhotoPrism's flat `Path`-keyed
* folder list. The API returns one row per subfolder
* (`2024`, `2024/lyon`, `2024/paris`, …); we group by the parent
* segment so the UI can render a real <ul> tree.
*/
export interface TreeNode {
path: string;
name: string;
children: TreeNode[];
}
export function buildTree(paths: string[]): TreeNode[] {
const root: TreeNode = { path: '', name: '', children: [] };
const index = new Map<string, TreeNode>([['', root]]);
const sorted = [...paths].sort();
for (const p of sorted) {
const parts = p.split('/');
let parentPath = '';
for (let i = 0; i < parts.length; i++) {
const here = parts.slice(0, i + 1).join('/');
if (!index.has(here)) {
const node: TreeNode = {
path: here,
name: parts[i],
children: []
};
const parent = index.get(parentPath);
if (parent) parent.children.push(node);
index.set(here, node);
}
parentPath = here;
}
}
return root.children;
}
</script>
<script lang="ts">
import { filters } from '$lib/stores/filters.svelte';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { ChevronRight, FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
import Self from './FolderTree.svelte';
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
interface Props {
nodes: TreeNode[];
depth?: number;
onPick: (path: string) => void;
/** Mutating callbacks are only required when readonly !== true. The
* 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;
/** Override the active-row predicate. By default rows light up when
* `filters.folderPath` matches (the sidebar nav case); the picker
* passes its own selection so the dialog has independent state. */
selectedPath?: string | null;
/** Optional per-path photo count. When provided, each row renders a
* compact badge with the count — matching the heaps section's
* "{n} photos" affordance. Undefined keeps the badge off entirely
* (the picker dialog doesn't need it). */
counts?: Record<string, number>;
}
let {
nodes,
depth = 0,
onPick,
onRename,
onDelete,
onCreateChild,
onMove,
readonly = false,
selectedPath,
counts
}: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state
// survives reloads. Empty set = everything collapsed at start.
const KEY = 'mule_folder_open';
let openSet = $state<Set<string>>(loadOpen());
function loadOpen(): Set<string> {
if (!browser) return new Set();
try {
const raw = localStorage.getItem(KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
function persist() {
if (browser) localStorage.setItem(KEY, JSON.stringify([...openSet]));
}
function toggle(p: string) {
if (openSet.has(p)) openSet.delete(p);
else openSet.add(p);
openSet = new Set(openSet); // re-trigger reactivity
persist();
}
function isActive(path: string): boolean {
if (selectedPath !== undefined) return selectedPath === path;
return filters.folderPath === path;
}
// Auto-expand the ancestor chain of the active folder so the
// highlighted row is actually visible after a deep-link navigation
// (RightSidebar's open-folder icon, URL hydration, etc.). Each
// FolderTree instance only owns the openSet entries for the nodes
// rendered at its depth, but since the root instance expands the
// top-level ancestor first, the child instance for that subtree is
// then mounted and runs the same effect — the cascade naturally
// reaches the leaf. Skipped in `readonly` mode (the heap-convert
// picker has its own selectedPath and shouldn't drive the sidebar
// state). Skipped for top-level paths (nothing to expand).
$effect(() => {
if (readonly || !browser) return;
const fp = selectedPath ?? filters.folderPath;
if (!fp || fp === '/' || !fp.includes('/')) return;
untrack(() => {
const parts = fp.split('/');
let changed = false;
for (let i = 1; i < parts.length; i++) {
const ancestor = parts.slice(0, i).join('/');
if (ancestor && !openSet.has(ancestor)) {
openSet.add(ancestor);
changed = true;
}
}
if (changed) {
openSet = new Set(openSet);
persist();
}
});
});
</script>
<ul>
{#each nodes as node (node.path)}
{@const open = openSet.has(node.path)}
{@const active = isActive(node.path)}
{@const hasChildren = node.children.length > 0}
<li>
<!--
Indent via padding-left rather than nested margin+border, so the
active row's background bleeds edge-to-edge of the sidebar (matches
mule-image's compact tree). 8px baseline aligns the depth-0 chevron
with the px-2 of Views/Heaps rows; +12px per nested level.
-->
<div
class="group flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
style="padding-left: {4 + depth * 12}px;"
>
{#if hasChildren}
<button
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'}
>
<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-5" aria-hidden="true"></span>
{/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge was previously a dead
zone right where the user's eye lands.
-->
<button
class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path}
>
<span class="truncate">{node.name}</span>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
</button>
{#if !readonly}
<!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]])
so the count holds the row's right edge by default
and the kebab pushes it left when it appears.
Suppressed in readonly mode (picker). -->
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Folder actions">
<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={() => onCreateChild?.(node.path)}
>
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
New subfolder
</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={() => onRename?.(node.path)}
>
<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"
onSelect={() => onDelete?.(node.path)}
>
<Trash2 class="h-3.5 w-3.5" />
Delete folder…
</Item>
</KebabMenu>
</div>
{/if}
</div>
{#if hasChildren && open}
<Self
nodes={node.children}
depth={depth + 1}
{onPick}
{onRename}
{onDelete}
{onCreateChild}
{onMove}
{readonly}
{selectedPath}
{counts}
/>
{/if}
</li>
{/each}
</ul>