mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
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:
parent
00d3b8d83a
commit
6e62c09d84
@ -864,6 +864,7 @@
|
|||||||
"loop_videos": "Loop videos",
|
"loop_videos": "Loop videos",
|
||||||
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
||||||
"main_branch_warning": "You’re using a development version; we strongly recommend using a release version!",
|
"main_branch_warning": "You’re using a development version; we strongly recommend using a release version!",
|
||||||
|
"main_menu": "Main menu",
|
||||||
"make": "Make",
|
"make": "Make",
|
||||||
"manage_shared_links": "Manage shared links",
|
"manage_shared_links": "Manage shared links",
|
||||||
"manage_sharing_with_partners": "Manage sharing with partners",
|
"manage_sharing_with_partners": "Manage sharing with partners",
|
||||||
|
@ -3,15 +3,16 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { show = $bindable() }: Props = $props();
|
let { show = $bindable(), active = $bindable() }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button type="button" onclick={() => (show = true)}>Open</button>
|
<button type="button" onclick={() => (show = true)}>Open</button>
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<div use:focusTrap>
|
<div use:focusTrap={{ active }}>
|
||||||
<div>
|
<div>
|
||||||
<span>text</span>
|
<span>text</span>
|
||||||
<button data-testid="one" type="button" onclick={() => (show = false)}>Close</button>
|
<button data-testid="one" type="button" onclick={() => (show = false)}>Close</button>
|
||||||
|
@ -12,6 +12,12 @@ describe('focusTrap action', () => {
|
|||||||
expect(document.activeElement).toEqual(screen.getByTestId('one'));
|
expect(document.activeElement).toEqual(screen.getByTestId('one'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not set focus if inactive', async () => {
|
||||||
|
render(FocusTrapTest, { show: true, active: false });
|
||||||
|
await tick();
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
it('supports backward focus wrapping', async () => {
|
it('supports backward focus wrapping', async () => {
|
||||||
render(FocusTrapTest, { show: true });
|
render(FocusTrapTest, { show: true });
|
||||||
await tick();
|
await tick();
|
||||||
|
@ -35,12 +35,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClick, true);
|
document.addEventListener('mousedown', handleClick, false);
|
||||||
node.addEventListener('keydown', handleKey, false);
|
node.addEventListener('keydown', handleKey, false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
document.removeEventListener('mousedown', handleClick, true);
|
document.removeEventListener('mousedown', handleClick, false);
|
||||||
node.removeEventListener('keydown', handleKey, false);
|
node.removeEventListener('keydown', handleKey, false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,16 +1,34 @@
|
|||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
const selectors =
|
interface Options {
|
||||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
/**
|
||||||
|
* Set whether the trap is active or not.
|
||||||
|
*/
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function focusTrap(container: HTMLElement) {
|
const selectors =
|
||||||
|
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
|
||||||
|
|
||||||
|
export function focusTrap(container: HTMLElement, options?: Options) {
|
||||||
const triggerElement = document.activeElement;
|
const triggerElement = document.activeElement;
|
||||||
|
|
||||||
const focusableElement = container.querySelector<HTMLElement>(selectors);
|
const withDefaults = (options?: Options) => {
|
||||||
|
return {
|
||||||
|
active: options?.active ?? true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Use tick() to ensure focus trap works correctly inside <Portal />
|
const setInitialFocus = () => {
|
||||||
void tick().then(() => focusableElement?.focus());
|
const focusableElement = container.querySelector<HTMLElement>(selectors);
|
||||||
|
// Use tick() to ensure focus trap works correctly inside <Portal />
|
||||||
|
void tick().then(() => focusableElement?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
if (withDefaults(options).active) {
|
||||||
|
setInitialFocus();
|
||||||
|
}
|
||||||
|
|
||||||
const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => {
|
const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => {
|
||||||
const focusableElements = container.querySelectorAll<HTMLElement>(selectors);
|
const focusableElements = container.querySelectorAll<HTMLElement>(selectors);
|
||||||
@ -27,7 +45,7 @@ export function focusTrap(container: HTMLElement) {
|
|||||||
shortcut: { key: 'Tab' },
|
shortcut: { key: 'Tab' },
|
||||||
onShortcut: (event) => {
|
onShortcut: (event) => {
|
||||||
const [firstElement, lastElement] = getFocusableElements();
|
const [firstElement, lastElement] = getFocusableElements();
|
||||||
if (document.activeElement === lastElement) {
|
if (document.activeElement === lastElement && withDefaults(options).active) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
firstElement?.focus();
|
firstElement?.focus();
|
||||||
}
|
}
|
||||||
@ -39,7 +57,7 @@ export function focusTrap(container: HTMLElement) {
|
|||||||
shortcut: { key: 'Tab', shift: true },
|
shortcut: { key: 'Tab', shift: true },
|
||||||
onShortcut: (event) => {
|
onShortcut: (event) => {
|
||||||
const [firstElement, lastElement] = getFocusableElements();
|
const [firstElement, lastElement] = getFocusableElements();
|
||||||
if (document.activeElement === firstElement) {
|
if (document.activeElement === firstElement && withDefaults(options).active) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
lastElement?.focus();
|
lastElement?.focus();
|
||||||
}
|
}
|
||||||
@ -48,6 +66,12 @@ export function focusTrap(container: HTMLElement) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
update(newOptions?: Options) {
|
||||||
|
options = newOptions;
|
||||||
|
if (withDefaults(options).active) {
|
||||||
|
setInitialFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyShortcuts?.();
|
destroyShortcuts?.();
|
||||||
if (triggerElement instanceof HTMLElement) {
|
if (triggerElement instanceof HTMLElement) {
|
||||||
|
@ -7,10 +7,17 @@
|
|||||||
* Target for the skip link to move focus to.
|
* Target for the skip link to move focus to.
|
||||||
*/
|
*/
|
||||||
target?: string;
|
target?: string;
|
||||||
|
/**
|
||||||
|
* Text for the skip link button.
|
||||||
|
*/
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/**
|
||||||
|
* Breakpoint at which the skip link is visible. Defaults to always being visible.
|
||||||
|
*/
|
||||||
|
breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
}
|
}
|
||||||
|
|
||||||
let { target = 'main', text = $t('skip_to_content') }: Props = $props();
|
let { target = 'main', text = $t('skip_to_content'), breakpoint }: Props = $props();
|
||||||
|
|
||||||
let isFocused = $state(false);
|
let isFocused = $state(false);
|
||||||
|
|
||||||
@ -18,6 +25,29 @@
|
|||||||
const targetEl = document.querySelector<HTMLElement>(target);
|
const targetEl = document.querySelector<HTMLElement>(target);
|
||||||
targetEl?.focus();
|
targetEl?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getBreakpoint = () => {
|
||||||
|
if (!breakpoint) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
switch (breakpoint) {
|
||||||
|
case 'sm': {
|
||||||
|
return 'hidden sm:block';
|
||||||
|
}
|
||||||
|
case 'md': {
|
||||||
|
return 'hidden md:block';
|
||||||
|
}
|
||||||
|
case 'lg': {
|
||||||
|
return 'hidden lg:block';
|
||||||
|
}
|
||||||
|
case 'xl': {
|
||||||
|
return 'hidden xl:block';
|
||||||
|
}
|
||||||
|
case '2xl': {
|
||||||
|
return 'hidden 2xl:block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
<div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
|
||||||
@ -25,6 +55,7 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
rounded="none"
|
rounded="none"
|
||||||
onclick={moveFocus}
|
onclick={moveFocus}
|
||||||
|
class={getBreakpoint()}
|
||||||
onfocus={() => (isFocused = true)}
|
onfocus={() => (isFocused = true)}
|
||||||
onblur={() => (isFocused = false)}
|
onblur={() => (isFocused = false)}
|
||||||
>
|
>
|
||||||
|
@ -51,7 +51,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="relative grid h-screen grid-cols-[theme(spacing.18)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
|
class="relative grid h-screen grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
|
||||||
>
|
>
|
||||||
{#if sidebar}{@render sidebar()}{:else if admin}
|
{#if sidebar}{@render sidebar()}{:else if admin}
|
||||||
<AdminSideBar />
|
<AdminSideBar />
|
||||||
@ -66,7 +66,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
{#if title}
|
{#if title}
|
||||||
<div class="font-medium" tabindex="-1" id={headerId}>{title}</div>
|
<div class="font-medium outline-none" tabindex="-1" id={headerId}>{title}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
|
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
|
||||||
|
@ -726,7 +726,7 @@
|
|||||||
class={[
|
class={[
|
||||||
'scrollbar-hidden h-full overflow-y-auto outline-none',
|
'scrollbar-hidden h-full overflow-y-auto outline-none',
|
||||||
{ 'm-0': isEmpty },
|
{ 'm-0': isEmpty },
|
||||||
{ 'ml-4 tall:ml-0': !isEmpty },
|
{ 'ml-0': !isEmpty },
|
||||||
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
|
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
|
||||||
]}
|
]}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
<section
|
<section
|
||||||
id="memory-lane"
|
id="memory-lane"
|
||||||
bind:this={memoryLaneElement}
|
bind:this={memoryLaneElement}
|
||||||
class="relative mt-5 overflow-x-scroll overflow-y-hidden whitespace-nowrap transition-all"
|
class="relative mt-5 mx-2 overflow-x-scroll overflow-y-hidden whitespace-nowrap transition-all"
|
||||||
style="scrollbar-width:none"
|
style="scrollbar-width:none"
|
||||||
use:resizeObserver={({ width }) => (offsetWidth = width)}
|
use:resizeObserver={({ width }) => (offsetWidth = width)}
|
||||||
onscroll={onScroll}
|
onscroll={onScroll}
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export const menuButtonId = 'top-menu-button';
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { clickOutside } from '$lib/actions/click-outside';
|
import { clickOutside } from '$lib/actions/click-outside';
|
||||||
@ -12,13 +16,14 @@
|
|||||||
import { handleLogout } from '$lib/utils/auth';
|
import { handleLogout } from '$lib/utils/auth';
|
||||||
import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk';
|
import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk';
|
||||||
import { Button, IconButton } from '@immich/ui';
|
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 { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import ThemeButton from '../theme-button.svelte';
|
import ThemeButton from '../theme-button.svelte';
|
||||||
import UserAvatar from '../user-avatar.svelte';
|
import UserAvatar from '../user-avatar.svelte';
|
||||||
import AccountInfoPanel from './account-info-panel.svelte';
|
import AccountInfoPanel from './account-info-panel.svelte';
|
||||||
|
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -57,11 +62,34 @@
|
|||||||
>
|
>
|
||||||
<SkipLink text={$t('skip_to_content')} />
|
<SkipLink text={$t('skip_to_content')} />
|
||||||
<div
|
<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}>
|
<div class="flex flex-row gap-1 mx-4 items-center">
|
||||||
<ImmichLogo class="max-md:h-[48px] h-[50px]" noText={mobileDevice.maxMd} />
|
<div>
|
||||||
</a>
|
<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="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">
|
<div class="hidden w-full max-w-5xl flex-1 tall:pl-0 sm:block">
|
||||||
{#if $featureFlags.search}
|
{#if $featureFlags.search}
|
||||||
@ -80,7 +108,6 @@
|
|||||||
href={AppRoute.SEARCH}
|
href={AppRoute.SEARCH}
|
||||||
id="search-button"
|
id="search-button"
|
||||||
class="sm:hidden"
|
class="sm:hidden"
|
||||||
title={$t('go_to_search')}
|
|
||||||
aria-label={$t('go_to_search')}
|
aria-label={$t('go_to_search')}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@ -120,7 +147,6 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="medium"
|
size="medium"
|
||||||
title={$t('support_and_feedback')}
|
|
||||||
icon={mdiHelpCircleOutline}
|
icon={mdiHelpCircleOutline}
|
||||||
onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
|
onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
|
||||||
aria-label={$t('support_and_feedback')}
|
aria-label={$t('support_and_feedback')}
|
||||||
|
@ -8,7 +8,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Individual Purchase Option -->
|
<!-- 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">
|
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||||
<Icon path={mdiAccount} size="56" />
|
<Icon path={mdiAccount} size="56" />
|
||||||
<p class="font-semibold text-lg mt-1">{$t('purchase_individual_title')}</p>
|
<p class="font-semibold text-lg mt-1">{$t('purchase_individual_title')}</p>
|
||||||
|
@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 />
|
<ServerPurchaseOptionCard />
|
||||||
<UserPurchaseOptionCard />
|
<UserPurchaseOptionCard />
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,7 +8,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- SERVER Purchase Options -->
|
<!-- 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">
|
<div class="text-immich-primary dark:text-immich-dark-primary">
|
||||||
<Icon path={mdiServer} size="56" />
|
<Icon path={mdiServer} size="56" />
|
||||||
<p class="font-semibold text-lg mt-1">{$t('purchase_server_title')}</p>
|
<p class="font-semibold text-lg mt-1">{$t('purchase_server_title')}</p>
|
||||||
|
@ -78,7 +78,7 @@
|
|||||||
<LicenseModal onClose={() => (isOpen = false)} />
|
<LicenseModal onClose={() => (isOpen = false)} />
|
||||||
{/if}
|
{/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}
|
{#if $isPurchased && $preferences.purchase.showSupportBadge}
|
||||||
<button
|
<button
|
||||||
onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
|
onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
|
||||||
@ -95,7 +95,7 @@
|
|||||||
onmouseleave={() => (hoverButton = false)}
|
onmouseleave={() => (hoverButton = false)}
|
||||||
onfocus={onButtonHover}
|
onfocus={onButtonHover}
|
||||||
onblur={() => (hoverButton = false)}
|
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 justify-between w-full place-items-center place-content-center">
|
||||||
<div class="flex place-items-center place-content-center gap-1">
|
<div class="flex place-items-center place-content-center gap-1">
|
||||||
@ -110,7 +110,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<Icon
|
<Icon
|
||||||
path={mdiInformationOutline}
|
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"
|
size="18"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -123,7 +123,7 @@
|
|||||||
{#if showMessage}
|
{#if showMessage}
|
||||||
<dialog
|
<dialog
|
||||||
open
|
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 }}
|
transition:fade={{ duration: 150 }}
|
||||||
onmouseover={() => (hoverMessage = true)}
|
onmouseover={() => (hoverMessage = true)}
|
||||||
onmouseleave={() => (hoverMessage = false)}
|
onmouseleave={() => (hoverMessage = false)}
|
||||||
|
@ -42,7 +42,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<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}
|
{#if $connected}
|
||||||
<div class="flex gap-2 place-items-center place-content-center">
|
<div class="flex gap-2 place-items-center place-content-center">
|
||||||
|
@ -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
|
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
|
{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">
|
<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 />
|
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
|
||||||
<span class="text-sm font-medium">{title}</span>
|
<span class="text-sm font-medium">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,17 +1,56 @@
|
|||||||
<script lang="ts">
|
<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 {
|
interface Props {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mdBreakpoint = 768;
|
||||||
|
|
||||||
let { children }: Props = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:innerWidth />
|
||||||
<section
|
<section
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
tabindex="-1"
|
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>
|
</section>
|
||||||
|
@ -84,10 +84,7 @@
|
|||||||
bind:isSelected={isSharingSelected}
|
bind:isSelected={isSharingSelected}
|
||||||
></SideBarLink>
|
></SideBarLink>
|
||||||
|
|
||||||
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
<p class="text-xs p-6 dark:text-immich-dark-fg">{$t('library').toUpperCase()}</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<SideBarLink
|
<SideBarLink
|
||||||
title={$t('favorites')}
|
title={$t('favorites')}
|
||||||
|
@ -46,7 +46,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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', {
|
title={$t('storage_usage', {
|
||||||
values: {
|
values: {
|
||||||
used: getByteUnitString(usedBytes, $locale, 3),
|
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}
|
{#if userInteraction.serverInfo}
|
||||||
<p class="text-gray-500 dark:text-gray-300">
|
<p class="text-gray-500 dark:text-gray-300">
|
||||||
{$t('storage_usage', {
|
{$t('storage_usage', {
|
||||||
values: {
|
values: {
|
||||||
used: getByteUnitString(usedBytes, $locale),
|
used: getByteUnitString(usedBytes, $locale),
|
||||||
available: getByteUnitString(availableBytes, $locale),
|
available: getByteUnitString(availableBytes, $locale),
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
<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 class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
href={getLink(path)}
|
href={getLink(path)}
|
||||||
title={value}
|
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'}`}
|
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' : ''}>
|
<button type="button" {onclick} class={Object.values(tree).length === 0 ? 'invisible' : ''}>
|
||||||
<Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
|
<Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
|
||||||
|
1
web/src/lib/stores/side-bar.svelte.ts
Normal file
1
web/src/lib/stores/side-bar.svelte.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const isSidebarOpen = $state({ value: false });
|
@ -131,7 +131,7 @@
|
|||||||
<UserPageLayout title={data.meta.title}>
|
<UserPageLayout title={data.meta.title}>
|
||||||
{#snippet sidebar()}
|
{#snippet sidebar()}
|
||||||
<SideBarSection>
|
<SideBarSection>
|
||||||
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} />
|
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
|
||||||
<section>
|
<section>
|
||||||
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
|
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
|
||||||
<div class="h-full">
|
<div class="h-full">
|
||||||
|
@ -148,7 +148,7 @@
|
|||||||
<UserPageLayout title={data.meta.title}>
|
<UserPageLayout title={data.meta.title}>
|
||||||
{#snippet sidebar()}
|
{#snippet sidebar()}
|
||||||
<SideBarSection>
|
<SideBarSection>
|
||||||
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} />
|
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
|
||||||
<section>
|
<section>
|
||||||
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
|
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
|
||||||
<div class="h-full">
|
<div class="h-full">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user