- 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>
264 lines
9.0 KiB
Svelte
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>
|