From 74f79cae69fe64e1374e7b4af02354190d8e6636 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 9 Jun 2025 11:02:16 -0400 Subject: [PATCH] refactor(web): tree data structure for folder and tag views (#18980) * refactor folder view inline link * improved tree collapsing * handle tags * linting * formatting * simplify * .from is faster * simplify * add key --- .../asset-viewer/detail-panel.svelte | 3 +- .../shared-components/tree/breadcrumbs.svelte | 41 ++--- .../tree/tree-item-thumbnails.svelte | 14 +- .../shared-components/tree/tree-items.svelte | 23 +-- .../shared-components/tree/tree.svelte | 37 ++--- web/src/lib/stores/folders.svelte.ts | 29 ++-- web/src/lib/utils/navigation.ts | 2 +- web/src/lib/utils/tree-utils.ts | 149 ++++++++++++++++-- .../[[assetId=id]]/+page.svelte | 51 +++--- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 26 +-- .../[[assetId=id]]/+page.svelte | 54 ++----- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 11 +- 12 files changed, 249 insertions(+), 191 deletions(-) diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index c8adabd055..ef4ddc13ce 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -19,6 +19,7 @@ import { handleError } from '$lib/utils/handle-error'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { fromISODateTime, fromISODateTimeUTC } from '$lib/utils/timeline-util'; + import { getParentPath } from '$lib/utils/tree-utils'; import { AssetMediaSize, getAssetInfo, @@ -137,7 +138,7 @@ const getAssetFolderHref = (asset: AssetResponseDto) => { const folderUrl = new URL(AppRoute.FOLDERS, globalThis.location.href); // Remove the last part of the path to get the parent path - const assetParentPath = asset.originalPath.split('/').slice(0, -1).join('/'); + const assetParentPath = getParentPath(asset.originalPath); folderUrl.searchParams.set(QueryParameter.PATH, assetParentPath); return folderUrl.href; }; diff --git a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte index 135dda0aca..01e442a928 100644 --- a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte +++ b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte @@ -1,23 +1,27 @@ diff --git a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte index 71d87acb8d..da8c24697b 100644 --- a/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte +++ b/web/src/lib/components/shared-components/tree/tree-item-thumbnails.svelte @@ -1,29 +1,31 @@ {#if items.length > 0}
- {#each items as item (item)} + + {#each items as item} {/each} diff --git a/web/src/lib/components/shared-components/tree/tree-items.svelte b/web/src/lib/components/shared-components/tree/tree-items.svelte index 0df71ca605..9c2fb6638e 100644 --- a/web/src/lib/components/shared-components/tree/tree-items.svelte +++ b/web/src/lib/components/shared-components/tree/tree-items.svelte @@ -1,28 +1,21 @@ diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte index 25c40f096e..6311b69831 100644 --- a/web/src/lib/components/shared-components/tree/tree.svelte +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -1,25 +1,20 @@ - -
+ {#if node.size > 0} + + {/if} +
- {value} + {node.value}
{#if isOpen} - + {/if} diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts index 6f3eb8b66a..f77b67bb7c 100644 --- a/web/src/lib/stores/folders.svelte.ts +++ b/web/src/lib/stores/folders.svelte.ts @@ -1,4 +1,5 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; +import { TreeNode } from '$lib/utils/tree-utils'; import { getAssetsByOriginalPath, getUniqueOriginalPaths, @@ -13,47 +14,41 @@ type AssetCache = { }; class FoldersStore { + folders = $state.raw(null); private initialized = false; - uniquePaths = $state([]); - assets = $state({}); + private assets = $state({}); constructor() { eventManager.on('auth.logout', () => this.clearCache()); } - async fetchUniquePaths() { + async fetchTree(): Promise { if (this.initialized) { - return; + return this.folders!; } this.initialized = true; - const uniquePaths = await getUniqueOriginalPaths(); - this.uniquePaths.push(...uniquePaths); + this.folders = TreeNode.fromPaths(await getUniqueOriginalPaths()); + this.folders.collapse(); + return this.folders; } bustAssetCache() { this.assets = {}; } - async refreshAssetsByPath(path: string | null) { - if (!path) { - return; - } - this.assets[path] = await getAssetsByOriginalPath({ path }); + async refreshAssetsByPath(path: string) { + return (this.assets[path] = await getAssetsByOriginalPath({ path })); } async fetchAssetsByPath(path: string) { - if (this.assets[path]) { - return; - } - - this.assets[path] = await getAssetsByOriginalPath({ path }); + return (this.assets[path] ??= await getAssetsByOriginalPath({ path })); } clearCache() { this.initialized = false; - this.uniquePaths = []; this.assets = {}; + this.folders = null; } } diff --git a/web/src/lib/utils/navigation.ts b/web/src/lib/utils/navigation.ts index c3b4d83f38..89e1f9f5f0 100644 --- a/web/src/lib/utils/navigation.ts +++ b/web/src/lib/utils/navigation.ts @@ -23,7 +23,7 @@ export const isAssetViewerRoute = (target?: NavigationTarget | null) => !!(target?.route.id?.endsWith('/[[assetId=id]]') && 'assetId' in (target?.params || {})); export function getAssetInfoFromParam({ assetId, key }: { assetId?: string; key?: string }) { - return assetId && getAssetInfo({ id: assetId, key }); + return assetId ? getAssetInfo({ id: assetId, key }) : undefined; } function currentUrlWithoutAsset() { diff --git a/web/src/lib/utils/tree-utils.ts b/web/src/lib/utils/tree-utils.ts index 5a6e917079..267bb2eec7 100644 --- a/web/src/lib/utils/tree-utils.ts +++ b/web/src/lib/utils/tree-utils.ts @@ -1,21 +1,144 @@ -export interface RecursiveObject { - [key: string]: RecursiveObject; -} +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable unicorn/no-this-assignment */ +/* eslint-disable unicorn/prefer-at */ +import type { TagResponseDto } from '@immich/sdk'; -export const normalizeTreePath = (path: string) => path.replace(/^\//, '').replace(/\/$/, ''); +export class TreeNode extends Map { + value: string; + path: string; + parent: TreeNode | null; + hasAssets: boolean; + id: string | undefined; + color: string | undefined; + private _parents: TreeNode[] | undefined; + private _children: TreeNode[] | undefined; -export function buildTree(paths: string[]) { - const root: RecursiveObject = {}; + private constructor(value: string, path: string, parent: TreeNode | null) { + super(); + this.value = value; + this.parent = parent; + this.path = path; + this.hasAssets = false; + } - for (const path of paths) { - const parts = path.split('/'); - let current = root; + static fromPaths(paths: string[]) { + const root = new TreeNode('', '', null); + for (const path of paths) { + const current = root.add(path); + current.hasAssets = true; + } + return root; + } + + static fromTags(tags: TagResponseDto[]) { + const root = new TreeNode('', '', null); + for (const tag of tags) { + const current = root.add(tag.value); + current.hasAssets = true; + current.id = tag.id; + current.color = tag.color; + } + return root; + } + + traverse(path: string) { + const parts = getPathParts(path); + let current: TreeNode = this; + let curPart = null; for (const part of parts) { - if (!current[part]) { - current[part] = {}; + // segments common to all subtrees can be collapsed together + curPart = curPart === null ? part : joinPaths(curPart, part); + const next = current.get(curPart); + if (next) { + current = next; + curPart = null; } - current = current[part]; + } + return current; + } + + collapse() { + if (this.size === 1 && !this.hasAssets && this.parent !== null) { + const child = this.values().next().value!; + child.value = joinPaths(this.value, child.value); + child.parent = this.parent; + this.parent.delete(this.value); + this.parent.set(child.value, child); + } + + for (const child of this.values()) { + child.collapse(); } } - return root; + + private add(path: string) { + let current: TreeNode = this; + for (const part of getPathParts(path)) { + let next = current.get(part); + if (next === undefined) { + next = new TreeNode(part, joinPaths(current.path, part), current); + current.set(part, next); + } + current = next; + } + return current; + } + + get parents(): TreeNode[] { + if (this._parents) { + return this._parents; + } + const parents: TreeNode[] = []; + let current: TreeNode | null = this.parent; + while (current !== null && current.parent !== null) { + parents.push(current); + current = current.parent; + } + return (this._parents = parents.reverse()); + } + + get children(): TreeNode[] { + return (this._children ??= Array.from(this.values())); + } +} + +export const normalizeTreePath = (path: string) => + path.length > 1 && path[path.length - 1] === '/' ? path.slice(0, -1) : path; + +export function getPathParts(path: string) { + const parts = path.split('/'); + if (path[0] === '/') { + parts[0] = '/'; + } + + if (path[path.length - 1] === '/') { + parts.pop(); + } + + return parts; +} + +export function joinPaths(path1: string, path2: string) { + if (!path1) { + return path2; + } + + if (!path2) { + return path1; + } + + if (path1[path1.length - 1] === '/') { + return path1 + path2; + } + + return path1 + '/' + path2; +} + +export function getParentPath(path: string) { + const normalized = normalizeTreePath(path); + const last = normalized.lastIndexOf('/'); + if (last > 0) { + return normalized.slice(0, last); + } + return last === 0 ? '/' : normalized; } diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 89df31562e..84ae328ccc 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,5 @@ @@ -99,8 +86,8 @@
@@ -108,10 +95,10 @@ {/snippet} - +
- + {#if data.pathAssets && data.pathAssets.length > 0} diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts index 7fd0a749c0..72fb102e53 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,38 +3,28 @@ import { foldersStore } from '$lib/stores/folders.svelte'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; -import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { await authenticate(url); - const asset = await getAssetInfoFromParam(params); - const $t = await getFormatter(); - - await foldersStore.fetchUniquePaths(); - - let pathAssets = null; + const [, asset, $t] = await Promise.all([foldersStore.fetchTree(), getAssetInfoFromParam(params), getFormatter()]); + let tree = foldersStore.folders!; const path = url.searchParams.get(QueryParameter.PATH); if (path) { - await foldersStore.fetchAssetsByPath(path); - pathAssets = foldersStore.assets[path] || null; - } else { - // If no path is provided, we we're at the root level + tree = tree.traverse(path); + } else if (path === null) { + // If no path is provided, we've just navigated to the folders page. // We should bust the asset cache of the folder store, to make sure we don't show stale data foldersStore.bustAssetCache(); } - let tree = buildTree(foldersStore.uniquePaths); - const parts = normalizeTreePath(path || '').split('/'); - for (const part of parts) { - tree = tree?.[part]; - } + // only fetch assets if the folder has assets + const pathAssets = tree.hasAssets ? await foldersStore.fetchAssetsByPath(tree.path) : null; return { asset, - path, - currentFolders: Object.keys(tree || {}).sort(), + tree, pathAssets, meta: { title: $t('folders'), diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index a42c6534a7..e344c448b0 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,5 @@