From d8b602f7575270dc05ebe4ee73c71fd48a1fb6ed Mon Sep 17 00:00:00 2001
From: Ben <45583362+ben-basten@users.noreply.github.com>
Date: Mon, 2 Sep 2024 15:42:27 -0400
Subject: [PATCH] feat(web): shared breadcrumbs component for folders and tags
 (#12215)

* feat(web): shared breadcrumbs component for folders and tags

* chore: revert changes to tree view
---
 .../shared-components/tree/breadcrumbs.svelte | 59 +++++++++++++++++++
 web/src/lib/i18n/en.json                      |  2 +-
 .../[[assetId=id]]/+page.svelte               | 48 ++-------------
 .../[[assetId=id]]/+page.svelte               | 46 ++++-----------
 4 files changed, 78 insertions(+), 77 deletions(-)
 create mode 100644 web/src/lib/components/shared-components/tree/breadcrumbs.svelte

diff --git a/web/src/lib/components/shared-components/tree/breadcrumbs.svelte b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
new file mode 100644
index 0000000000..a3c49a1430
--- /dev/null
+++ b/web/src/lib/components/shared-components/tree/breadcrumbs.svelte
@@ -0,0 +1,59 @@
+<script lang="ts">
+  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import Icon from '$lib/components/elements/icon.svelte';
+  import { mdiArrowUpLeft, mdiChevronRight } from '@mdi/js';
+  import { t } from 'svelte-i18n';
+
+  export let pathSegments: string[] = [];
+  export let getLink: (path: string) => string;
+  export let title: string;
+  export let icon: string;
+
+  $: isRoot = pathSegments.length === 0;
+</script>
+
+<nav class="flex items-center py-2">
+  {#if !isRoot}
+    <div>
+      <CircleIconButton
+        icon={mdiArrowUpLeft}
+        title={$t('to_parent')}
+        href={getLink(pathSegments.slice(0, -1).join('/'))}
+        class="mr-2"
+        padding="2"
+      />
+    </div>
+  {/if}
+
+  <div
+    class="bg-gray-50 dark:bg-immich-dark-gray/50 w-full p-2 rounded-2xl border border-gray-100 dark:border-gray-900 overflow-y-auto immich-scrollbar"
+  >
+    <ol class="flex gap-2 items-center">
+      <li>
+        <CircleIconButton
+          {icon}
+          href={getLink('')}
+          {title}
+          size="1.25em"
+          padding="2"
+          aria-current={isRoot ? 'page' : undefined}
+        />
+      </li>
+      {#each pathSegments as segment, index}
+        {@const isLastSegment = index === pathSegments.length - 1}
+        <li
+          class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
+        >
+          <Icon path={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size={16} ariaHidden />
+          {#if isLastSegment}
+            <p class="cursor-default">{segment}</p>
+          {:else}
+            <a class="underline hover:font-semibold" href={getLink(pathSegments.slice(0, index + 1).join('/'))}>
+              {segment}
+            </a>
+          {/if}
+        </li>
+      {/each}
+    </ol>
+  </div>
+</nav>
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json
index d0d330480a..25f3b6ea2f 100644
--- a/web/src/lib/i18n/en.json
+++ b/web/src/lib/i18n/en.json
@@ -1191,7 +1191,7 @@
   "to_change_password": "Change password",
   "to_favorite": "Favorite",
   "to_login": "Login",
-  "to_root": "To root",
+  "to_parent": "Go to parent",
   "to_trash": "Trash",
   "toggle_settings": "Toggle settings",
   "toggle_theme": "Toggle dark theme",
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 a8b8602c02..1ffa64d3a3 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,8 +1,6 @@
 <script lang="ts">
   import { goto } from '$app/navigation';
   import { page } from '$app/stores';
-  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
-  import Icon from '$lib/components/elements/icon.svelte';
   import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
   import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
   import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
@@ -13,10 +11,11 @@
   import { foldersStore } from '$lib/stores/folders.store';
   import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
   import { type AssetResponseDto } from '@immich/sdk';
-  import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
+  import { mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
   import { onMount } from 'svelte';
   import { t } from 'svelte-i18n';
   import type { PageData } from './$types';
+  import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
 
   export let data: PageData;
 
@@ -35,20 +34,11 @@
     await navigateToView(normalizeTreePath(`${data.path || ''}/${folderName}`));
   };
 
-  const handleBackNavigation = async () => {
-    if (data.path) {
-      const parentPath = data.path.split('/').slice(0, -1).join('/');
-      await navigateToView(parentPath);
-    }
-  };
-
-  const handleBreadcrumbNavigation = async (targetPath: string) => {
-    await navigateToView(targetPath);
-  };
-
   const getLink = (path: string) => {
     const url = new URL(AppRoute.FOLDERS, window.location.href);
-    url.searchParams.set(QueryParameter.PATH, path);
+    if (path) {
+      url.searchParams.set(QueryParameter.PATH, path);
+    }
     return url.href;
   };
 
@@ -70,33 +60,7 @@
     </section>
   </SideBarSection>
 
-  <section id="path-summary" class="text-immich-primary dark:text-immich-dark-primary rounded-xl flex">
-    {#if data.path}
-      <CircleIconButton icon={mdiArrowUpLeft} title="Back" on:click={handleBackNavigation} class="mr-2" padding="2" />
-    {/if}
-
-    <div
-      class="flex place-items-center gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 rounded-2xl border border-gray-100 dark:border-gray-900"
-    >
-      <a href={`${AppRoute.FOLDERS}`} title={$t('to_root')}>
-        <Icon path={mdiFolderHome} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
-      </a>
-      {#each pathSegments as segment, index}
-        <button
-          class="text-sm font-mono underline hover:font-semibold"
-          on:click={() => handleBreadcrumbNavigation(pathSegments.slice(0, index + 1).join('/'))}
-          type="button"
-        >
-          {segment}
-        </button>
-        <p class="text-gray-500">
-          {#if index < pathSegments.length - 1}
-            <Icon path={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size={16} />
-          {/if}
-        </p>
-      {/each}
-    </div>
-  </section>
+  <Breadcrumbs {pathSegments} icon={mdiFolderHome} title={$t('folders')} {getLink} />
 
   <section class="mt-2">
     <TreeItemThumbnails items={data.currentFolders} icon={mdiFolder} onClick={handleNavigation} />
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 7335bf83c1..30f17da15d 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
@@ -22,10 +22,11 @@
   import { AssetStore } from '$lib/stores/assets.store';
   import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
   import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
-  import { mdiChevronRight, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
+  import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
   import { t } from 'svelte-i18n';
   import type { PageData } from './$types';
   import { dialogController } from '$lib/components/shared-components/dialog/dialog';
+  import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
 
   export let data: PageData;
 
@@ -47,13 +48,11 @@
     await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
   };
 
-  const handleBreadcrumbNavigation = async (targetPath: string) => {
-    await navigateToView(targetPath);
-  };
-
   const getLink = (path: string) => {
     const url = new URL(AppRoute.TAGS, window.location.href);
-    url.searchParams.set(QueryParameter.PATH, path);
+    if (path) {
+      url.searchParams.set(QueryParameter.PATH, path);
+    }
     return url.href;
   };
 
@@ -149,14 +148,13 @@
       </div>
     </LinkButton>
 
-    <LinkButton on:click={handleEdit}>
-      <div class="flex place-items-center gap-2 text-sm">
-        <Icon path={mdiPencil} size="18" />
-        <p class="hidden md:block">{$t('edit_tag')}</p>
-      </div>
-    </LinkButton>
-
     {#if pathSegments.length > 0 && tag}
+      <LinkButton on:click={handleEdit}>
+        <div class="flex place-items-center gap-2 text-sm">
+          <Icon path={mdiPencil} size="18" />
+          <p class="hidden md:block">{$t('edit_tag')}</p>
+        </div>
+      </LinkButton>
       <LinkButton on:click={handleDelete}>
         <div class="flex place-items-center gap-2 text-sm">
           <Icon path={mdiTrashCanOutline} size="18" />
@@ -166,27 +164,7 @@
     {/if}
   </section>
 
-  <section
-    class="flex place-items-center gap-2 mt-2 text-immich-primary dark:text-immich-dark-primary rounded-2xl bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 border border-gray-100 dark:border-gray-900"
-  >
-    <a href={`${AppRoute.TAGS}`} title={$t('to_root')}>
-      <Icon path={mdiTagMultiple} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
-    </a>
-    {#each pathSegments as segment, index}
-      <button
-        class="text-sm font-mono underline hover:font-semibold"
-        on:click={() => handleBreadcrumbNavigation(pathSegments.slice(0, index + 1).join('/'))}
-        type="button"
-      >
-        {segment}
-      </button>
-      <p class="text-gray-500">
-        {#if index < pathSegments.length - 1}
-          <Icon path={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size={16} />
-        {/if}
-      </p>
-    {/each}
-  </section>
+  <Breadcrumbs {pathSegments} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
 
   <section class="mt-2 h-full">
     {#key $page.url.href}