mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:29:32 -05:00 
			
		
		
		
	feat(web): keyboard accessible context menus (#10017)
* feat(web,a11y): context menu keyboard navigation * wip: all context menus visible * wip: more migrations to the ButtonContextMenu, usability improvements * wip: migrate Administration, PeopleCard * wip: refocus the button on click, docs * fix: more intuitive RightClickContextMenu - configurable title - focus management: tab keys, clicks, closing the menu - automatically closing when an option is selected * fix: refining the little details - adjust the aria attributes - intuitive escape key propagation - extract context into its own file * fix: dropdown options not clickable in a <Portal> * wip: small fixes - export selectedColor to prevent unexpected styling - better context function naming * chore: revert changes to list navigation, to reduce scope of the PR * fix: remove topBorder prop * feat: automatically select the first option on enter or space keypress * fix: use Svelte store instead to handle selecting menu options - better prop naming for ButtonContextMenu * feat: hovering the mouse can change the active element * fix: remove Portal, more predictable open/close behavior * feat: make selected item visible using a scroll - also: minor cleanup of the context-menu-navigation Svelte action * feat: maintain context menu position on resize * fix: use the whole padding class as better tailwind convention * fix: options not announcing with screen reader for ButtonContextMenu * fix: screen reader announcing right click context menu options * fix: handle focus out scenario --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									99c6fdbc1c
								
							
						
					
					
						commit
						b71aa4473b
					
				
							
								
								
									
										108
									
								
								web/src/lib/actions/context-menu-navigation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								web/src/lib/actions/context-menu-navigation.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,108 @@
 | 
				
			|||||||
 | 
					import { shortcuts } from '$lib/actions/shortcut';
 | 
				
			||||||
 | 
					import { tick } from 'svelte';
 | 
				
			||||||
 | 
					import type { Action } from 'svelte/action';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Options {
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * A function that is called when the dropdown should be closed.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  closeDropdown: () => void;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * The container element that with direct children that should be navigated.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  container: HTMLElement;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Indicates if the dropdown is open.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  isOpen: boolean;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Override the default behavior for the escape key.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  onEscape?: (event: KeyboardEvent) => void;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * A function that is called when the dropdown should be opened.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  openDropdown?: (event: KeyboardEvent) => void;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * The id of the currently selected element.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  selectedId: string | undefined;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * A function that is called when the selection changes, to notify consumers of the new selected id.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  selectionChanged: (id: string | undefined) => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const contextMenuNavigation: Action<HTMLElement, Options> = (node, options: Options) => {
 | 
				
			||||||
 | 
					  const getCurrentElement = () => {
 | 
				
			||||||
 | 
					    const { container, selectedId: activeId } = options;
 | 
				
			||||||
 | 
					    return container?.querySelector(`#${activeId}`) as HTMLElement | null;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const close = () => {
 | 
				
			||||||
 | 
					    const { closeDropdown, selectionChanged } = options;
 | 
				
			||||||
 | 
					    selectionChanged(undefined);
 | 
				
			||||||
 | 
					    closeDropdown();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					    const { selectionChanged, container, openDropdown } = options;
 | 
				
			||||||
 | 
					    if (openDropdown) {
 | 
				
			||||||
 | 
					      openDropdown(event);
 | 
				
			||||||
 | 
					      await tick();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
 | 
				
			||||||
 | 
					    if (children.length === 0) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const currentEl = getCurrentElement();
 | 
				
			||||||
 | 
					    const currentIndex = currentEl ? children.indexOf(currentEl) : -1;
 | 
				
			||||||
 | 
					    const directionFactor = (direction === 'up' ? -1 : 1) + (direction === 'up' && currentIndex === -1 ? 1 : 0);
 | 
				
			||||||
 | 
					    const newIndex = (currentIndex + directionFactor + children.length) % children.length;
 | 
				
			||||||
 | 
					    const selectedNode = children[newIndex];
 | 
				
			||||||
 | 
					    selectedNode?.scrollIntoView({ block: 'nearest' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    selectionChanged(selectedNode?.id);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onEscape = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					    const { onEscape } = options;
 | 
				
			||||||
 | 
					    if (onEscape) {
 | 
				
			||||||
 | 
					      onEscape(event);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    event.stopPropagation();
 | 
				
			||||||
 | 
					    close();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleClick = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					    const { selectedId, isOpen, closeDropdown } = options;
 | 
				
			||||||
 | 
					    if (isOpen && !selectedId) {
 | 
				
			||||||
 | 
					      closeDropdown();
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (!selectedId) {
 | 
				
			||||||
 | 
					      void moveSelection('down', event);
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const currentEl = getCurrentElement();
 | 
				
			||||||
 | 
					    currentEl?.click();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { destroy } = shortcuts(node, [
 | 
				
			||||||
 | 
					    { shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) },
 | 
				
			||||||
 | 
					    { shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) },
 | 
				
			||||||
 | 
					    { shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) },
 | 
				
			||||||
 | 
					    { shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) },
 | 
				
			||||||
 | 
					    { shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) },
 | 
				
			||||||
 | 
					  ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    update(newOptions) {
 | 
				
			||||||
 | 
					      options = newOptions;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    destroy,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
interface Options {
 | 
					interface Options {
 | 
				
			||||||
  onFocusOut?: () => void;
 | 
					  onFocusOut?: (event: FocusEvent) => void;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function focusOutside(node: HTMLElement, options: Options = {}) {
 | 
					export function focusOutside(node: HTMLElement, options: Options = {}) {
 | 
				
			||||||
@ -7,7 +7,7 @@ export function focusOutside(node: HTMLElement, options: Options = {}) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const handleFocusOut = (event: FocusEvent) => {
 | 
					  const handleFocusOut = (event: FocusEvent) => {
 | 
				
			||||||
    if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) {
 | 
					    if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) {
 | 
				
			||||||
      onFocusOut();
 | 
					      onFocusOut(event);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,7 @@
 | 
				
			|||||||
  import { user } from '$lib/stores/user.store';
 | 
					  import { user } from '$lib/stores/user.store';
 | 
				
			||||||
  import type { AlbumResponseDto } from '@immich/sdk';
 | 
					  import type { AlbumResponseDto } from '@immich/sdk';
 | 
				
			||||||
  import { mdiDotsVertical } from '@mdi/js';
 | 
					  import { mdiDotsVertical } from '@mdi/js';
 | 
				
			||||||
  import { getContextMenuPosition, type ContextMenuPosition } from '$lib/utils/context-menu';
 | 
					  import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
 | 
				
			||||||
  import { getShortDateRange } from '$lib/utils/date-time';
 | 
					  import { getShortDateRange } from '$lib/utils/date-time';
 | 
				
			||||||
  import AlbumCover from '$lib/components/album-page/album-cover.svelte';
 | 
					  import AlbumCover from '$lib/components/album-page/album-cover.svelte';
 | 
				
			||||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
					  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
				
			||||||
@ -20,7 +20,7 @@
 | 
				
			|||||||
  const showAlbumContextMenu = (e: MouseEvent) => {
 | 
					  const showAlbumContextMenu = (e: MouseEvent) => {
 | 
				
			||||||
    e.stopPropagation();
 | 
					    e.stopPropagation();
 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault();
 | 
				
			||||||
    onShowContextMenu?.(getContextMenuPosition(e));
 | 
					    onShowContextMenu?.(getContextMenuPositionFromEvent(e));
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,6 @@
 | 
				
			|||||||
  import { groupBy, orderBy } from 'lodash-es';
 | 
					  import { groupBy, orderBy } from 'lodash-es';
 | 
				
			||||||
  import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
 | 
					  import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
 | 
				
			||||||
  import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
 | 
					  import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
 | 
				
			||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					 | 
				
			||||||
  import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
 | 
					  import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
 | 
				
			||||||
  import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
 | 
					  import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
@ -167,6 +166,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
 | 
					  let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
 | 
				
			||||||
  let contextMenuTargetAlbum: AlbumResponseDto | null = null;
 | 
					  let contextMenuTargetAlbum: AlbumResponseDto | null = null;
 | 
				
			||||||
 | 
					  let isOpen = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Step 1: Filter between Owned and Shared albums, or both.
 | 
					  // Step 1: Filter between Owned and Shared albums, or both.
 | 
				
			||||||
  $: {
 | 
					  $: {
 | 
				
			||||||
@ -224,7 +224,6 @@
 | 
				
			|||||||
    albumGroupIds = groupedAlbums.map(({ id }) => id);
 | 
					    albumGroupIds = groupedAlbums.map(({ id }) => id);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  $: showContextMenu = !!contextMenuTargetAlbum;
 | 
					 | 
				
			||||||
  $: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
 | 
					  $: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onMount(async () => {
 | 
					  onMount(async () => {
 | 
				
			||||||
@ -253,10 +252,11 @@
 | 
				
			|||||||
      x: contextMenuDetail.x,
 | 
					      x: contextMenuDetail.x,
 | 
				
			||||||
      y: contextMenuDetail.y,
 | 
					      y: contextMenuDetail.y,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					    isOpen = true;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const closeAlbumContextMenu = () => {
 | 
					  const closeAlbumContextMenu = () => {
 | 
				
			||||||
    contextMenuTargetAlbum = null;
 | 
					    isOpen = false;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleDownloadAlbum = async () => {
 | 
					  const handleDownloadAlbum = async () => {
 | 
				
			||||||
@ -419,34 +419,18 @@
 | 
				
			|||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<!-- Context Menu -->
 | 
					<!-- Context Menu -->
 | 
				
			||||||
<RightClickContextMenu {...contextMenuPosition} isOpen={showContextMenu} onClose={closeAlbumContextMenu}>
 | 
					<RightClickContextMenu title={$t('album_options')} {...contextMenuPosition} {isOpen} onClose={closeAlbumContextMenu}>
 | 
				
			||||||
  {#if showFullContextMenu}
 | 
					  {#if showFullContextMenu}
 | 
				
			||||||
    <MenuOption on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}>
 | 
					    <MenuOption
 | 
				
			||||||
      <p class="flex gap-2">
 | 
					      icon={mdiRenameOutline}
 | 
				
			||||||
        <Icon path={mdiRenameOutline} size="18" />
 | 
					      text={$t('edit_album')}
 | 
				
			||||||
        Edit
 | 
					      on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}
 | 
				
			||||||
      </p>
 | 
					    />
 | 
				
			||||||
    </MenuOption>
 | 
					    <MenuOption icon={mdiShareVariantOutline} text={$t('share')} on:click={() => openShareModal()} />
 | 
				
			||||||
    <MenuOption on:click={() => openShareModal()}>
 | 
					 | 
				
			||||||
      <p class="flex gap-2">
 | 
					 | 
				
			||||||
        <Icon path={mdiShareVariantOutline} size="18" />
 | 
					 | 
				
			||||||
        Share
 | 
					 | 
				
			||||||
      </p>
 | 
					 | 
				
			||||||
    </MenuOption>
 | 
					 | 
				
			||||||
  {/if}
 | 
					  {/if}
 | 
				
			||||||
  <MenuOption on:click={() => handleDownloadAlbum()}>
 | 
					  <MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} on:click={() => handleDownloadAlbum()} />
 | 
				
			||||||
    <p class="flex gap-2">
 | 
					 | 
				
			||||||
      <Icon path={mdiFolderDownloadOutline} size="18" />
 | 
					 | 
				
			||||||
      Download
 | 
					 | 
				
			||||||
    </p>
 | 
					 | 
				
			||||||
  </MenuOption>
 | 
					 | 
				
			||||||
  {#if showFullContextMenu}
 | 
					  {#if showFullContextMenu}
 | 
				
			||||||
    <MenuOption on:click={() => setAlbumToDelete()}>
 | 
					    <MenuOption icon={mdiDeleteOutline} text={$t('delete')} on:click={() => setAlbumToDelete()} />
 | 
				
			||||||
      <p class="flex gap-2">
 | 
					 | 
				
			||||||
        <Icon path={mdiDeleteOutline} size="18" />
 | 
					 | 
				
			||||||
        Delete
 | 
					 | 
				
			||||||
      </p>
 | 
					 | 
				
			||||||
    </MenuOption>
 | 
					 | 
				
			||||||
  {/if}
 | 
					  {/if}
 | 
				
			||||||
</RightClickContextMenu>
 | 
					</RightClickContextMenu>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -9,16 +9,14 @@
 | 
				
			|||||||
  } from '@immich/sdk';
 | 
					  } from '@immich/sdk';
 | 
				
			||||||
  import { mdiDotsVertical } from '@mdi/js';
 | 
					  import { mdiDotsVertical } from '@mdi/js';
 | 
				
			||||||
  import { createEventDispatcher, onMount } from 'svelte';
 | 
					  import { createEventDispatcher, onMount } from 'svelte';
 | 
				
			||||||
  import { getContextMenuPosition } from '../../utils/context-menu';
 | 
					 | 
				
			||||||
  import { handleError } from '../../utils/handle-error';
 | 
					  import { handleError } from '../../utils/handle-error';
 | 
				
			||||||
  import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
 | 
					 | 
				
			||||||
  import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
 | 
					  import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
 | 
				
			||||||
  import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
 | 
					 | 
				
			||||||
  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 | 
					  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
  import { NotificationType, notificationController } from '../shared-components/notification/notification';
 | 
					  import { NotificationType, notificationController } from '../shared-components/notification/notification';
 | 
				
			||||||
  import UserAvatar from '../shared-components/user-avatar.svelte';
 | 
					  import UserAvatar from '../shared-components/user-avatar.svelte';
 | 
				
			||||||
  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
 | 
					  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let album: AlbumResponseDto;
 | 
					  export let album: AlbumResponseDto;
 | 
				
			||||||
  export let onClose: () => void;
 | 
					  export let onClose: () => void;
 | 
				
			||||||
@ -29,8 +27,6 @@
 | 
				
			|||||||
  }>();
 | 
					  }>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let currentUser: UserResponseDto;
 | 
					  let currentUser: UserResponseDto;
 | 
				
			||||||
  let position = { x: 0, y: 0 };
 | 
					 | 
				
			||||||
  let selectedMenuUser: UserResponseDto | null = null;
 | 
					 | 
				
			||||||
  let selectedRemoveUser: UserResponseDto | null = null;
 | 
					  let selectedRemoveUser: UserResponseDto | null = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  $: isOwned = currentUser?.id == album.ownerId;
 | 
					  $: isOwned = currentUser?.id == album.ownerId;
 | 
				
			||||||
@ -43,15 +39,8 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const showContextMenu = (event: MouseEvent, user: UserResponseDto) => {
 | 
					  const handleMenuRemove = (user: UserResponseDto) => {
 | 
				
			||||||
    position = getContextMenuPosition(event);
 | 
					    selectedRemoveUser = user;
 | 
				
			||||||
    selectedMenuUser = user;
 | 
					 | 
				
			||||||
    selectedRemoveUser = null;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleMenuRemove = () => {
 | 
					 | 
				
			||||||
    selectedRemoveUser = selectedMenuUser;
 | 
					 | 
				
			||||||
    selectedMenuUser = null;
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleRemoveUser = async () => {
 | 
					  const handleRemoveUser = async () => {
 | 
				
			||||||
@ -118,31 +107,17 @@
 | 
				
			|||||||
              {/if}
 | 
					              {/if}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
            {#if isOwned}
 | 
					            {#if isOwned}
 | 
				
			||||||
              <div>
 | 
					              <ButtonContextMenu icon={mdiDotsVertical} size="20" title={$t('options')}>
 | 
				
			||||||
                <CircleIconButton
 | 
					 | 
				
			||||||
                  title={$t('options')}
 | 
					 | 
				
			||||||
                  on:click={(event) => showContextMenu(event, user)}
 | 
					 | 
				
			||||||
                  icon={mdiDotsVertical}
 | 
					 | 
				
			||||||
                  size="20"
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                {#if selectedMenuUser === user}
 | 
					 | 
				
			||||||
                  <ContextMenu {...position} onClose={() => (selectedMenuUser = null)}>
 | 
					 | 
				
			||||||
                {#if role === AlbumUserRole.Viewer}
 | 
					                {#if role === AlbumUserRole.Viewer}
 | 
				
			||||||
                      <MenuOption
 | 
					                  <MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
 | 
				
			||||||
                        on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)}
 | 
					 | 
				
			||||||
                        text={$t('allow_edits')}
 | 
					 | 
				
			||||||
                      />
 | 
					 | 
				
			||||||
                {:else}
 | 
					                {:else}
 | 
				
			||||||
                  <MenuOption
 | 
					                  <MenuOption
 | 
				
			||||||
                    on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
 | 
					                    on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
 | 
				
			||||||
                    text={$t('disallow_edits')}
 | 
					                    text={$t('disallow_edits')}
 | 
				
			||||||
                  />
 | 
					                  />
 | 
				
			||||||
                {/if}
 | 
					                {/if}
 | 
				
			||||||
                    <MenuOption on:click={handleMenuRemove} text={$t('remove')} />
 | 
					                <MenuOption on:click={() => handleMenuRemove(user)} text={$t('remove')} />
 | 
				
			||||||
                  </ContextMenu>
 | 
					              </ButtonContextMenu>
 | 
				
			||||||
                {/if}
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            {:else if user.id == currentUser?.id}
 | 
					            {:else if user.id == currentUser?.id}
 | 
				
			||||||
              <button
 | 
					              <button
 | 
				
			||||||
                type="button"
 | 
					                type="button"
 | 
				
			||||||
 | 
				
			|||||||
@ -4,8 +4,6 @@
 | 
				
			|||||||
  import { user } from '$lib/stores/user.store';
 | 
					  import { user } from '$lib/stores/user.store';
 | 
				
			||||||
  import { photoZoomState } from '$lib/stores/zoom-image.store';
 | 
					  import { photoZoomState } from '$lib/stores/zoom-image.store';
 | 
				
			||||||
  import { getAssetJobName } from '$lib/utils';
 | 
					  import { getAssetJobName } from '$lib/utils';
 | 
				
			||||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
					 | 
				
			||||||
  import { getContextMenuPosition } from '$lib/utils/context-menu';
 | 
					 | 
				
			||||||
  import { openFileUploadDialog } from '$lib/utils/file-uploader';
 | 
					  import { openFileUploadDialog } from '$lib/utils/file-uploader';
 | 
				
			||||||
  import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
 | 
					  import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
@ -36,9 +34,9 @@
 | 
				
			|||||||
    mdiUpload,
 | 
					    mdiUpload,
 | 
				
			||||||
  } from '@mdi/js';
 | 
					  } from '@mdi/js';
 | 
				
			||||||
  import { createEventDispatcher } from 'svelte';
 | 
					  import { createEventDispatcher } from 'svelte';
 | 
				
			||||||
  import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
 | 
					 | 
				
			||||||
  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 | 
					  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let asset: AssetResponseDto;
 | 
					  export let asset: AssetResponseDto;
 | 
				
			||||||
  export let album: AlbumResponseDto | null = null;
 | 
					  export let album: AlbumResponseDto | null = null;
 | 
				
			||||||
@ -79,21 +77,11 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const dispatch = createEventDispatcher<EventTypes>();
 | 
					  const dispatch = createEventDispatcher<EventTypes>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let contextMenuPosition = { x: 0, y: 0 };
 | 
					 | 
				
			||||||
  let isShowAssetOptions = false;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const showOptionsMenu = (event: MouseEvent) => {
 | 
					 | 
				
			||||||
    contextMenuPosition = getContextMenuPosition(event, 'top-right');
 | 
					 | 
				
			||||||
    isShowAssetOptions = !isShowAssetOptions;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const onJobClick = (name: AssetJobName) => {
 | 
					  const onJobClick = (name: AssetJobName) => {
 | 
				
			||||||
    isShowAssetOptions = false;
 | 
					 | 
				
			||||||
    dispatch('runJob', name);
 | 
					    dispatch('runJob', name);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onMenuClick = (eventName: keyof EventTypes) => {
 | 
					  const onMenuClick = (eventName: keyof EventTypes) => {
 | 
				
			||||||
    isShowAssetOptions = false;
 | 
					 | 
				
			||||||
    dispatch(eventName);
 | 
					    dispatch(eventName);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@ -187,28 +175,12 @@
 | 
				
			|||||||
        on:delete={() => dispatch('delete')}
 | 
					        on:delete={() => dispatch('delete')}
 | 
				
			||||||
        on:permanentlyDelete={() => dispatch('permanentlyDelete')}
 | 
					        on:permanentlyDelete={() => dispatch('permanentlyDelete')}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
      <div
 | 
					      <ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
 | 
				
			||||||
        use:clickOutside={{
 | 
					 | 
				
			||||||
          onOutclick: () => (isShowAssetOptions = false),
 | 
					 | 
				
			||||||
          onEscape: () => (isShowAssetOptions = false),
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <CircleIconButton color="opaque" icon={mdiDotsVertical} on:click={showOptionsMenu} title={$t('more')} />
 | 
					 | 
				
			||||||
        {#if isShowAssetOptions}
 | 
					 | 
				
			||||||
          <ContextMenu {...contextMenuPosition} direction="left">
 | 
					 | 
				
			||||||
        {#if showSlideshow}
 | 
					        {#if showSlideshow}
 | 
				
			||||||
              <MenuOption
 | 
					          <MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text={$t('slideshow')} />
 | 
				
			||||||
                icon={mdiPresentationPlay}
 | 
					 | 
				
			||||||
                on:click={() => onMenuClick('playSlideShow')}
 | 
					 | 
				
			||||||
                text={$t('slideshow')}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
        {/if}
 | 
					        {/if}
 | 
				
			||||||
        {#if showDownloadButton}
 | 
					        {#if showDownloadButton}
 | 
				
			||||||
              <MenuOption
 | 
					          <MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text={$t('download')} />
 | 
				
			||||||
                icon={mdiFolderDownloadOutline}
 | 
					 | 
				
			||||||
                on:click={() => onMenuClick('download')}
 | 
					 | 
				
			||||||
                text={$t('download')}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
        {/if}
 | 
					        {/if}
 | 
				
			||||||
        {#if asset.isTrashed}
 | 
					        {#if asset.isTrashed}
 | 
				
			||||||
          <MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
 | 
					          <MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
 | 
				
			||||||
@ -268,9 +240,7 @@
 | 
				
			|||||||
            />
 | 
					            />
 | 
				
			||||||
          {/if}
 | 
					          {/if}
 | 
				
			||||||
        {/if}
 | 
					        {/if}
 | 
				
			||||||
          </ContextMenu>
 | 
					      </ButtonContextMenu>
 | 
				
			||||||
        {/if}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    {/if}
 | 
					    {/if}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,12 +1,21 @@
 | 
				
			|||||||
 | 
					<script lang="ts" context="module">
 | 
				
			||||||
 | 
					  export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
  type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let type: 'button' | 'submit' | 'reset' = 'button';
 | 
					  export let type: 'button' | 'submit' | 'reset' = 'button';
 | 
				
			||||||
  export let icon: string;
 | 
					  export let icon: string;
 | 
				
			||||||
  export let color: Color = 'transparent';
 | 
					  export let color: Color = 'transparent';
 | 
				
			||||||
  export let title: string;
 | 
					  export let title: string;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * The padding of the button, used by the `p-{padding}` Tailwind CSS class.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
  export let padding = '3';
 | 
					  export let padding = '3';
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Size of the button, used for a CSS value.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
  export let size = '24';
 | 
					  export let size = '24';
 | 
				
			||||||
  export let hideMobile = false;
 | 
					  export let hideMobile = false;
 | 
				
			||||||
  export let buttonSize: string | undefined = undefined;
 | 
					  export let buttonSize: string | undefined = undefined;
 | 
				
			||||||
@ -14,6 +23,10 @@
 | 
				
			|||||||
   * viewBox attribute for the SVG icon.
 | 
					   * viewBox attribute for the SVG icon.
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  export let viewBox: string | undefined = undefined;
 | 
					  export let viewBox: string | undefined = undefined;
 | 
				
			||||||
 | 
					  export let id: string | undefined = undefined;
 | 
				
			||||||
 | 
					  export let ariaHasPopup: boolean | undefined = undefined;
 | 
				
			||||||
 | 
					  export let ariaExpanded: boolean | undefined = undefined;
 | 
				
			||||||
 | 
					  export let ariaControls: string | undefined = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Override the default styling of the button for specific use cases, such as the icon color.
 | 
					   * Override the default styling of the button for specific use cases, such as the icon color.
 | 
				
			||||||
@ -33,14 +46,19 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  $: colorClass = colorClasses[color];
 | 
					  $: colorClass = colorClasses[color];
 | 
				
			||||||
  $: mobileClass = hideMobile ? 'hidden sm:flex' : '';
 | 
					  $: mobileClass = hideMobile ? 'hidden sm:flex' : '';
 | 
				
			||||||
 | 
					  $: paddingClass = `p-${padding}`;
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<button
 | 
					<button
 | 
				
			||||||
 | 
					  {id}
 | 
				
			||||||
  {title}
 | 
					  {title}
 | 
				
			||||||
  {type}
 | 
					  {type}
 | 
				
			||||||
  style:width={buttonSize ? buttonSize + 'px' : ''}
 | 
					  style:width={buttonSize ? buttonSize + 'px' : ''}
 | 
				
			||||||
  style:height={buttonSize ? buttonSize + 'px' : ''}
 | 
					  style:height={buttonSize ? buttonSize + 'px' : ''}
 | 
				
			||||||
  class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
 | 
					  class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
 | 
				
			||||||
 | 
					  aria-haspopup={ariaHasPopup}
 | 
				
			||||||
 | 
					  aria-expanded={ariaExpanded}
 | 
				
			||||||
 | 
					  aria-controls={ariaControls}
 | 
				
			||||||
  on:click
 | 
					  on:click
 | 
				
			||||||
>
 | 
					>
 | 
				
			||||||
  <Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
 | 
					  <Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,6 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import { AppRoute, QueryParameter } from '$lib/constants';
 | 
					  import { AppRoute, QueryParameter } from '$lib/constants';
 | 
				
			||||||
  import { getPeopleThumbnailUrl } from '$lib/utils';
 | 
					  import { getPeopleThumbnailUrl } from '$lib/utils';
 | 
				
			||||||
  import { getContextMenuPosition } from '$lib/utils/context-menu';
 | 
					 | 
				
			||||||
  import { type PersonResponseDto } from '@immich/sdk';
 | 
					  import { type PersonResponseDto } from '@immich/sdk';
 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
    mdiAccountEditOutline,
 | 
					    mdiAccountEditOutline,
 | 
				
			||||||
@ -12,11 +11,10 @@
 | 
				
			|||||||
  } from '@mdi/js';
 | 
					  } from '@mdi/js';
 | 
				
			||||||
  import { createEventDispatcher } from 'svelte';
 | 
					  import { createEventDispatcher } from 'svelte';
 | 
				
			||||||
  import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
 | 
					  import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
 | 
				
			||||||
  import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
 | 
					 | 
				
			||||||
  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 | 
					  import MenuOption from '../shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
  import Portal from '../shared-components/portal/portal.svelte';
 | 
					 | 
				
			||||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
					 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import { focusOutside } from '$lib/actions/focus-outside';
 | 
				
			||||||
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let person: PersonResponseDto;
 | 
					  export let person: PersonResponseDto;
 | 
				
			||||||
  export let preload = false;
 | 
					  export let preload = false;
 | 
				
			||||||
@ -30,17 +28,7 @@
 | 
				
			|||||||
  }>();
 | 
					  }>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let showVerticalDots = false;
 | 
					  let showVerticalDots = false;
 | 
				
			||||||
  let showContextMenu = false;
 | 
					 | 
				
			||||||
  let contextMenuPosition = { x: 0, y: 0 };
 | 
					 | 
				
			||||||
  const showMenu = (event: MouseEvent) => {
 | 
					 | 
				
			||||||
    contextMenuPosition = getContextMenuPosition(event);
 | 
					 | 
				
			||||||
    showContextMenu = !showContextMenu;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  const onMenuExit = () => {
 | 
					 | 
				
			||||||
    showContextMenu = false;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  const onMenuClick = (event: MenuItemEvent) => {
 | 
					  const onMenuClick = (event: MenuItemEvent) => {
 | 
				
			||||||
    onMenuExit();
 | 
					 | 
				
			||||||
    dispatch(event);
 | 
					    dispatch(event);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
@ -51,8 +39,13 @@
 | 
				
			|||||||
  on:mouseenter={() => (showVerticalDots = true)}
 | 
					  on:mouseenter={() => (showVerticalDots = true)}
 | 
				
			||||||
  on:mouseleave={() => (showVerticalDots = false)}
 | 
					  on:mouseleave={() => (showVerticalDots = false)}
 | 
				
			||||||
  role="group"
 | 
					  role="group"
 | 
				
			||||||
 | 
					  use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					  <a
 | 
				
			||||||
 | 
					    href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
 | 
				
			||||||
 | 
					    draggable="false"
 | 
				
			||||||
 | 
					    on:focus={() => (showVerticalDots = true)}
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
  <a href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}" draggable="false">
 | 
					 | 
				
			||||||
    <div class="w-full h-full rounded-xl brightness-95 filter">
 | 
					    <div class="w-full h-full rounded-xl brightness-95 filter">
 | 
				
			||||||
      <ImageThumbnail
 | 
					      <ImageThumbnail
 | 
				
			||||||
        shadow
 | 
					        shadow
 | 
				
			||||||
@ -73,22 +66,15 @@
 | 
				
			|||||||
    {/if}
 | 
					    {/if}
 | 
				
			||||||
  </a>
 | 
					  </a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="absolute right-2 top-2" class:hidden={!showVerticalDots}>
 | 
					  <div class="absolute top-2 right-2">
 | 
				
			||||||
    <CircleIconButton
 | 
					    <ButtonContextMenu
 | 
				
			||||||
 | 
					      buttonClass="icon-white-drop-shadow focus:opacity-100 {showVerticalDots ? 'opacity-100' : 'opacity-0'}"
 | 
				
			||||||
      color="opaque"
 | 
					      color="opaque"
 | 
				
			||||||
 | 
					      padding="2"
 | 
				
			||||||
 | 
					      size="20"
 | 
				
			||||||
      icon={mdiDotsVertical}
 | 
					      icon={mdiDotsVertical}
 | 
				
			||||||
      title={$t('show_person_options')}
 | 
					      title={$t('show_person_options')}
 | 
				
			||||||
      size="20"
 | 
					    >
 | 
				
			||||||
      padding="2"
 | 
					 | 
				
			||||||
      class="icon-white-drop-shadow"
 | 
					 | 
				
			||||||
      on:click={showMenu}
 | 
					 | 
				
			||||||
    />
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{#if showContextMenu}
 | 
					 | 
				
			||||||
  <Portal target="body">
 | 
					 | 
				
			||||||
    <ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
 | 
					 | 
				
			||||||
      <MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} />
 | 
					      <MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} />
 | 
				
			||||||
      <MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
 | 
					      <MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
 | 
				
			||||||
      <MenuOption
 | 
					      <MenuOption
 | 
				
			||||||
@ -101,6 +87,6 @@
 | 
				
			|||||||
        icon={mdiAccountMultipleCheckOutline}
 | 
					        icon={mdiAccountMultipleCheckOutline}
 | 
				
			||||||
        text={$t('merge_people')}
 | 
					        text={$t('merge_people')}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </ContextMenu>
 | 
					    </ButtonContextMenu>
 | 
				
			||||||
  </Portal>
 | 
					  </div>
 | 
				
			||||||
{/if}
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -12,7 +12,7 @@
 | 
				
			|||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
 | 
					  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
 | 
				
			||||||
  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
					  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
					  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
				
			||||||
  import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
 | 
					  import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
 | 
				
			||||||
@ -146,20 +146,20 @@
 | 
				
			|||||||
      <CreateSharedLink />
 | 
					      <CreateSharedLink />
 | 
				
			||||||
      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
 | 
					      <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					      <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
        <AddToAlbum />
 | 
					        <AddToAlbum />
 | 
				
			||||||
        <AddToAlbum shared />
 | 
					        <AddToAlbum shared />
 | 
				
			||||||
      </AssetSelectContextMenu>
 | 
					      </ButtonContextMenu>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
 | 
					      <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
					      <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
				
			||||||
        <DownloadAction menuItem />
 | 
					        <DownloadAction menuItem />
 | 
				
			||||||
        <ChangeDate menuItem />
 | 
					        <ChangeDate menuItem />
 | 
				
			||||||
        <ChangeLocation menuItem />
 | 
					        <ChangeLocation menuItem />
 | 
				
			||||||
        <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
 | 
					        <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
 | 
				
			||||||
        <DeleteAssets menuItem {onAssetDelete} />
 | 
					        <DeleteAssets menuItem {onAssetDelete} />
 | 
				
			||||||
      </AssetSelectContextMenu>
 | 
					      </ButtonContextMenu>
 | 
				
			||||||
    </AssetSelectControlBar>
 | 
					    </AssetSelectControlBar>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,6 @@
 | 
				
			|||||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
					  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
  import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
 | 
					  import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
 | 
				
			||||||
  import type { AlbumResponseDto } from '@immich/sdk';
 | 
					  import type { AlbumResponseDto } from '@immich/sdk';
 | 
				
			||||||
  import { getMenuContext } from '../asset-select-context-menu.svelte';
 | 
					 | 
				
			||||||
  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 | 
					  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 | 
				
			||||||
  import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
 | 
					  import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
@ -13,16 +12,13 @@
 | 
				
			|||||||
  let showAlbumPicker = false;
 | 
					  let showAlbumPicker = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { getAssets, clearSelect } = getAssetControlContext();
 | 
					  const { getAssets, clearSelect } = getAssetControlContext();
 | 
				
			||||||
  const closeMenu = getMenuContext();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleHideAlbumPicker = () => {
 | 
					  const handleHideAlbumPicker = () => {
 | 
				
			||||||
    showAlbumPicker = false;
 | 
					    showAlbumPicker = false;
 | 
				
			||||||
    closeMenu();
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleAddToNewAlbum = async (albumName: string) => {
 | 
					  const handleAddToNewAlbum = async (albumName: string) => {
 | 
				
			||||||
    showAlbumPicker = false;
 | 
					    showAlbumPicker = false;
 | 
				
			||||||
    closeMenu();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const assetIds = [...getAssets()].map((asset) => asset.id);
 | 
					    const assetIds = [...getAssets()].map((asset) => asset.id);
 | 
				
			||||||
    await addAssetsToNewAlbum(albumName, assetIds);
 | 
					    await addAssetsToNewAlbum(albumName, assetIds);
 | 
				
			||||||
 | 
				
			|||||||
@ -1,35 +0,0 @@
 | 
				
			|||||||
<script lang="ts" context="module">
 | 
					 | 
				
			||||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
					 | 
				
			||||||
  import { createContext } from '$lib/utils/context';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const { get: getMenuContext, set: setContext } = createContext<() => void>();
 | 
					 | 
				
			||||||
  export { getMenuContext };
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<script lang="ts">
 | 
					 | 
				
			||||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
					 | 
				
			||||||
  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
					 | 
				
			||||||
  import { getContextMenuPosition } from '$lib/utils/context-menu';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  export let icon: string;
 | 
					 | 
				
			||||||
  export let title: string;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let showContextMenu = false;
 | 
					 | 
				
			||||||
  let contextMenuPosition = { x: 0, y: 0 };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleShowMenu = (event: MouseEvent) => {
 | 
					 | 
				
			||||||
    contextMenuPosition = getContextMenuPosition(event, 'top-left');
 | 
					 | 
				
			||||||
    showContextMenu = !showContextMenu;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  setContext(() => (showContextMenu = false));
 | 
					 | 
				
			||||||
</script>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
<div use:clickOutside={{ onOutclick: () => (showContextMenu = false) }}>
 | 
					 | 
				
			||||||
  <CircleIconButton {title} {icon} on:click={handleShowMenu} />
 | 
					 | 
				
			||||||
  {#if showContextMenu}
 | 
					 | 
				
			||||||
    <ContextMenu {...contextMenuPosition}>
 | 
					 | 
				
			||||||
      <slot />
 | 
					 | 
				
			||||||
    </ContextMenu>
 | 
					 | 
				
			||||||
  {/if}
 | 
					 | 
				
			||||||
</div>
 | 
					 | 
				
			||||||
@ -0,0 +1,151 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import CircleIconButton, { type Color } from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
				
			||||||
 | 
					  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
				
			||||||
 | 
					  import {
 | 
				
			||||||
 | 
					    getContextMenuPositionFromBoundingRect,
 | 
				
			||||||
 | 
					    getContextMenuPositionFromEvent,
 | 
				
			||||||
 | 
					    type Align,
 | 
				
			||||||
 | 
					  } from '$lib/utils/context-menu';
 | 
				
			||||||
 | 
					  import { generateId } from '$lib/utils/generate-id';
 | 
				
			||||||
 | 
					  import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
 | 
				
			||||||
 | 
					  import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
 | 
				
			||||||
 | 
					  import { clickOutside } from '$lib/actions/click-outside';
 | 
				
			||||||
 | 
					  import { shortcuts } from '$lib/actions/shortcut';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export let icon: string;
 | 
				
			||||||
 | 
					  export let title: string;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * The alignment of the context menu relative to the button.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  export let align: Align = 'top-left';
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * The direction in which the context menu should open.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  export let direction: 'left' | 'right' = 'right';
 | 
				
			||||||
 | 
					  export let color: Color = 'transparent';
 | 
				
			||||||
 | 
					  export let size: string | undefined = undefined;
 | 
				
			||||||
 | 
					  export let padding: string | undefined = undefined;
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Additional classes to apply to the button.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  export let buttonClass: string | undefined = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let isOpen = false;
 | 
				
			||||||
 | 
					  let contextMenuPosition = { x: 0, y: 0 };
 | 
				
			||||||
 | 
					  let menuContainer: HTMLUListElement;
 | 
				
			||||||
 | 
					  let buttonContainer: HTMLDivElement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const id = generateId();
 | 
				
			||||||
 | 
					  const buttonId = `context-menu-button-${id}`;
 | 
				
			||||||
 | 
					  const menuId = `context-menu-${id}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  $: {
 | 
				
			||||||
 | 
					    if (isOpen) {
 | 
				
			||||||
 | 
					      $optionClickCallbackStore = handleOptionClick;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const openDropdown = (event: KeyboardEvent | MouseEvent) => {
 | 
				
			||||||
 | 
					    contextMenuPosition = getContextMenuPositionFromEvent(event, align);
 | 
				
			||||||
 | 
					    isOpen = true;
 | 
				
			||||||
 | 
					    menuContainer?.focus();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleClick = (event: MouseEvent) => {
 | 
				
			||||||
 | 
					    if (isOpen) {
 | 
				
			||||||
 | 
					      closeDropdown();
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    openDropdown(event);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onEscape = (event: KeyboardEvent) => {
 | 
				
			||||||
 | 
					    if (isOpen) {
 | 
				
			||||||
 | 
					      // if the dropdown is open, stop the event from propagating
 | 
				
			||||||
 | 
					      event.stopPropagation();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    closeDropdown();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onResize = () => {
 | 
				
			||||||
 | 
					    if (!isOpen) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const closeDropdown = () => {
 | 
				
			||||||
 | 
					    if (!isOpen) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    focusButton();
 | 
				
			||||||
 | 
					    isOpen = false;
 | 
				
			||||||
 | 
					    $selectedIdStore = undefined;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleOptionClick = () => {
 | 
				
			||||||
 | 
					    closeDropdown();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const focusButton = () => {
 | 
				
			||||||
 | 
					    const button: HTMLButtonElement | null = buttonContainer.querySelector(`#${buttonId}`);
 | 
				
			||||||
 | 
					    button?.focus();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<svelte:window on:resize={onResize} />
 | 
				
			||||||
 | 
					<div
 | 
				
			||||||
 | 
					  use:contextMenuNavigation={{
 | 
				
			||||||
 | 
					    closeDropdown,
 | 
				
			||||||
 | 
					    container: menuContainer,
 | 
				
			||||||
 | 
					    isOpen,
 | 
				
			||||||
 | 
					    onEscape,
 | 
				
			||||||
 | 
					    openDropdown,
 | 
				
			||||||
 | 
					    selectedId: $selectedIdStore,
 | 
				
			||||||
 | 
					    selectionChanged: (id) => ($selectedIdStore = id),
 | 
				
			||||||
 | 
					  }}
 | 
				
			||||||
 | 
					  use:clickOutside={{ onOutclick: closeDropdown }}
 | 
				
			||||||
 | 
					  on:resize={onResize}
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					  <div bind:this={buttonContainer}>
 | 
				
			||||||
 | 
					    <CircleIconButton
 | 
				
			||||||
 | 
					      {color}
 | 
				
			||||||
 | 
					      {icon}
 | 
				
			||||||
 | 
					      {padding}
 | 
				
			||||||
 | 
					      {size}
 | 
				
			||||||
 | 
					      {title}
 | 
				
			||||||
 | 
					      ariaControls={menuId}
 | 
				
			||||||
 | 
					      ariaExpanded={isOpen}
 | 
				
			||||||
 | 
					      ariaHasPopup={true}
 | 
				
			||||||
 | 
					      class={buttonClass}
 | 
				
			||||||
 | 
					      id={buttonId}
 | 
				
			||||||
 | 
					      on:click={handleClick}
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					  <div
 | 
				
			||||||
 | 
					    use:shortcuts={[
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        shortcut: { key: 'Tab' },
 | 
				
			||||||
 | 
					        onShortcut: closeDropdown,
 | 
				
			||||||
 | 
					        preventDefault: false,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        shortcut: { key: 'Tab', shift: true },
 | 
				
			||||||
 | 
					        onShortcut: closeDropdown,
 | 
				
			||||||
 | 
					        preventDefault: false,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ]}
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <ContextMenu
 | 
				
			||||||
 | 
					      {...contextMenuPosition}
 | 
				
			||||||
 | 
					      {direction}
 | 
				
			||||||
 | 
					      ariaActiveDescendant={$selectedIdStore}
 | 
				
			||||||
 | 
					      ariaLabelledBy={buttonId}
 | 
				
			||||||
 | 
					      bind:menuElement={menuContainer}
 | 
				
			||||||
 | 
					      id={menuId}
 | 
				
			||||||
 | 
					      isVisible={isOpen}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <slot />
 | 
				
			||||||
 | 
					    </ContextMenu>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
@ -3,11 +3,16 @@
 | 
				
			|||||||
  import { slide } from 'svelte/transition';
 | 
					  import { slide } from 'svelte/transition';
 | 
				
			||||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
					  import { clickOutside } from '$lib/actions/click-outside';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export let isVisible: boolean = false;
 | 
				
			||||||
  export let direction: 'left' | 'right' = 'right';
 | 
					  export let direction: 'left' | 'right' = 'right';
 | 
				
			||||||
  export let x = 0;
 | 
					  export let x = 0;
 | 
				
			||||||
  export let y = 0;
 | 
					  export let y = 0;
 | 
				
			||||||
 | 
					  export let id: string | undefined = undefined;
 | 
				
			||||||
 | 
					  export let ariaLabel: string | undefined = undefined;
 | 
				
			||||||
 | 
					  export let ariaLabelledBy: string | undefined = undefined;
 | 
				
			||||||
 | 
					  export let ariaActiveDescendant: string | undefined = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let menuElement: HTMLDivElement | undefined = undefined;
 | 
					  export let menuElement: HTMLUListElement | undefined = undefined;
 | 
				
			||||||
  export let onClose: (() => void) | undefined = undefined;
 | 
					  export let onClose: (() => void) | undefined = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let left: number;
 | 
					  let left: number;
 | 
				
			||||||
@ -30,16 +35,25 @@
 | 
				
			|||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div
 | 
					<div
 | 
				
			||||||
  bind:this={menuElement}
 | 
					 | 
				
			||||||
  bind:clientHeight={height}
 | 
					  bind:clientHeight={height}
 | 
				
			||||||
  transition:slide={{ duration: 250, easing: quintOut }}
 | 
					  class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
 | 
				
			||||||
  class="absolute z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
 | 
					 | 
				
			||||||
  style:top="{top}px"
 | 
					 | 
				
			||||||
  style:left="{left}px"
 | 
					  style:left="{left}px"
 | 
				
			||||||
  role="menu"
 | 
					  style:top="{top}px"
 | 
				
			||||||
  use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
 | 
					  transition:slide={{ duration: 250, easing: quintOut }}
 | 
				
			||||||
 | 
					  use:clickOutside={{ onOutclick: onClose }}
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					  <ul
 | 
				
			||||||
 | 
					    {id}
 | 
				
			||||||
 | 
					    aria-activedescendant={ariaActiveDescendant ?? ''}
 | 
				
			||||||
 | 
					    aria-label={ariaLabel}
 | 
				
			||||||
 | 
					    aria-labelledby={ariaLabelledBy}
 | 
				
			||||||
 | 
					    bind:this={menuElement}
 | 
				
			||||||
 | 
					    class:max-h-[100vh]={isVisible}
 | 
				
			||||||
 | 
					    class:max-h-0={!isVisible}
 | 
				
			||||||
 | 
					    class="flex flex-col transition-all duration-[250ms] ease-in-out"
 | 
				
			||||||
 | 
					    role="menu"
 | 
				
			||||||
 | 
					    tabindex="-1"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
  <div class="flex flex-col rounded-lg">
 | 
					 | 
				
			||||||
    <slot />
 | 
					    <slot />
 | 
				
			||||||
  </div>
 | 
					  </ul>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,37 @@
 | 
				
			|||||||
<script>
 | 
					<script lang="ts">
 | 
				
			||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
 | 
					  import { generateId } from '$lib/utils/generate-id';
 | 
				
			||||||
 | 
					  import { createEventDispatcher } from 'svelte';
 | 
				
			||||||
 | 
					  import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let text = '';
 | 
					  export let text = '';
 | 
				
			||||||
  export let subtitle = '';
 | 
					  export let subtitle = '';
 | 
				
			||||||
  export let icon = '';
 | 
					  export let icon = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let id: string = generateId();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  $: isActive = $selectedIdStore === id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const dispatch = createEventDispatcher<{
 | 
				
			||||||
 | 
					    click: void;
 | 
				
			||||||
 | 
					  }>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleClick = () => {
 | 
				
			||||||
 | 
					    $optionClickCallbackStore?.();
 | 
				
			||||||
 | 
					    dispatch('click');
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<button
 | 
					<!-- svelte-ignore a11y-click-events-have-key-events -->
 | 
				
			||||||
  type="button"
 | 
					<!-- svelte-ignore a11y-mouse-events-have-key-events -->
 | 
				
			||||||
  on:click
 | 
					<li
 | 
				
			||||||
  class="w-full bg-slate-100 p-4 text-left text-sm font-medium text-immich-fg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg"
 | 
					  {id}
 | 
				
			||||||
 | 
					  on:click={handleClick}
 | 
				
			||||||
 | 
					  on:mouseover={() => ($selectedIdStore = id)}
 | 
				
			||||||
 | 
					  on:mouseleave={() => ($selectedIdStore = undefined)}
 | 
				
			||||||
 | 
					  class="w-full p-4 text-left text-sm font-medium text-immich-fg focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg cursor-pointer border-gray-200"
 | 
				
			||||||
 | 
					  class:bg-slate-300={isActive}
 | 
				
			||||||
 | 
					  class:bg-slate-100={!isActive}
 | 
				
			||||||
  role="menuitem"
 | 
					  role="menuitem"
 | 
				
			||||||
>
 | 
					>
 | 
				
			||||||
  {#if text}
 | 
					  {#if text}
 | 
				
			||||||
@ -30,4 +52,4 @@
 | 
				
			|||||||
      {subtitle}
 | 
					      {subtitle}
 | 
				
			||||||
    </p>
 | 
					    </p>
 | 
				
			||||||
  </slot>
 | 
					  </slot>
 | 
				
			||||||
</button>
 | 
					</li>
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +0,0 @@
 | 
				
			|||||||
const key = {};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export { key };
 | 
					 | 
				
			||||||
@ -1,7 +1,12 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import { tick } from 'svelte';
 | 
					  import { tick } from 'svelte';
 | 
				
			||||||
  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
					  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
				
			||||||
 | 
					  import { shortcuts } from '$lib/actions/shortcut';
 | 
				
			||||||
 | 
					  import { generateId } from '$lib/utils/generate-id';
 | 
				
			||||||
 | 
					  import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
 | 
				
			||||||
 | 
					  import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export let title: string;
 | 
				
			||||||
  export let direction: 'left' | 'right' = 'right';
 | 
					  export let direction: 'left' | 'right' = 'right';
 | 
				
			||||||
  export let x = 0;
 | 
					  export let x = 0;
 | 
				
			||||||
  export let y = 0;
 | 
					  export let y = 0;
 | 
				
			||||||
@ -9,7 +14,19 @@
 | 
				
			|||||||
  export let onClose: (() => unknown) | undefined;
 | 
					  export let onClose: (() => unknown) | undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let uniqueKey = {};
 | 
					  let uniqueKey = {};
 | 
				
			||||||
  let contextMenuElement: HTMLDivElement;
 | 
					  let menuContainer: HTMLUListElement;
 | 
				
			||||||
 | 
					  let triggerElement: HTMLElement | undefined = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const id = generateId();
 | 
				
			||||||
 | 
					  const menuId = `context-menu-${id}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  $: {
 | 
				
			||||||
 | 
					    if (isOpen && menuContainer) {
 | 
				
			||||||
 | 
					      triggerElement = document.activeElement as HTMLElement;
 | 
				
			||||||
 | 
					      menuContainer.focus();
 | 
				
			||||||
 | 
					      $optionClickCallbackStore = closeContextMenu;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const reopenContextMenu = async (event: MouseEvent) => {
 | 
					  const reopenContextMenu = async (event: MouseEvent) => {
 | 
				
			||||||
    const contextMenuEvent = new MouseEvent('contextmenu', {
 | 
					    const contextMenuEvent = new MouseEvent('contextmenu', {
 | 
				
			||||||
@ -22,7 +39,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    const elements = document.elementsFromPoint(event.x, event.y);
 | 
					    const elements = document.elementsFromPoint(event.x, event.y);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (elements.includes(contextMenuElement)) {
 | 
					    if (elements.includes(menuContainer)) {
 | 
				
			||||||
      // User right-clicked on the context menu itself, we keep the context
 | 
					      // User right-clicked on the context menu itself, we keep the context
 | 
				
			||||||
      // menu as is
 | 
					      // menu as is
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
@ -38,20 +55,51 @@
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const closeContextMenu = () => {
 | 
					  const closeContextMenu = () => {
 | 
				
			||||||
 | 
					    triggerElement?.focus();
 | 
				
			||||||
    onClose?.();
 | 
					    onClose?.();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{#key uniqueKey}
 | 
					{#key uniqueKey}
 | 
				
			||||||
  {#if isOpen}
 | 
					  {#if isOpen}
 | 
				
			||||||
 | 
					    <div
 | 
				
			||||||
 | 
					      use:contextMenuNavigation={{
 | 
				
			||||||
 | 
					        closeDropdown: closeContextMenu,
 | 
				
			||||||
 | 
					        container: menuContainer,
 | 
				
			||||||
 | 
					        isOpen,
 | 
				
			||||||
 | 
					        selectedId: $selectedIdStore,
 | 
				
			||||||
 | 
					        selectionChanged: (id) => ($selectedIdStore = id),
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					      use:shortcuts={[
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          shortcut: { key: 'Tab' },
 | 
				
			||||||
 | 
					          onShortcut: closeContextMenu,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          shortcut: { key: 'Tab', shift: true },
 | 
				
			||||||
 | 
					          onShortcut: closeContextMenu,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ]}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <section
 | 
					      <section
 | 
				
			||||||
        class="fixed left-0 top-0 z-10 flex h-screen w-screen"
 | 
					        class="fixed left-0 top-0 z-10 flex h-screen w-screen"
 | 
				
			||||||
        on:contextmenu|preventDefault={reopenContextMenu}
 | 
					        on:contextmenu|preventDefault={reopenContextMenu}
 | 
				
			||||||
        role="presentation"
 | 
					        role="presentation"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
      <ContextMenu {x} {y} {direction} onClose={closeContextMenu} bind:menuElement={contextMenuElement}>
 | 
					        <ContextMenu
 | 
				
			||||||
 | 
					          {direction}
 | 
				
			||||||
 | 
					          {x}
 | 
				
			||||||
 | 
					          {y}
 | 
				
			||||||
 | 
					          ariaActiveDescendant={$selectedIdStore}
 | 
				
			||||||
 | 
					          ariaLabel={title}
 | 
				
			||||||
 | 
					          bind:menuElement={menuContainer}
 | 
				
			||||||
 | 
					          id={menuId}
 | 
				
			||||||
 | 
					          isVisible
 | 
				
			||||||
 | 
					          onClose={closeContextMenu}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
          <slot />
 | 
					          <slot />
 | 
				
			||||||
        </ContextMenu>
 | 
					        </ContextMenu>
 | 
				
			||||||
      </section>
 | 
					      </section>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
  {/if}
 | 
					  {/if}
 | 
				
			||||||
{/key}
 | 
					{/key}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										6
									
								
								web/src/lib/stores/context-menu.store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								web/src/lib/stores/context-menu.store.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					import { writable } from 'svelte/store';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const selectedIdStore = writable<string | undefined>(undefined);
 | 
				
			||||||
 | 
					const optionClickCallbackStore = writable<(() => void) | undefined>(undefined);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export { optionClickCallbackStore, selectedIdStore };
 | 
				
			||||||
@ -2,22 +2,31 @@ export type Align = 'middle' | 'top-left' | 'top-right';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export type ContextMenuPosition = { x: number; y: number };
 | 
					export type ContextMenuPosition = { x: number; y: number };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const getContextMenuPosition = (event: MouseEvent, align: Align = 'middle'): ContextMenuPosition => {
 | 
					export const getContextMenuPositionFromEvent = (
 | 
				
			||||||
  const { x, y, currentTarget, target } = event;
 | 
					  event: MouseEvent | KeyboardEvent,
 | 
				
			||||||
 | 
					  align: Align = 'middle',
 | 
				
			||||||
 | 
					): ContextMenuPosition => {
 | 
				
			||||||
 | 
					  const { currentTarget, target } = event;
 | 
				
			||||||
 | 
					  const x = 'x' in event ? event.x : 0;
 | 
				
			||||||
 | 
					  const y = 'y' in event ? event.y : 0;
 | 
				
			||||||
  const box = ((currentTarget || target) as HTMLElement)?.getBoundingClientRect();
 | 
					  const box = ((currentTarget || target) as HTMLElement)?.getBoundingClientRect();
 | 
				
			||||||
  if (box) {
 | 
					  if (box) {
 | 
				
			||||||
    switch (align) {
 | 
					    return getContextMenuPositionFromBoundingRect(box, align);
 | 
				
			||||||
      case 'middle': {
 | 
					 | 
				
			||||||
        return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      case 'top-left': {
 | 
					 | 
				
			||||||
        return { x: box.x, y: box.y };
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      case 'top-right': {
 | 
					 | 
				
			||||||
        return { x: box.x + box.width, y: box.y };
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return { x, y };
 | 
					  return { x, y };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Align = 'middle'): ContextMenuPosition => {
 | 
				
			||||||
 | 
					  switch (align) {
 | 
				
			||||||
 | 
					    case 'middle': {
 | 
				
			||||||
 | 
					      return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    case 'top-left': {
 | 
				
			||||||
 | 
					      return { x: rect.x, y: rect.y };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    case 'top-right': {
 | 
				
			||||||
 | 
					      return { x: rect.x + rect.width, y: rect.y };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -22,9 +22,8 @@
 | 
				
			|||||||
  import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
 | 
					  import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
 | 
				
			||||||
  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
					  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
				
			||||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
					  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
					 | 
				
			||||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
					  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
					  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
				
			||||||
  import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
 | 
					  import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
 | 
				
			||||||
@ -43,8 +42,6 @@
 | 
				
			|||||||
  import { user } from '$lib/stores/user.store';
 | 
					  import { user } from '$lib/stores/user.store';
 | 
				
			||||||
  import { handlePromiseError, s } from '$lib/utils';
 | 
					  import { handlePromiseError, s } from '$lib/utils';
 | 
				
			||||||
  import { downloadAlbum } from '$lib/utils/asset-utils';
 | 
					  import { downloadAlbum } from '$lib/utils/asset-utils';
 | 
				
			||||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
					 | 
				
			||||||
  import { getContextMenuPosition } from '$lib/utils/context-menu';
 | 
					 | 
				
			||||||
  import { openFileUploadDialog } from '$lib/utils/file-uploader';
 | 
					  import { openFileUploadDialog } from '$lib/utils/file-uploader';
 | 
				
			||||||
  import { handleError } from '$lib/utils/handle-error';
 | 
					  import { handleError } from '$lib/utils/handle-error';
 | 
				
			||||||
  import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
 | 
					  import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
 | 
				
			||||||
@ -103,7 +100,6 @@
 | 
				
			|||||||
    SELECT_USERS = 'select-users',
 | 
					    SELECT_USERS = 'select-users',
 | 
				
			||||||
    SELECT_THUMBNAIL = 'select-thumbnail',
 | 
					    SELECT_THUMBNAIL = 'select-thumbnail',
 | 
				
			||||||
    SELECT_ASSETS = 'select-assets',
 | 
					    SELECT_ASSETS = 'select-assets',
 | 
				
			||||||
    ALBUM_OPTIONS = 'album-options',
 | 
					 | 
				
			||||||
    VIEW_USERS = 'view-users',
 | 
					    VIEW_USERS = 'view-users',
 | 
				
			||||||
    VIEW = 'view',
 | 
					    VIEW = 'view',
 | 
				
			||||||
    OPTIONS = 'options',
 | 
					    OPTIONS = 'options',
 | 
				
			||||||
@ -112,7 +108,6 @@
 | 
				
			|||||||
  let backUrl: string = AppRoute.ALBUMS;
 | 
					  let backUrl: string = AppRoute.ALBUMS;
 | 
				
			||||||
  let viewMode = ViewMode.VIEW;
 | 
					  let viewMode = ViewMode.VIEW;
 | 
				
			||||||
  let isCreatingSharedAlbum = false;
 | 
					  let isCreatingSharedAlbum = false;
 | 
				
			||||||
  let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
 | 
					 | 
				
			||||||
  let isShowActivity = false;
 | 
					  let isShowActivity = false;
 | 
				
			||||||
  let isLiked: ActivityResponseDto | null = null;
 | 
					  let isLiked: ActivityResponseDto | null = null;
 | 
				
			||||||
  let reactions: ActivityResponseDto[] = [];
 | 
					  let reactions: ActivityResponseDto[] = [];
 | 
				
			||||||
@ -305,11 +300,6 @@
 | 
				
			|||||||
    timelineInteractionStore.clearMultiselect();
 | 
					    timelineInteractionStore.clearMultiselect();
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleOpenAlbumOptions = (event: MouseEvent) => {
 | 
					 | 
				
			||||||
    contextMenuPosition = getContextMenuPosition(event, 'top-left');
 | 
					 | 
				
			||||||
    viewMode = viewMode === ViewMode.VIEW ? ViewMode.ALBUM_OPTIONS : ViewMode.VIEW;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleSelectFromComputer = async () => {
 | 
					  const handleSelectFromComputer = async () => {
 | 
				
			||||||
    await openFileUploadDialog({ albumId: album.id });
 | 
					    await openFileUploadDialog({ albumId: album.id });
 | 
				
			||||||
    timelineInteractionStore.clearMultiselect();
 | 
					    timelineInteractionStore.clearMultiselect();
 | 
				
			||||||
@ -420,14 +410,14 @@
 | 
				
			|||||||
      <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
 | 
					      <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
 | 
				
			||||||
        <CreateSharedLink />
 | 
					        <CreateSharedLink />
 | 
				
			||||||
        <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
					        <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
				
			||||||
        <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					        <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
          <AddToAlbum />
 | 
					          <AddToAlbum />
 | 
				
			||||||
          <AddToAlbum shared />
 | 
					          <AddToAlbum shared />
 | 
				
			||||||
        </AssetSelectContextMenu>
 | 
					        </ButtonContextMenu>
 | 
				
			||||||
        {#if isAllUserOwned}
 | 
					        {#if isAllUserOwned}
 | 
				
			||||||
          <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
					          <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
				
			||||||
        {/if}
 | 
					        {/if}
 | 
				
			||||||
        <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					        <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
          <DownloadAction menuItem filename="{album.albumName}.zip" />
 | 
					          <DownloadAction menuItem filename="{album.albumName}.zip" />
 | 
				
			||||||
          {#if isAllUserOwned}
 | 
					          {#if isAllUserOwned}
 | 
				
			||||||
            <ChangeDate menuItem />
 | 
					            <ChangeDate menuItem />
 | 
				
			||||||
@ -447,10 +437,10 @@
 | 
				
			|||||||
          {#if isAllUserOwned}
 | 
					          {#if isAllUserOwned}
 | 
				
			||||||
            <DeleteAssets menuItem onAssetDelete={handleRemoveAssets} />
 | 
					            <DeleteAssets menuItem onAssetDelete={handleRemoveAssets} />
 | 
				
			||||||
          {/if}
 | 
					          {/if}
 | 
				
			||||||
        </AssetSelectContextMenu>
 | 
					        </ButtonContextMenu>
 | 
				
			||||||
      </AssetSelectControlBar>
 | 
					      </AssetSelectControlBar>
 | 
				
			||||||
    {:else}
 | 
					    {:else}
 | 
				
			||||||
      {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
 | 
					      {#if viewMode === ViewMode.VIEW}
 | 
				
			||||||
        <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}>
 | 
					        <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}>
 | 
				
			||||||
          <svelte:fragment slot="trailing">
 | 
					          <svelte:fragment slot="trailing">
 | 
				
			||||||
            {#if isEditor}
 | 
					            {#if isEditor}
 | 
				
			||||||
@ -474,14 +464,7 @@
 | 
				
			|||||||
              <CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
 | 
					              <CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              {#if isOwned}
 | 
					              {#if isOwned}
 | 
				
			||||||
                <div use:clickOutside={{ onOutclick: () => (viewMode = ViewMode.VIEW) }}>
 | 
					                <ButtonContextMenu icon={mdiDotsVertical} title={$t('album_options')}>
 | 
				
			||||||
                  <CircleIconButton
 | 
					 | 
				
			||||||
                    title={$t('album_options')}
 | 
					 | 
				
			||||||
                    on:click={handleOpenAlbumOptions}
 | 
					 | 
				
			||||||
                    icon={mdiDotsVertical}
 | 
					 | 
				
			||||||
                  />
 | 
					 | 
				
			||||||
                  {#if viewMode === ViewMode.ALBUM_OPTIONS}
 | 
					 | 
				
			||||||
                    <ContextMenu {...contextMenuPosition}>
 | 
					 | 
				
			||||||
                  <MenuOption
 | 
					                  <MenuOption
 | 
				
			||||||
                    icon={mdiImageOutline}
 | 
					                    icon={mdiImageOutline}
 | 
				
			||||||
                    text={$t('select_album_cover')}
 | 
					                    text={$t('select_album_cover')}
 | 
				
			||||||
@ -492,14 +475,8 @@
 | 
				
			|||||||
                    text={$t('options')}
 | 
					                    text={$t('options')}
 | 
				
			||||||
                    on:click={() => (viewMode = ViewMode.OPTIONS)}
 | 
					                    on:click={() => (viewMode = ViewMode.OPTIONS)}
 | 
				
			||||||
                  />
 | 
					                  />
 | 
				
			||||||
                      <MenuOption
 | 
					                  <MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} on:click={() => handleRemoveAlbum()} />
 | 
				
			||||||
                        icon={mdiDeleteOutline}
 | 
					                </ButtonContextMenu>
 | 
				
			||||||
                        text={$t('delete_album')}
 | 
					 | 
				
			||||||
                        on:click={() => handleRemoveAlbum()}
 | 
					 | 
				
			||||||
                      />
 | 
					 | 
				
			||||||
                    </ContextMenu>
 | 
					 | 
				
			||||||
                  {/if}
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              {/if}
 | 
					              {/if}
 | 
				
			||||||
            {/if}
 | 
					            {/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -8,7 +8,7 @@
 | 
				
			|||||||
  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
					  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
				
			||||||
  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
					  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
				
			||||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
					  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
 | 
					  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
 | 
				
			||||||
  import { AssetAction } from '$lib/constants';
 | 
					  import { AssetAction } from '$lib/constants';
 | 
				
			||||||
@ -32,15 +32,15 @@
 | 
				
			|||||||
    <ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					    <ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
    <CreateSharedLink />
 | 
					    <CreateSharedLink />
 | 
				
			||||||
    <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
					    <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
				
			||||||
    <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					    <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
      <AddToAlbum />
 | 
					      <AddToAlbum />
 | 
				
			||||||
      <AddToAlbum shared />
 | 
					      <AddToAlbum shared />
 | 
				
			||||||
    </AssetSelectContextMenu>
 | 
					    </ButtonContextMenu>
 | 
				
			||||||
    <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
					    <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
				
			||||||
    <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
					    <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
				
			||||||
      <DownloadAction menuItem />
 | 
					      <DownloadAction menuItem />
 | 
				
			||||||
      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
    </AssetSelectContextMenu>
 | 
					    </ButtonContextMenu>
 | 
				
			||||||
  </AssetSelectControlBar>
 | 
					  </AssetSelectControlBar>
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,7 @@
 | 
				
			|||||||
  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
					  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
				
			||||||
  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
					  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
				
			||||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
					  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
 | 
					  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
 | 
				
			||||||
  import { AssetAction } from '$lib/constants';
 | 
					  import { AssetAction } from '$lib/constants';
 | 
				
			||||||
@ -35,17 +35,17 @@
 | 
				
			|||||||
    <FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					    <FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
    <CreateSharedLink />
 | 
					    <CreateSharedLink />
 | 
				
			||||||
    <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
					    <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
				
			||||||
    <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					    <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
      <AddToAlbum />
 | 
					      <AddToAlbum />
 | 
				
			||||||
      <AddToAlbum shared />
 | 
					      <AddToAlbum shared />
 | 
				
			||||||
    </AssetSelectContextMenu>
 | 
					    </ButtonContextMenu>
 | 
				
			||||||
    <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					    <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
      <DownloadAction menuItem />
 | 
					      <DownloadAction menuItem />
 | 
				
			||||||
      <ChangeDate menuItem />
 | 
					      <ChangeDate menuItem />
 | 
				
			||||||
      <ChangeLocation menuItem />
 | 
					      <ChangeLocation menuItem />
 | 
				
			||||||
      <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					      <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
    </AssetSelectContextMenu>
 | 
					    </ButtonContextMenu>
 | 
				
			||||||
  </AssetSelectControlBar>
 | 
					  </AssetSelectControlBar>
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@
 | 
				
			|||||||
  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
					  import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
 | 
				
			||||||
  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
 | 
					  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
 | 
				
			||||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
					  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
					  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
				
			||||||
  import { AppRoute } from '$lib/constants';
 | 
					  import { AppRoute } from '$lib/constants';
 | 
				
			||||||
@ -30,10 +30,10 @@
 | 
				
			|||||||
  {#if $isMultiSelectState}
 | 
					  {#if $isMultiSelectState}
 | 
				
			||||||
    <AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
 | 
					    <AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
 | 
				
			||||||
      <CreateSharedLink />
 | 
					      <CreateSharedLink />
 | 
				
			||||||
      <AssetSelectContextMenu icon={mdiPlus} title={$t('add')}>
 | 
					      <ButtonContextMenu icon={mdiPlus} title={$t('add')}>
 | 
				
			||||||
        <AddToAlbum />
 | 
					        <AddToAlbum />
 | 
				
			||||||
        <AddToAlbum shared />
 | 
					        <AddToAlbum shared />
 | 
				
			||||||
      </AssetSelectContextMenu>
 | 
					      </ButtonContextMenu>
 | 
				
			||||||
      <DownloadAction />
 | 
					      <DownloadAction />
 | 
				
			||||||
    </AssetSelectControlBar>
 | 
					    </AssetSelectControlBar>
 | 
				
			||||||
  {:else}
 | 
					  {:else}
 | 
				
			||||||
 | 
				
			|||||||
@ -17,7 +17,6 @@
 | 
				
			|||||||
  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
					  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
				
			||||||
  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
					  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
				
			||||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
					  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
					  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
					  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
				
			||||||
@ -57,6 +56,7 @@
 | 
				
			|||||||
  import type { PageData } from './$types';
 | 
					  import type { PageData } from './$types';
 | 
				
			||||||
  import { listNavigation } from '$lib/actions/list-navigation';
 | 
					  import { listNavigation } from '$lib/actions/list-navigation';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let data: PageData;
 | 
					  export let data: PageData;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -380,12 +380,12 @@
 | 
				
			|||||||
    <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
 | 
					    <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
 | 
				
			||||||
      <CreateSharedLink />
 | 
					      <CreateSharedLink />
 | 
				
			||||||
      <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
					      <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
				
			||||||
      <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					      <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
        <AddToAlbum />
 | 
					        <AddToAlbum />
 | 
				
			||||||
        <AddToAlbum shared />
 | 
					        <AddToAlbum shared />
 | 
				
			||||||
      </AssetSelectContextMenu>
 | 
					      </ButtonContextMenu>
 | 
				
			||||||
      <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
					      <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
				
			||||||
      <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
					      <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
				
			||||||
        <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
 | 
					        <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
 | 
				
			||||||
        <MenuOption
 | 
					        <MenuOption
 | 
				
			||||||
          icon={mdiAccountMultipleCheckOutline}
 | 
					          icon={mdiAccountMultipleCheckOutline}
 | 
				
			||||||
@ -396,13 +396,13 @@
 | 
				
			|||||||
        <ChangeLocation menuItem />
 | 
					        <ChangeLocation menuItem />
 | 
				
			||||||
        <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
 | 
					        <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
        <DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
 | 
					        <DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
      </AssetSelectContextMenu>
 | 
					      </ButtonContextMenu>
 | 
				
			||||||
    </AssetSelectControlBar>
 | 
					    </AssetSelectControlBar>
 | 
				
			||||||
  {:else}
 | 
					  {:else}
 | 
				
			||||||
    {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
 | 
					    {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
 | 
				
			||||||
      <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}>
 | 
					      <ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}>
 | 
				
			||||||
        <svelte:fragment slot="trailing">
 | 
					        <svelte:fragment slot="trailing">
 | 
				
			||||||
          <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					          <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
            <MenuOption
 | 
					            <MenuOption
 | 
				
			||||||
              text={$t('select_featured_photo')}
 | 
					              text={$t('select_featured_photo')}
 | 
				
			||||||
              icon={mdiAccountBoxOutline}
 | 
					              icon={mdiAccountBoxOutline}
 | 
				
			||||||
@ -423,7 +423,7 @@
 | 
				
			|||||||
              icon={mdiAccountMultipleCheckOutline}
 | 
					              icon={mdiAccountMultipleCheckOutline}
 | 
				
			||||||
              on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)}
 | 
					              on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)}
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </AssetSelectContextMenu>
 | 
					          </ButtonContextMenu>
 | 
				
			||||||
        </svelte:fragment>
 | 
					        </svelte:fragment>
 | 
				
			||||||
      </ControlAppBar>
 | 
					      </ControlAppBar>
 | 
				
			||||||
    {/if}
 | 
					    {/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -12,7 +12,7 @@
 | 
				
			|||||||
  import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
 | 
					  import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
 | 
				
			||||||
  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
					  import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
 | 
				
			||||||
  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
					  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
 | 
					  import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
 | 
				
			||||||
  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
 | 
					  import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
 | 
				
			||||||
@ -58,12 +58,12 @@
 | 
				
			|||||||
  >
 | 
					  >
 | 
				
			||||||
    <CreateSharedLink />
 | 
					    <CreateSharedLink />
 | 
				
			||||||
    <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
					    <SelectAllAssets {assetStore} {assetInteractionStore} />
 | 
				
			||||||
    <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					    <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
      <AddToAlbum />
 | 
					      <AddToAlbum />
 | 
				
			||||||
      <AddToAlbum shared />
 | 
					      <AddToAlbum shared />
 | 
				
			||||||
    </AssetSelectContextMenu>
 | 
					    </ButtonContextMenu>
 | 
				
			||||||
    <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
					    <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
 | 
				
			||||||
    <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
					    <ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
 | 
				
			||||||
      <DownloadAction menuItem />
 | 
					      <DownloadAction menuItem />
 | 
				
			||||||
      {#if $selectedAssets.size > 1 || isAssetStackSelected}
 | 
					      {#if $selectedAssets.size > 1 || isAssetStackSelected}
 | 
				
			||||||
        <StackAction
 | 
					        <StackAction
 | 
				
			||||||
@ -78,7 +78,7 @@
 | 
				
			|||||||
      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
      <hr />
 | 
					      <hr />
 | 
				
			||||||
      <AssetJobActions />
 | 
					      <AssetJobActions />
 | 
				
			||||||
    </AssetSelectContextMenu>
 | 
					    </ButtonContextMenu>
 | 
				
			||||||
  </AssetSelectControlBar>
 | 
					  </AssetSelectControlBar>
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -11,7 +11,7 @@
 | 
				
			|||||||
  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
					  import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
 | 
				
			||||||
  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
 | 
					  import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
 | 
				
			||||||
  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
					  import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
 | 
				
			||||||
  import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
					  import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
 | 
				
			||||||
  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
					  import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 | 
				
			||||||
  import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
 | 
					  import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
 | 
				
			||||||
@ -211,19 +211,19 @@
 | 
				
			|||||||
      <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
 | 
					      <AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
 | 
				
			||||||
        <CreateSharedLink />
 | 
					        <CreateSharedLink />
 | 
				
			||||||
        <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
 | 
					        <CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
 | 
				
			||||||
        <AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
					        <ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
 | 
				
			||||||
          <AddToAlbum />
 | 
					          <AddToAlbum />
 | 
				
			||||||
          <AddToAlbum shared />
 | 
					          <AddToAlbum shared />
 | 
				
			||||||
        </AssetSelectContextMenu>
 | 
					        </ButtonContextMenu>
 | 
				
			||||||
        <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
 | 
					        <FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
					        <ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
 | 
				
			||||||
          <DownloadAction menuItem />
 | 
					          <DownloadAction menuItem />
 | 
				
			||||||
          <ChangeDate menuItem />
 | 
					          <ChangeDate menuItem />
 | 
				
			||||||
          <ChangeLocation menuItem />
 | 
					          <ChangeLocation menuItem />
 | 
				
			||||||
          <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
 | 
					          <ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
 | 
				
			||||||
          <DeleteAssets menuItem {onAssetDelete} />
 | 
					          <DeleteAssets menuItem {onAssetDelete} />
 | 
				
			||||||
        </AssetSelectContextMenu>
 | 
					        </ButtonContextMenu>
 | 
				
			||||||
      </AssetSelectControlBar>
 | 
					      </AssetSelectControlBar>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  {:else}
 | 
					  {:else}
 | 
				
			||||||
 | 
				
			|||||||
@ -6,16 +6,13 @@
 | 
				
			|||||||
  import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
 | 
					  import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
 | 
				
			||||||
  import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.svelte';
 | 
					  import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.svelte';
 | 
				
			||||||
  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
					  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
				
			||||||
  import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
 | 
					 | 
				
			||||||
  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
					  import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
					  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
    notificationController,
 | 
					    notificationController,
 | 
				
			||||||
    NotificationType,
 | 
					    NotificationType,
 | 
				
			||||||
  } from '$lib/components/shared-components/notification/notification';
 | 
					  } from '$lib/components/shared-components/notification/notification';
 | 
				
			||||||
  import Portal from '$lib/components/shared-components/portal/portal.svelte';
 | 
					 | 
				
			||||||
  import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units';
 | 
					  import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units';
 | 
				
			||||||
  import { getContextMenuPosition } from '$lib/utils/context-menu';
 | 
					 | 
				
			||||||
  import { handleError } from '$lib/utils/handle-error';
 | 
					  import { handleError } from '$lib/utils/handle-error';
 | 
				
			||||||
  import {
 | 
					  import {
 | 
				
			||||||
    createLibrary,
 | 
					    createLibrary,
 | 
				
			||||||
@ -35,9 +32,9 @@
 | 
				
			|||||||
  import { fade, slide } from 'svelte/transition';
 | 
					  import { fade, slide } from 'svelte/transition';
 | 
				
			||||||
  import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte';
 | 
					  import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte';
 | 
				
			||||||
  import type { PageData } from './$types';
 | 
					  import type { PageData } from './$types';
 | 
				
			||||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
					 | 
				
			||||||
  import { dialogController } from '$lib/components/shared-components/dialog/dialog';
 | 
					  import { dialogController } from '$lib/components/shared-components/dialog/dialog';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let data: PageData;
 | 
					  export let data: PageData;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -63,10 +60,6 @@
 | 
				
			|||||||
  let deleteAssetCount = 0;
 | 
					  let deleteAssetCount = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let dropdownOpen: boolean[] = [];
 | 
					  let dropdownOpen: boolean[] = [];
 | 
				
			||||||
  let showContextMenu = false;
 | 
					 | 
				
			||||||
  let contextMenuPosition = { x: 0, y: 0 };
 | 
					 | 
				
			||||||
  let selectedLibraryIndex = 0;
 | 
					 | 
				
			||||||
  let selectedLibrary: LibraryResponseDto | null = null;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let toCreateLibrary = false;
 | 
					  let toCreateLibrary = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -79,25 +72,12 @@
 | 
				
			|||||||
    editScanSettings = null;
 | 
					    editScanSettings = null;
 | 
				
			||||||
    renameLibrary = null;
 | 
					    renameLibrary = null;
 | 
				
			||||||
    updateLibraryIndex = null;
 | 
					    updateLibraryIndex = null;
 | 
				
			||||||
    showContextMenu = false;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (let index = 0; index < dropdownOpen.length; index++) {
 | 
					    for (let index = 0; index < dropdownOpen.length; index++) {
 | 
				
			||||||
      dropdownOpen[index] = false;
 | 
					      dropdownOpen[index] = false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const showMenu = (event: MouseEvent, library: LibraryResponseDto, index: number) => {
 | 
					 | 
				
			||||||
    contextMenuPosition = getContextMenuPosition(event);
 | 
					 | 
				
			||||||
    showContextMenu = !showContextMenu;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    selectedLibraryIndex = index;
 | 
					 | 
				
			||||||
    selectedLibrary = library;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const onMenuExit = () => {
 | 
					 | 
				
			||||||
    showContextMenu = false;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const refreshStats = async (listIndex: number) => {
 | 
					  const refreshStats = async (listIndex: number) => {
 | 
				
			||||||
    stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
 | 
					    stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
 | 
				
			||||||
    owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
 | 
					    owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
 | 
				
			||||||
@ -233,72 +213,72 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onRenameClicked = () => {
 | 
					  const onRenameClicked = (index: number) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
    renameLibrary = selectedLibraryIndex;
 | 
					    renameLibrary = index;
 | 
				
			||||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
					    updateLibraryIndex = index;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onEditImportPathClicked = () => {
 | 
					  const onEditImportPathClicked = (index: number) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
    editImportPaths = selectedLibraryIndex;
 | 
					    editImportPaths = index;
 | 
				
			||||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
					    updateLibraryIndex = index;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onScanNewLibraryClicked = async () => {
 | 
					  const onScanNewLibraryClicked = async (library: LibraryResponseDto) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (selectedLibrary) {
 | 
					    if (library) {
 | 
				
			||||||
      await handleScan(selectedLibrary.id);
 | 
					      await handleScan(library.id);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onScanSettingClicked = () => {
 | 
					  const onScanSettingClicked = (index: number) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
    editScanSettings = selectedLibraryIndex;
 | 
					    editScanSettings = index;
 | 
				
			||||||
    updateLibraryIndex = selectedLibraryIndex;
 | 
					    updateLibraryIndex = index;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onScanAllLibraryFilesClicked = async () => {
 | 
					  const onScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
    if (selectedLibrary) {
 | 
					    if (library) {
 | 
				
			||||||
      await handleScanChanges(selectedLibrary.id);
 | 
					      await handleScanChanges(library.id);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onForceScanAllLibraryFilesClicked = async () => {
 | 
					  const onForceScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
    if (selectedLibrary) {
 | 
					    if (library) {
 | 
				
			||||||
      await handleForceScan(selectedLibrary.id);
 | 
					      await handleForceScan(library.id);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onRemoveOfflineFilesClicked = async () => {
 | 
					  const onRemoveOfflineFilesClicked = async (library: LibraryResponseDto) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
    if (selectedLibrary) {
 | 
					    if (library) {
 | 
				
			||||||
      await handleRemoveOffline(selectedLibrary.id);
 | 
					      await handleRemoveOffline(library.id);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const onDeleteLibraryClicked = async () => {
 | 
					  const onDeleteLibraryClicked = async (library: LibraryResponseDto, index: number) => {
 | 
				
			||||||
    closeAll();
 | 
					    closeAll();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!selectedLibrary) {
 | 
					    if (!library) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const isConfirmedLibrary = await dialogController.show({
 | 
					    const isConfirmedLibrary = await dialogController.show({
 | 
				
			||||||
      id: 'delete-library',
 | 
					      id: 'delete-library',
 | 
				
			||||||
      prompt: $t('admin.confirm_delete_library', { values: { library: selectedLibrary.name } }),
 | 
					      prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!isConfirmedLibrary) {
 | 
					    if (!isConfirmedLibrary) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await refreshStats(selectedLibraryIndex);
 | 
					    await refreshStats(index);
 | 
				
			||||||
    if (totalCount[selectedLibraryIndex] > 0) {
 | 
					    if (totalCount[index] > 0) {
 | 
				
			||||||
      deleteAssetCount = totalCount[selectedLibraryIndex];
 | 
					      deleteAssetCount = totalCount[index];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const isConfirmedLibraryAssetCount = await dialogController.show({
 | 
					      const isConfirmedLibraryAssetCount = await dialogController.show({
 | 
				
			||||||
        id: 'delete-library-assets',
 | 
					        id: 'delete-library-assets',
 | 
				
			||||||
@ -310,7 +290,7 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
      await handleDelete();
 | 
					      await handleDelete();
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      deletedLibrary = selectedLibrary;
 | 
					      deletedLibrary = library;
 | 
				
			||||||
      await handleDelete();
 | 
					      await handleDelete();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
@ -392,46 +372,38 @@
 | 
				
			|||||||
                {/if}
 | 
					                {/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <td class=" text-ellipsis px-4 text-sm">
 | 
					                <td class=" text-ellipsis px-4 text-sm">
 | 
				
			||||||
                  <CircleIconButton
 | 
					                  <ButtonContextMenu
 | 
				
			||||||
 | 
					                    align="top-right"
 | 
				
			||||||
 | 
					                    direction="left"
 | 
				
			||||||
                    color="primary"
 | 
					                    color="primary"
 | 
				
			||||||
 | 
					                    size="16"
 | 
				
			||||||
                    icon={mdiDotsVertical}
 | 
					                    icon={mdiDotsVertical}
 | 
				
			||||||
                    title={$t('library_options')}
 | 
					                    title={$t('library_options')}
 | 
				
			||||||
                    size="16"
 | 
					                  >
 | 
				
			||||||
                    on:click={(e) => showMenu(e, library, index)}
 | 
					                    <MenuOption on:click={() => onRenameClicked(index)} text={$t('rename')} />
 | 
				
			||||||
                  />
 | 
					                    <MenuOption on:click={() => onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
 | 
				
			||||||
 | 
					                    <MenuOption on:click={() => onScanSettingClicked(index)} text={$t('scan_settings')} />
 | 
				
			||||||
                  {#if showContextMenu}
 | 
					 | 
				
			||||||
                    <Portal target="body">
 | 
					 | 
				
			||||||
                      <ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
 | 
					 | 
				
			||||||
                        <MenuOption on:click={() => onRenameClicked()} text={$t('rename')} />
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        {#if selectedLibrary}
 | 
					 | 
				
			||||||
                          <MenuOption on:click={() => onEditImportPathClicked()} text={$t('edit_import_paths')} />
 | 
					 | 
				
			||||||
                          <MenuOption on:click={() => onScanSettingClicked()} text={$t('scan_settings')} />
 | 
					 | 
				
			||||||
                    <hr />
 | 
					                    <hr />
 | 
				
			||||||
                          <MenuOption on:click={() => onScanNewLibraryClicked()} text={$t('scan_new_library_files')} />
 | 
					                    <MenuOption on:click={() => onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} />
 | 
				
			||||||
                    <MenuOption
 | 
					                    <MenuOption
 | 
				
			||||||
                            on:click={() => onScanAllLibraryFilesClicked()}
 | 
					                      on:click={() => onScanAllLibraryFilesClicked(library)}
 | 
				
			||||||
                      text={$t('scan_all_library_files')}
 | 
					                      text={$t('scan_all_library_files')}
 | 
				
			||||||
                      subtitle={$t('only_refreshes_modified_files')}
 | 
					                      subtitle={$t('only_refreshes_modified_files')}
 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
                    <MenuOption
 | 
					                    <MenuOption
 | 
				
			||||||
                            on:click={() => onForceScanAllLibraryFilesClicked()}
 | 
					                      on:click={() => onForceScanAllLibraryFilesClicked(library)}
 | 
				
			||||||
                      text={$t('force_re-scan_library_files')}
 | 
					                      text={$t('force_re-scan_library_files')}
 | 
				
			||||||
                      subtitle={$t('refreshes_every_file')}
 | 
					                      subtitle={$t('refreshes_every_file')}
 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
                    <hr />
 | 
					                    <hr />
 | 
				
			||||||
                    <MenuOption
 | 
					                    <MenuOption
 | 
				
			||||||
                            on:click={() => onRemoveOfflineFilesClicked()}
 | 
					                      on:click={() => onRemoveOfflineFilesClicked(library)}
 | 
				
			||||||
                      text={$t('remove_offline_files')}
 | 
					                      text={$t('remove_offline_files')}
 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
                          <MenuOption on:click={() => onDeleteLibraryClicked()}>
 | 
					                    <MenuOption on:click={() => onDeleteLibraryClicked(library, index)}>
 | 
				
			||||||
                      <p class="text-red-600">{$t('delete_library')}</p>
 | 
					                      <p class="text-red-600">{$t('delete_library')}</p>
 | 
				
			||||||
                    </MenuOption>
 | 
					                    </MenuOption>
 | 
				
			||||||
                        {/if}
 | 
					                  </ButtonContextMenu>
 | 
				
			||||||
                      </ContextMenu>
 | 
					 | 
				
			||||||
                    </Portal>
 | 
					 | 
				
			||||||
                  {/if}
 | 
					 | 
				
			||||||
                </td>
 | 
					                </td>
 | 
				
			||||||
              </tr>
 | 
					              </tr>
 | 
				
			||||||
              {#if renameLibrary === index}
 | 
					              {#if renameLibrary === index}
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user