diff --git a/i18n/en.json b/i18n/en.json index 9741c10b29..073d4ba893 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -736,6 +736,7 @@ "external": "External", "external_libraries": "External Libraries", "face_unassigned": "Unassigned", + "failed_to_load_assets": "Failed to load assets", "favorite": "Favorite", "favorite_or_unfavorite_photo": "Favorite or unfavorite photo", "favorites": "Favorites", @@ -1036,6 +1037,7 @@ "reassing_hint": "Assign selected assets to an existing person", "recent": "Recent", "recent_searches": "Recent searches", + "recent-albums": "Recent albums", "refresh": "Refresh", "refresh_encoded_videos": "Refresh encoded videos", "refresh_faces": "Refresh faces", diff --git a/web/src/lib/components/shared-components/side-bar/recent-albums.svelte b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte new file mode 100644 index 0000000000..a412d5cc42 --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/recent-albums.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { getAssetThumbnailUrl } from '$lib/utils'; + import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk'; + import { handleError } from '$lib/utils/handle-error'; + import { t } from 'svelte-i18n'; + + let albums: AlbumResponseDto[] = $state([]); + + onMount(async () => { + try { + const allAlbums = await getAllAlbums({}); + albums = allAlbums + .sort((album1, album2) => (album1.lastModifiedAssetTimestamp! > album2.lastModifiedAssetTimestamp! ? 1 : 0)) + .slice(0, 3); + } catch (error) { + handleError(error, $t('failed_to_load_assets')); + } + }); +</script> + +{#each albums as album} + <a + href={'/albums/' + album.id} + title={album.albumName} + class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary pl-10 group-hover:sm:px-10 md:px-10" + > + <div> + <div + class="h-6 w-6 bg-cover rounded bg-gray-200 dark:bg-gray-600" + style={album.albumThumbnailAssetId + ? `background-image:url('${getAssetThumbnailUrl({ id: album.albumThumbnailAssetId })}')` + : ''} + ></div> + </div> + <div class="grow text-sm font-medium truncate"> + {album.albumName} + </div> + </a> +{/each} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte index 13f08533c5..4da73b6288 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-link.svelte @@ -1,7 +1,10 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; + import { mdiChevronDown, mdiChevronLeft } from '@mdi/js'; import { resolveRoute } from '$app/paths'; import { page } from '$app/stores'; + import type { Snippet } from 'svelte'; + import { t } from 'svelte-i18n'; interface Props { title: string; @@ -10,6 +13,9 @@ flippedLogo?: boolean; isSelected?: boolean; preloadData?: boolean; + moreInformation?: Snippet; + dropDownContent?: Snippet; + dropdownOpen?: boolean; } let { @@ -19,6 +25,8 @@ flippedLogo = false, isSelected = $bindable(false), preloadData = true, + dropDownContent: hasDropdown, + dropdownOpen = $bindable(false), }: Props = $props(); let routePath = $derived(resolveRoute(routeId, {})); @@ -28,21 +36,44 @@ }); </script> -<a - href={routePath} - data-sveltekit-preload-data={preloadData ? 'hover' : 'off'} - draggable="false" - aria-current={isSelected ? 'page' : undefined} - class="flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary +<span class="relative"> + {#if hasDropdown} + <span class="hidden md:block absolute left-1 z-50 h-full"> + <button + type="button" + aria-label={$t('recent-albums')} + class="relative flex cursor-default pt-4 pb-4 select-none justify-center hover:cursor-pointer hover:bg-immich-gray hover:fill-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary rounded h-fill" + onclick={() => (dropdownOpen = !dropdownOpen)} + > + <Icon + path={dropdownOpen ? mdiChevronDown : mdiChevronLeft} + size="1em" + class="shrink-0 delay-100 duration-100 " + flipped={flippedLogo} + ariaHidden + /> + </button> + </span> + {/if} + <a + href={routePath} + data-sveltekit-preload-data={preloadData ? 'hover' : 'off'} + draggable="false" + aria-current={isSelected ? 'page' : undefined} + class="flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary {isSelected - ? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary' - : ''} + ? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary' + : ''} pl-5 group-hover:sm:px-5 md:px-5 " -> - <div class="flex w-full place-items-center gap-4 overflow-hidden truncate"> - <Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden /> - <span class="text-sm font-medium">{title}</span> - </div> - <div></div> -</a> + > + <div class="flex w-full place-items-center gap-4 overflow-hidden truncate"> + <Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden /> + <span class="text-sm font-medium">{title}</span> + </div> + <div></div> + </a> +</span> +{#if hasDropdown && dropdownOpen} + {@render hasDropdown?.()} +{/if} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 000afa5d1a..9c49b971ba 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -27,6 +27,9 @@ import { t } from 'svelte-i18n'; import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte'; import { preferences } from '$lib/stores/user.store'; + import { recentAlbumsDropdown } from '$lib/stores/preferences.store'; + import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte'; + import { fly } from 'svelte/transition'; let isArchiveSelected: boolean = $state(false); let isFavoritesSelected: boolean = $state(false); @@ -88,7 +91,19 @@ bind:isSelected={isFavoritesSelected} ></SideBarLink> - <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo></SideBarLink> + <SideBarLink + title={$t('albums')} + routeId="/(user)/albums" + icon={mdiImageAlbum} + flippedLogo + bind:dropdownOpen={$recentAlbumsDropdown} + > + {#snippet dropDownContent()} + <span in:fly={{ y: -20 }} class="hidden md:block"> + <RecentAlbums /> + </span> + {/snippet} + </SideBarLink> {#if $preferences.tags.enabled && $preferences.tags.sidebarWeb} <SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo /> diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 87f4a7ba44..2b3ff86c2f 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -144,3 +144,5 @@ export const alwaysLoadOriginalFile = persisted<boolean>('always-load-original-f export const playVideoThumbnailOnHover = persisted<boolean>('play-video-thumbnail-on-hover', true, {}); export const loopVideo = persisted<boolean>('loop-video', true, {}); + +export const recentAlbumsDropdown = persisted<boolean>('recent-albums-open', true, {});