feat(web): expand/collapse sidebar (#16768)

* feat: expand/collapse sidebar

* fix: general PR cleanup

- add skip link unit test
- remove unused tailwind styles
- adjust asset grid spacing
- fix event propogation

* fix: cleaning up event listeners

* fix: purchase modal and button on small screens

* fix: explicit tailwind classes

* fix: no animation on initial page load

* fix: sidebar spacing and reactivity

* chore: reverting changes to icons in nav and account info panel

* fix: remove left margin from the asset grid after merging in new timeline

* chore: extract search-bar changes for a separate PR

* fix: add margin to memories
This commit is contained in:
Ben 2025-04-01 22:12:04 -04:00 committed by GitHub
parent 00d3b8d83a
commit 6e62c09d84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 193 additions and 66 deletions

View file

@ -1,3 +1,7 @@
<script lang="ts" module>
export const menuButtonId = 'top-menu-button';
</script>
<script lang="ts">
import { page } from '$app/state';
import { clickOutside } from '$lib/actions/click-outside';
@ -12,13 +16,14 @@
import { handleLogout } from '$lib/utils/auth';
import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui';
import { mdiHelpCircleOutline, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import ThemeButton from '../theme-button.svelte';
import UserAvatar from '../user-avatar.svelte';
import AccountInfoPanel from './account-info-panel.svelte';
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
interface Props {
@ -57,11 +62,34 @@
>
<SkipLink text={$t('skip_to_content')} />
<div
class="grid h-full grid-cols-[theme(spacing.18)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
>
<a data-sveltekit-preload-data="hover" class="ml-4" href={AppRoute.PHOTOS}>
<ImmichLogo class="max-md:h-[48px] h-[50px]" noText={mobileDevice.maxMd} />
</a>
<div class="flex flex-row gap-1 mx-4 items-center">
<div>
<IconButton
id={menuButtonId}
shape="round"
color="secondary"
variant="ghost"
size="large"
aria-label={$t('main_menu')}
icon={mdiMenu}
onclick={() => {
isSidebarOpen.value = !isSidebarOpen.value;
}}
onmousedown={(event: MouseEvent) => {
if (isSidebarOpen.value) {
// stops event from reaching the default handler when clicking outside of the sidebar
event.stopPropagation();
}
}}
class="md:hidden"
/>
</div>
<a data-sveltekit-preload-data="hover" href={AppRoute.PHOTOS}>
<ImmichLogo class="max-md:h-[48px] h-[50px]" noText={mobileDevice.maxMd} />
</a>
</div>
<div class="flex justify-between gap-4 lg:gap-8 pr-6">
<div class="hidden w-full max-w-5xl flex-1 tall:pl-0 sm:block">
{#if $featureFlags.search}
@ -80,7 +108,6 @@
href={AppRoute.SEARCH}
id="search-button"
class="sm:hidden"
title={$t('go_to_search')}
aria-label={$t('go_to_search')}
/>
{/if}
@ -120,7 +147,6 @@
color="secondary"
variant="ghost"
size="medium"
title={$t('support_and_feedback')}
icon={mdiHelpCircleOutline}
onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
aria-label={$t('support_and_feedback')}

View file

@ -8,7 +8,9 @@
</script>
<!-- Individual Purchase Option -->
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiAccount} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_individual_title')}</p>

View file

@ -57,7 +57,7 @@
</div>
{/if}
<div class="flex gap-6 mt-4 justify-between">
<div class="flex flex-col sm:flex-row gap-6 mt-4 justify-between">
<ServerPurchaseOptionCard />
<UserPurchaseOptionCard />
</div>

View file

@ -8,7 +8,9 @@
</script>
<!-- SERVER Purchase Options -->
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiServer} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_server_title')}</p>

View file

@ -78,7 +78,7 @@
<LicenseModal onClose={() => (isOpen = false)} />
{/if}
<div class="hidden md:block license-status pl-4 text-sm">
<div class="license-status pl-4 text-sm">
{#if $isPurchased && $preferences.purchase.showSupportBadge}
<button
onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
@ -95,7 +95,7 @@
onmouseleave={() => (hoverButton = false)}
onfocus={onButtonHover}
onblur={() => (hoverButton = false)}
class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 w-full"
class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 min-w-52 w-full"
>
<div class="flex justify-between w-full place-items-center place-content-center">
<div class="flex place-items-center place-content-center gap-1">
@ -110,7 +110,7 @@
<div>
<Icon
path={mdiInformationOutline}
class="flex text-immich-primary dark:text-immich-dark-primary font-medium"
class="hidden md:flex text-immich-primary dark:text-immich-dark-primary font-medium"
size="18"
/>
</div>
@ -123,7 +123,7 @@
{#if showMessage}
<dialog
open
class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
class="hidden md:block w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
transition:fade={{ duration: 150 }}
onmouseover={() => (hoverMessage = true)}
onmouseleave={() => (hoverMessage = false)}

View file

@ -42,7 +42,7 @@
{/if}
<div
class="text-sm hidden group-hover:sm:flex md:flex pl-5 pr-1 place-items-center place-content-center justify-between"
class="text-sm flex md:flex pl-5 pr-1 place-items-center place-content-center justify-between min-w-52 overflow-hidden"
>
{#if $connected}
<div class="flex gap-2 place-items-center place-content-center">

View file

@ -62,11 +62,9 @@
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'
: ''}
pl-5 group-hover:sm:px-5 md:px-5
"
: ''}"
>
<div class="flex w-full place-items-center gap-4 overflow-hidden truncate">
<div class="flex w-full place-items-center gap-4 pl-5 overflow-hidden truncate">
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
<span class="text-sm font-medium">{title}</span>
</div>

View file

@ -1,17 +1,56 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { clickOutside } from '$lib/actions/click-outside';
import { focusTrap } from '$lib/actions/focus-trap';
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
import { type Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
const mdBreakpoint = 768;
let { children }: Props = $props();
let innerWidth: number = $state(0);
const closeSidebar = (width: number) => {
isSidebarOpen.value = width >= mdBreakpoint;
};
$effect(() => {
closeSidebar(innerWidth);
});
const isHidden = $derived(!isSidebarOpen.value && innerWidth < mdBreakpoint);
const isExpanded = $derived(isSidebarOpen.value && innerWidth < mdBreakpoint);
const handleClickOutside = () => {
if (!isSidebarOpen.value) {
return;
}
closeSidebar(innerWidth);
if (isHidden) {
document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus();
}
};
</script>
<svelte:window bind:innerWidth />
<section
id="sidebar"
tabindex="-1"
class="immich-scrollbar group relative z-10 flex w-18 flex-col gap-1 overflow-y-auto bg-immich-bg pt-8 max-md:pt-16 transition-all duration-200 dark:bg-immich-dark-bg hover:sm:w-64 hover:sm:border-r hover:sm:pr-6 hover:sm:shadow-2xl hover:sm:dark:border-r-immich-dark-gray md:w-64 md:pr-6 hover:md:border-none hover:md:shadow-none"
class="immich-scrollbar relative z-10 w-0 md:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
class:shadow-2xl={isExpanded}
class:dark:border-r-immich-dark-gray={isExpanded}
class:border-r={isExpanded}
class:w-[min(100vw,16rem)]={isSidebarOpen.value}
inert={isHidden}
use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}
use:focusTrap={{ active: isExpanded }}
>
{@render children?.()}
<div class="pr-6 flex flex-col gap-1 h-max min-h-full">
{@render children?.()}
</div>
</section>

View file

@ -84,10 +84,7 @@
bind:isSelected={isSharingSelected}
></SideBarLink>
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
</div>
<p class="text-xs p-6 dark:text-immich-dark-fg">{$t('library').toUpperCase()}</p>
<SideBarLink
title={$t('favorites')}

View file

@ -46,7 +46,7 @@
</script>
<div
class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm"
class="storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm min-w-52"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
@ -54,26 +54,24 @@
},
})}
>
<div class="hidden group-hover:sm:block md:block">
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
{#if userInteraction.serverInfo}
<p class="text-gray-500 dark:text-gray-300">
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale),
available: getByteUnitString(availableBytes, $locale),
},
})}
</p>
{#if userInteraction.serverInfo}
<p class="text-gray-500 dark:text-gray-300">
{$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale),
available: getByteUnitString(availableBytes, $locale),
},
})}
</p>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%"></div>
</div>
{:else}
<div class="mt-2">
<LoadingSpinner />
</div>
{/if}
</div>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%"></div>
</div>
{:else}
<div class="mt-2">
<LoadingSpinner />
</div>
{/if}
</div>

View file

@ -32,6 +32,7 @@
href={getLink(path)}
title={value}
class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
data-sveltekit-keepfocus
>
<button type="button" {onclick} class={Object.values(tree).length === 0 ? 'invisible' : ''}>
<Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />