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
This commit is contained in:
Mert
2025-06-09 11:02:16 -04:00
committed by GitHub
parent ac0e94c003
commit 74f79cae69
12 changed files with 249 additions and 191 deletions
@@ -1,6 +1,5 @@
<script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
@@ -28,10 +27,9 @@
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { joinPaths } from '$lib/utils/tree-utils';
import { IconButton } from '@immich/ui';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -43,51 +41,40 @@
const viewport: Viewport = $state({ width: 0, height: 0 });
let pathSegments = $derived(data.path ? data.path.split('/') : []);
let tree = $derived(buildTree(foldersStore.uniquePaths));
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort());
const assetInteraction = new AssetInteraction();
onMount(async function initializeFolders() {
await foldersStore.fetchUniquePaths();
});
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
const handleNavigateToFolder = async function handleNavigateToFolder(folderName: string) {
await navigateToView(normalizeTreePath(`${data.path || ''}/${folderName}`));
};
const getLinkForPath = function getLinkForPath(path: string) {
function getLinkForPath(path: string) {
const url = new URL(AppRoute.FOLDERS, globalThis.location.href);
if (path) {
url.searchParams.set(QueryParameter.PATH, path);
}
url.searchParams.set(QueryParameter.PATH, path);
return url.href;
};
}
afterNavigate(function clearAssetSelection() {
// Clear the asset selection when we navigate (like going to another folder)
cancelMultiselect(assetInteraction);
});
const navigateToView = function navigateToView(path: string) {
return goto(getLinkForPath(path));
};
function navigateToView(path: string) {
return goto(getLinkForPath(path), { keepFocus: true, noScroll: true });
}
const triggerAssetUpdate = async function updateAssets() {
async function triggerAssetUpdate() {
cancelMultiselect(assetInteraction);
await foldersStore.refreshAssetsByPath(data.path);
if (data.tree.path) {
await foldersStore.refreshAssetsByPath(data.tree.path);
}
await invalidateAll();
};
}
const handleSelectAllAssets = function handleSelectAllAssets() {
function handleSelectAllAssets() {
if (!data.pathAssets) {
return;
}
assetInteraction.selectAssets(data.pathAssets.map((asset) => toTimelineAsset(asset)));
};
}
</script>
<UserPageLayout title={data.meta.title}>
@@ -99,8 +86,8 @@
<div class="h-full">
<TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }}
items={tree}
active={currentPath}
tree={foldersStore.folders!}
active={data.tree.path}
getLink={getLinkForPath}
/>
</div>
@@ -108,10 +95,10 @@
</Sidebar>
{/snippet}
<Breadcrumbs {pathSegments} icon={mdiFolderHome} title={$t('folders')} getLink={getLinkForPath} />
<Breadcrumbs node={data.tree} icon={mdiFolderHome} title={$t('folders')} getLink={getLinkForPath} />
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
<TreeItemThumbnails items={currentTreeItems} icon={mdiFolder} onClick={handleNavigateToFolder} />
<TreeItemThumbnails items={data.tree.children} icon={mdiFolder} onClick={handleNavigateToFolder} />
<!-- Assets -->
{#if data.pathAssets && data.pathAssets.length > 0}
@@ -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'),
@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
@@ -15,9 +14,9 @@
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute, AssetAction, QueryParameter, SettingInputFieldType } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
@@ -31,38 +30,24 @@
let { data }: Props = $props();
let pathSegments = $derived(data.path ? data.path.split('/') : []);
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
const assetInteraction = new AssetInteraction();
const buildMap = (tags: TagResponseDto[]) => {
return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
};
const assetStore = new AssetStore();
$effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId }));
$effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId: tag.id }));
onDestroy(() => assetStore.destroy());
let tags = $derived<TagResponseDto[]>(data.tags);
let tagsMap = $derived(buildMap(tags));
let tag = $derived(currentPath ? tagsMap[currentPath] : null);
let tagId = $derived(tag?.id);
let tree = $derived(buildTree(tags.map((tag) => tag.value)));
const tree = $derived(TreeNode.fromTags(tags));
const tag = $derived(tree.traverse(data.path));
const handleNavigation = async (tag: string) => {
await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
};
const handleNavigation = (tag: string) => navigateToView(joinPaths(data.path, tag));
const getLink = (path: string) => {
const url = new URL(AppRoute.TAGS, globalThis.location.href);
if (path) {
url.searchParams.set(QueryParameter.PATH, path);
}
url.searchParams.set(QueryParameter.PATH, path);
return url.href;
};
const getColor = (path: string) => tagsMap[path]?.color;
const navigateToView = (path: string) => goto(getLink(path));
let isNewOpen = $state(false);
@@ -86,7 +71,7 @@
const handleSubmit = async () => {
if (tag && isEditOpen && newTagColor) {
await updateTag({ id: tag.id, tagUpdateDto: { color: newTagColor } });
await updateTag({ id: tag.id!, tagUpdateDto: { color: newTagColor } });
notificationController.show({
message: $t('tag_updated', { values: { tag: tag.value } }),
@@ -125,12 +110,11 @@
return;
}
await deleteTag({ id: tag.id });
await deleteTag({ id: tag.id! });
tags = await getAllTags();
// navigate to parent
const parentPath = pathSegments.slice(0, -1).join('/');
await navigateToView(parentPath);
await navigateToView(tag.parent ? tag.parent.path : '');
};
const onsubmit = async (event: Event) => {
@@ -146,13 +130,7 @@
<section>
<div class="text-xs ps-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems
icons={{ default: mdiTag, active: mdiTag }}
items={tree}
active={currentPath}
{getLink}
{getColor}
/>
<TreeItems icons={{ default: mdiTag, active: mdiTag }} {tree} active={tag.path} {getLink} />
</div>
</section>
</Sidebar>
@@ -164,7 +142,7 @@
<Text class="hidden md:block">{$t('create_tag')}</Text>
</Button>
{#if pathSegments.length > 0 && tag}
{#if tag.path.length > 0}
<Button leadingIcon={mdiPencil} onclick={handleEdit} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('edit_tag')}</Text>
</Button>
@@ -175,17 +153,17 @@
</HStack>
{/snippet}
<Breadcrumbs {pathSegments} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
<Breadcrumbs node={tag} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
{#if tag}
{#if tag.hasAssets}
<AssetGrid enableRouting={true} {assetStore} {assetInteraction} removeAction={AssetAction.UNARCHIVE}>
{#snippet empty()}
<TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} />
<TreeItemThumbnails items={tag.children} icon={mdiTag} onClick={handleNavigation} />
{/snippet}
</AssetGrid>
{:else}
<TreeItemThumbnails items={Object.keys(tree)} icon={mdiTag} onClick={handleNavigation} />
<TreeItemThumbnails items={tag.children} icon={mdiTag} onClick={handleNavigation} />
{/if}
</section>
</UserPageLayout>
@@ -2,7 +2,6 @@ import { QueryParameter } from '$lib/constants';
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 { getAllTags } from '@immich/sdk';
import type { PageLoad } from './$types';
@@ -11,20 +10,12 @@ export const load = (async ({ params, url }) => {
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
const path = url.searchParams.get(QueryParameter.PATH);
const tags = await getAllTags();
const tree = buildTree(tags.map((tag) => tag.value));
let currentTree = tree;
const parts = normalizeTreePath(path || '').split('/');
for (const part of parts) {
currentTree = currentTree?.[part];
}
return {
path: url.searchParams.get(QueryParameter.PATH) ?? '',
tags,
asset,
path,
children: Object.keys(currentTree || {}),
meta: {
title: $t('tags'),
},