mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00: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
26 changed files with 639 additions and 441 deletions
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 {
|
||||
onFocusOut?: () => void;
|
||||
onFocusOut?: (event: FocusEvent) => void;
|
||||
}
|
||||
|
||||
export function focusOutside(node: HTMLElement, options: Options = {}) {
|
||||
|
@ -7,7 +7,7 @@ export function focusOutside(node: HTMLElement, options: Options = {}) {
|
|||
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
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 type { AlbumResponseDto } from '@immich/sdk';
|
||||
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 AlbumCover from '$lib/components/album-page/album-cover.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
|
@ -20,7 +20,7 @@
|
|||
const showAlbumContextMenu = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onShowContextMenu?.(getContextMenuPosition(e));
|
||||
onShowContextMenu?.(getContextMenuPositionFromEvent(e));
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import { groupBy, orderBy } from 'lodash-es';
|
||||
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
|
||||
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 CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
||||
import {
|
||||
|
@ -167,6 +166,7 @@
|
|||
|
||||
let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
|
||||
let contextMenuTargetAlbum: AlbumResponseDto | null = null;
|
||||
let isOpen = false;
|
||||
|
||||
// Step 1: Filter between Owned and Shared albums, or both.
|
||||
$: {
|
||||
|
@ -224,7 +224,6 @@
|
|||
albumGroupIds = groupedAlbums.map(({ id }) => id);
|
||||
}
|
||||
|
||||
$: showContextMenu = !!contextMenuTargetAlbum;
|
||||
$: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
|
||||
|
||||
onMount(async () => {
|
||||
|
@ -253,10 +252,11 @@
|
|||
x: contextMenuDetail.x,
|
||||
y: contextMenuDetail.y,
|
||||
};
|
||||
isOpen = true;
|
||||
};
|
||||
|
||||
const closeAlbumContextMenu = () => {
|
||||
contextMenuTargetAlbum = null;
|
||||
isOpen = false;
|
||||
};
|
||||
|
||||
const handleDownloadAlbum = async () => {
|
||||
|
@ -419,34 +419,18 @@
|
|||
{/if}
|
||||
|
||||
<!-- Context Menu -->
|
||||
<RightClickContextMenu {...contextMenuPosition} isOpen={showContextMenu} onClose={closeAlbumContextMenu}>
|
||||
<RightClickContextMenu title={$t('album_options')} {...contextMenuPosition} {isOpen} onClose={closeAlbumContextMenu}>
|
||||
{#if showFullContextMenu}
|
||||
<MenuOption on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiRenameOutline} size="18" />
|
||||
Edit
|
||||
</p>
|
||||
</MenuOption>
|
||||
<MenuOption on:click={() => openShareModal()}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiShareVariantOutline} size="18" />
|
||||
Share
|
||||
</p>
|
||||
</MenuOption>
|
||||
<MenuOption
|
||||
icon={mdiRenameOutline}
|
||||
text={$t('edit_album')}
|
||||
on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}
|
||||
/>
|
||||
<MenuOption icon={mdiShareVariantOutline} text={$t('share')} on:click={() => openShareModal()} />
|
||||
{/if}
|
||||
<MenuOption on:click={() => handleDownloadAlbum()}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiFolderDownloadOutline} size="18" />
|
||||
Download
|
||||
</p>
|
||||
</MenuOption>
|
||||
<MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} on:click={() => handleDownloadAlbum()} />
|
||||
{#if showFullContextMenu}
|
||||
<MenuOption on:click={() => setAlbumToDelete()}>
|
||||
<p class="flex gap-2">
|
||||
<Icon path={mdiDeleteOutline} size="18" />
|
||||
Delete
|
||||
</p>
|
||||
</MenuOption>
|
||||
<MenuOption icon={mdiDeleteOutline} text={$t('delete')} on:click={() => setAlbumToDelete()} />
|
||||
{/if}
|
||||
</RightClickContextMenu>
|
||||
|
||||
|
|
|
@ -9,16 +9,14 @@
|
|||
} from '@immich/sdk';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { getContextMenuPosition } from '../../utils/context-menu';
|
||||
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 ContextMenu from '../shared-components/context-menu/context-menu.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
|
||||
export let album: AlbumResponseDto;
|
||||
export let onClose: () => void;
|
||||
|
@ -29,8 +27,6 @@
|
|||
}>();
|
||||
|
||||
let currentUser: UserResponseDto;
|
||||
let position = { x: 0, y: 0 };
|
||||
let selectedMenuUser: UserResponseDto | null = null;
|
||||
let selectedRemoveUser: UserResponseDto | null = null;
|
||||
|
||||
$: isOwned = currentUser?.id == album.ownerId;
|
||||
|
@ -43,15 +39,8 @@
|
|||
}
|
||||
});
|
||||
|
||||
const showContextMenu = (event: MouseEvent, user: UserResponseDto) => {
|
||||
position = getContextMenuPosition(event);
|
||||
selectedMenuUser = user;
|
||||
selectedRemoveUser = null;
|
||||
};
|
||||
|
||||
const handleMenuRemove = () => {
|
||||
selectedRemoveUser = selectedMenuUser;
|
||||
selectedMenuUser = null;
|
||||
const handleMenuRemove = (user: UserResponseDto) => {
|
||||
selectedRemoveUser = user;
|
||||
};
|
||||
|
||||
const handleRemoveUser = async () => {
|
||||
|
@ -118,31 +107,17 @@
|
|||
{/if}
|
||||
</div>
|
||||
{#if isOwned}
|
||||
<div>
|
||||
<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}
|
||||
<MenuOption
|
||||
on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)}
|
||||
text={$t('allow_edits')}
|
||||
/>
|
||||
{:else}
|
||||
<MenuOption
|
||||
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
<MenuOption on:click={handleMenuRemove} text={$t('remove')} />
|
||||
</ContextMenu>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} size="20" title={$t('options')}>
|
||||
{#if role === AlbumUserRole.Viewer}
|
||||
<MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
||||
{:else}
|
||||
<MenuOption
|
||||
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
|
||||
text={$t('disallow_edits')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<MenuOption on:click={() => handleMenuRemove(user)} text={$t('remove')} />
|
||||
</ButtonContextMenu>
|
||||
{:else if user.id == currentUser?.id}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
import { user } from '$lib/stores/user.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
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 { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
|
@ -36,9 +34,9 @@
|
|||
mdiUpload,
|
||||
} from '@mdi/js';
|
||||
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 { t } from 'svelte-i18n';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
|
@ -79,21 +77,11 @@
|
|||
|
||||
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) => {
|
||||
isShowAssetOptions = false;
|
||||
dispatch('runJob', name);
|
||||
};
|
||||
|
||||
const onMenuClick = (eventName: keyof EventTypes) => {
|
||||
isShowAssetOptions = false;
|
||||
dispatch(eventName);
|
||||
};
|
||||
</script>
|
||||
|
@ -187,90 +175,72 @@
|
|||
on:delete={() => dispatch('delete')}
|
||||
on:permanentlyDelete={() => dispatch('permanentlyDelete')}
|
||||
/>
|
||||
<div
|
||||
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}
|
||||
<MenuOption
|
||||
icon={mdiPresentationPlay}
|
||||
on:click={() => onMenuClick('playSlideShow')}
|
||||
text={$t('slideshow')}
|
||||
/>
|
||||
{/if}
|
||||
{#if showDownloadButton}
|
||||
<MenuOption
|
||||
icon={mdiFolderDownloadOutline}
|
||||
on:click={() => onMenuClick('download')}
|
||||
text={$t('download')}
|
||||
/>
|
||||
{/if}
|
||||
{#if asset.isTrashed}
|
||||
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
|
||||
{:else}
|
||||
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
|
||||
<MenuOption
|
||||
icon={mdiShareVariantOutline}
|
||||
on:click={() => onMenuClick('addToSharedAlbum')}
|
||||
text={$t('add_to_shared_album')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
{#if hasStackChildren}
|
||||
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
|
||||
{/if}
|
||||
{#if album}
|
||||
<MenuOption
|
||||
text={$t('set_as_album_cover')}
|
||||
icon={mdiImageOutline}
|
||||
on:click={() => onMenuClick('setAsAlbumCover')}
|
||||
/>
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<MenuOption
|
||||
icon={mdiAccountCircleOutline}
|
||||
on:click={() => onMenuClick('asProfileImage')}
|
||||
text={$t('set_as_profile_picture')}
|
||||
/>
|
||||
{/if}
|
||||
<MenuOption
|
||||
on:click={() => onMenuClick('toggleArchive')}
|
||||
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
|
||||
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiUpload}
|
||||
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
|
||||
text={$t('replace_with_upload')}
|
||||
/>
|
||||
<hr />
|
||||
<MenuOption
|
||||
icon={mdiDatabaseRefreshOutline}
|
||||
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
|
||||
text={getAssetJobName(AssetJobName.RefreshMetadata)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiImageRefreshOutline}
|
||||
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
|
||||
text={getAssetJobName(AssetJobName.RegenerateThumbnail)}
|
||||
/>
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiCogRefreshOutline}
|
||||
on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
|
||||
text={getAssetJobName(AssetJobName.TranscodeVideo)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</ContextMenu>
|
||||
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
||||
{#if showSlideshow}
|
||||
<MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text={$t('slideshow')} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDownloadButton}
|
||||
<MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text={$t('download')} />
|
||||
{/if}
|
||||
{#if asset.isTrashed}
|
||||
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
|
||||
{:else}
|
||||
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
|
||||
<MenuOption
|
||||
icon={mdiShareVariantOutline}
|
||||
on:click={() => onMenuClick('addToSharedAlbum')}
|
||||
text={$t('add_to_shared_album')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
{#if hasStackChildren}
|
||||
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
|
||||
{/if}
|
||||
{#if album}
|
||||
<MenuOption
|
||||
text={$t('set_as_album_cover')}
|
||||
icon={mdiImageOutline}
|
||||
on:click={() => onMenuClick('setAsAlbumCover')}
|
||||
/>
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<MenuOption
|
||||
icon={mdiAccountCircleOutline}
|
||||
on:click={() => onMenuClick('asProfileImage')}
|
||||
text={$t('set_as_profile_picture')}
|
||||
/>
|
||||
{/if}
|
||||
<MenuOption
|
||||
on:click={() => onMenuClick('toggleArchive')}
|
||||
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
|
||||
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiUpload}
|
||||
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
|
||||
text={$t('replace_with_upload')}
|
||||
/>
|
||||
<hr />
|
||||
<MenuOption
|
||||
icon={mdiDatabaseRefreshOutline}
|
||||
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
|
||||
text={getAssetJobName(AssetJobName.RefreshMetadata)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiImageRefreshOutline}
|
||||
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
|
||||
text={getAssetJobName(AssetJobName.RegenerateThumbnail)}
|
||||
/>
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiCogRefreshOutline}
|
||||
on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
|
||||
text={getAssetJobName(AssetJobName.TranscodeVideo)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
<script lang="ts" context="module">
|
||||
export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
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 icon: string;
|
||||
export let color: Color = 'transparent';
|
||||
export let title: string;
|
||||
/**
|
||||
* The padding of the button, used by the `p-{padding}` Tailwind CSS class.
|
||||
*/
|
||||
export let padding = '3';
|
||||
/**
|
||||
* Size of the button, used for a CSS value.
|
||||
*/
|
||||
export let size = '24';
|
||||
export let hideMobile = false;
|
||||
export let buttonSize: string | undefined = undefined;
|
||||
|
@ -14,6 +23,10 @@
|
|||
* viewBox attribute for the SVG icon.
|
||||
*/
|
||||
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.
|
||||
|
@ -33,14 +46,19 @@
|
|||
|
||||
$: colorClass = colorClasses[color];
|
||||
$: mobileClass = hideMobile ? 'hidden sm:flex' : '';
|
||||
$: paddingClass = `p-${padding}`;
|
||||
</script>
|
||||
|
||||
<button
|
||||
{id}
|
||||
{title}
|
||||
{type}
|
||||
style:width={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
|
||||
>
|
||||
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiAccountEditOutline,
|
||||
|
@ -12,11 +11,10 @@
|
|||
} from '@mdi/js';
|
||||
import { createEventDispatcher } from '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 Portal from '../shared-components/portal/portal.svelte';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
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 preload = false;
|
||||
|
@ -30,17 +28,7 @@
|
|||
}>();
|
||||
|
||||
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) => {
|
||||
onMenuExit();
|
||||
dispatch(event);
|
||||
};
|
||||
</script>
|
||||
|
@ -51,8 +39,13 @@
|
|||
on:mouseenter={() => (showVerticalDots = true)}
|
||||
on:mouseleave={() => (showVerticalDots = false)}
|
||||
role="group"
|
||||
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
|
||||
>
|
||||
<a href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}" draggable="false">
|
||||
<a
|
||||
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
|
||||
draggable="false"
|
||||
on:focus={() => (showVerticalDots = true)}
|
||||
>
|
||||
<div class="w-full h-full rounded-xl brightness-95 filter">
|
||||
<ImageThumbnail
|
||||
shadow
|
||||
|
@ -73,22 +66,15 @@
|
|||
{/if}
|
||||
</a>
|
||||
|
||||
<div class="absolute right-2 top-2" class:hidden={!showVerticalDots}>
|
||||
<CircleIconButton
|
||||
<div class="absolute top-2 right-2">
|
||||
<ButtonContextMenu
|
||||
buttonClass="icon-white-drop-shadow focus:opacity-100 {showVerticalDots ? 'opacity-100' : 'opacity-0'}"
|
||||
color="opaque"
|
||||
padding="2"
|
||||
size="20"
|
||||
icon={mdiDotsVertical}
|
||||
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('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
|
||||
<MenuOption
|
||||
|
@ -101,6 +87,6 @@
|
|||
icon={mdiAccountMultipleCheckOutline}
|
||||
text={$t('merge_people')}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</Portal>
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-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 ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
|
@ -146,20 +146,20 @@
|
|||
<CreateSharedLink />
|
||||
<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 shared />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
|
||||
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
|
||||
<DeleteAssets menuItem {onAssetDelete} />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
|
||||
import type { AlbumResponseDto } from '@immich/sdk';
|
||||
import { getMenuContext } from '../asset-select-context-menu.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
@ -13,16 +12,13 @@
|
|||
let showAlbumPicker = false;
|
||||
|
||||
const { getAssets, clearSelect } = getAssetControlContext();
|
||||
const closeMenu = getMenuContext();
|
||||
|
||||
const handleHideAlbumPicker = () => {
|
||||
showAlbumPicker = false;
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const handleAddToNewAlbum = async (albumName: string) => {
|
||||
showAlbumPicker = false;
|
||||
closeMenu();
|
||||
|
||||
const assetIds = [...getAssets()].map((asset) => asset.id);
|
||||
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 { clickOutside } from '$lib/actions/click-outside';
|
||||
|
||||
export let isVisible: boolean = false;
|
||||
export let direction: 'left' | 'right' = 'right';
|
||||
export let x = 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;
|
||||
|
||||
let left: number;
|
||||
|
@ -30,16 +35,25 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
bind:clientHeight={height}
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
class="absolute z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
style:top="{top}px"
|
||||
class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
|
||||
style:left="{left}px"
|
||||
role="menu"
|
||||
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
|
||||
style:top="{top}px"
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
use:clickOutside={{ onOutclick: onClose }}
|
||||
>
|
||||
<div class="flex flex-col rounded-lg">
|
||||
<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"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -1,15 +1,37 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
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 subtitle = '';
|
||||
export let icon = '';
|
||||
|
||||
let id: string = generateId();
|
||||
|
||||
$: isActive = $selectedIdStore === id;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
click: void;
|
||||
}>();
|
||||
|
||||
const handleClick = () => {
|
||||
$optionClickCallbackStore?.();
|
||||
dispatch('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click
|
||||
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"
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
|
||||
<li
|
||||
{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"
|
||||
>
|
||||
{#if text}
|
||||
|
@ -30,4 +52,4 @@
|
|||
{subtitle}
|
||||
</p>
|
||||
</slot>
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
const key = {};
|
||||
|
||||
export { key };
|
|
@ -1,7 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { tick } from '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 x = 0;
|
||||
export let y = 0;
|
||||
|
@ -9,7 +14,19 @@
|
|||
export let onClose: (() => unknown) | undefined;
|
||||
|
||||
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 contextMenuEvent = new MouseEvent('contextmenu', {
|
||||
|
@ -22,7 +39,7 @@
|
|||
|
||||
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
|
||||
// menu as is
|
||||
return;
|
||||
|
@ -38,20 +55,51 @@
|
|||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
triggerElement?.focus();
|
||||
onClose?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#key uniqueKey}
|
||||
{#if isOpen}
|
||||
<section
|
||||
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
|
||||
on:contextmenu|preventDefault={reopenContextMenu}
|
||||
role="presentation"
|
||||
<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,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ContextMenu {x} {y} {direction} onClose={closeContextMenu} bind:menuElement={contextMenuElement}>
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
</section>
|
||||
<section
|
||||
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
|
||||
on:contextmenu|preventDefault={reopenContextMenu}
|
||||
role="presentation"
|
||||
>
|
||||
<ContextMenu
|
||||
{direction}
|
||||
{x}
|
||||
{y}
|
||||
ariaActiveDescendant={$selectedIdStore}
|
||||
ariaLabel={title}
|
||||
bind:menuElement={menuContainer}
|
||||
id={menuId}
|
||||
isVisible
|
||||
onClose={closeContextMenu}
|
||||
>
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{/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 const getContextMenuPosition = (event: MouseEvent, align: Align = 'middle'): ContextMenuPosition => {
|
||||
const { x, y, currentTarget, target } = event;
|
||||
export const getContextMenuPositionFromEvent = (
|
||||
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();
|
||||
if (box) {
|
||||
switch (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 getContextMenuPositionFromBoundingRect(box, align);
|
||||
}
|
||||
|
||||
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 SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.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 ContextMenu from '$lib/components/shared-components/context-menu/context-menu.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 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 { handlePromiseError, s } from '$lib/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 { handleError } from '$lib/utils/handle-error';
|
||||
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
|
||||
|
@ -103,7 +100,6 @@
|
|||
SELECT_USERS = 'select-users',
|
||||
SELECT_THUMBNAIL = 'select-thumbnail',
|
||||
SELECT_ASSETS = 'select-assets',
|
||||
ALBUM_OPTIONS = 'album-options',
|
||||
VIEW_USERS = 'view-users',
|
||||
VIEW = 'view',
|
||||
OPTIONS = 'options',
|
||||
|
@ -112,7 +108,6 @@
|
|||
let backUrl: string = AppRoute.ALBUMS;
|
||||
let viewMode = ViewMode.VIEW;
|
||||
let isCreatingSharedAlbum = false;
|
||||
let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
|
||||
let isShowActivity = false;
|
||||
let isLiked: ActivityResponseDto | null = null;
|
||||
let reactions: ActivityResponseDto[] = [];
|
||||
|
@ -305,11 +300,6 @@
|
|||
timelineInteractionStore.clearMultiselect();
|
||||
};
|
||||
|
||||
const handleOpenAlbumOptions = (event: MouseEvent) => {
|
||||
contextMenuPosition = getContextMenuPosition(event, 'top-left');
|
||||
viewMode = viewMode === ViewMode.VIEW ? ViewMode.ALBUM_OPTIONS : ViewMode.VIEW;
|
||||
};
|
||||
|
||||
const handleSelectFromComputer = async () => {
|
||||
await openFileUploadDialog({ albumId: album.id });
|
||||
timelineInteractionStore.clearMultiselect();
|
||||
|
@ -420,14 +410,14 @@
|
|||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
{#if isAllUserOwned}
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
{/if}
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||
{#if isAllUserOwned}
|
||||
<ChangeDate menuItem />
|
||||
|
@ -447,10 +437,10 @@
|
|||
{#if isAllUserOwned}
|
||||
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} />
|
||||
{/if}
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
{#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
|
||||
{#if viewMode === ViewMode.VIEW}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}>
|
||||
<svelte:fragment slot="trailing">
|
||||
{#if isEditor}
|
||||
|
@ -474,32 +464,19 @@
|
|||
<CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
|
||||
|
||||
{#if isOwned}
|
||||
<div use:clickOutside={{ onOutclick: () => (viewMode = ViewMode.VIEW) }}>
|
||||
<CircleIconButton
|
||||
title={$t('album_options')}
|
||||
on:click={handleOpenAlbumOptions}
|
||||
icon={mdiDotsVertical}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('album_options')}>
|
||||
<MenuOption
|
||||
icon={mdiImageOutline}
|
||||
text={$t('select_album_cover')}
|
||||
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
|
||||
/>
|
||||
{#if viewMode === ViewMode.ALBUM_OPTIONS}
|
||||
<ContextMenu {...contextMenuPosition}>
|
||||
<MenuOption
|
||||
icon={mdiImageOutline}
|
||||
text={$t('select_album_cover')}
|
||||
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiCogOutline}
|
||||
text={$t('options')}
|
||||
on:click={() => (viewMode = ViewMode.OPTIONS)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiDeleteOutline}
|
||||
text={$t('delete_album')}
|
||||
on:click={() => handleRemoveAlbum()}
|
||||
/>
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</div>
|
||||
<MenuOption
|
||||
icon={mdiCogOutline}
|
||||
text={$t('options')}
|
||||
on:click={() => (viewMode = ViewMode.OPTIONS)}
|
||||
/>
|
||||
<MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} on:click={() => handleRemoveAlbum()} />
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.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 EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
|
@ -32,15 +32,15 @@
|
|||
<ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<DownloadAction menuItem />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.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 EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
|
@ -35,17 +35,17 @@
|
|||
<FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
</ButtonContextMenu>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-action.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 ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
|
@ -30,10 +30,10 @@
|
|||
{#if $isMultiSelectState}
|
||||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
|
||||
<CreateSharedLink />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
<DownloadAction />
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.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 MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
|
@ -57,6 +56,7 @@
|
|||
import type { PageData } from './$types';
|
||||
import { listNavigation } from '$lib/actions/list-navigation';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -380,12 +380,12 @@
|
|||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
<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" />
|
||||
<MenuOption
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
|
@ -396,13 +396,13 @@
|
|||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}>
|
||||
<svelte:fragment slot="trailing">
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<MenuOption
|
||||
text={$t('select_featured_photo')}
|
||||
icon={mdiAccountBoxOutline}
|
||||
|
@ -423,7 +423,7 @@
|
|||
icon={mdiAccountMultipleCheckOutline}
|
||||
on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)}
|
||||
/>
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
|
||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.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 MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
|
@ -58,12 +58,12 @@
|
|||
>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if $selectedAssets.size > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
|
@ -78,7 +78,7 @@
|
|||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
|
||||
import DownloadAction from '$lib/components/photos-page/actions/download-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 ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||
|
@ -211,19 +211,19 @@
|
|||
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
|
||||
<CreateSharedLink />
|
||||
<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 shared />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
|
||||
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
|
||||
<DeleteAssets menuItem {onAssetDelete} />
|
||||
</AssetSelectContextMenu>
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
|
@ -6,16 +6,13 @@
|
|||
import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
|
||||
import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.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 LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} 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 { getContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
createLibrary,
|
||||
|
@ -35,9 +32,9 @@
|
|||
import { fade, slide } from 'svelte/transition';
|
||||
import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte';
|
||||
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 { t } from 'svelte-i18n';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -63,10 +60,6 @@
|
|||
let deleteAssetCount = 0;
|
||||
|
||||
let dropdownOpen: boolean[] = [];
|
||||
let showContextMenu = false;
|
||||
let contextMenuPosition = { x: 0, y: 0 };
|
||||
let selectedLibraryIndex = 0;
|
||||
let selectedLibrary: LibraryResponseDto | null = null;
|
||||
|
||||
let toCreateLibrary = false;
|
||||
|
||||
|
@ -79,25 +72,12 @@
|
|||
editScanSettings = null;
|
||||
renameLibrary = null;
|
||||
updateLibraryIndex = null;
|
||||
showContextMenu = false;
|
||||
|
||||
for (let index = 0; index < dropdownOpen.length; index++) {
|
||||
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) => {
|
||||
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
|
||||
owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
|
||||
|
@ -233,72 +213,72 @@
|
|||
}
|
||||
};
|
||||
|
||||
const onRenameClicked = () => {
|
||||
const onRenameClicked = (index: number) => {
|
||||
closeAll();
|
||||
renameLibrary = selectedLibraryIndex;
|
||||
updateLibraryIndex = selectedLibraryIndex;
|
||||
renameLibrary = index;
|
||||
updateLibraryIndex = index;
|
||||
};
|
||||
|
||||
const onEditImportPathClicked = () => {
|
||||
const onEditImportPathClicked = (index: number) => {
|
||||
closeAll();
|
||||
editImportPaths = selectedLibraryIndex;
|
||||
updateLibraryIndex = selectedLibraryIndex;
|
||||
editImportPaths = index;
|
||||
updateLibraryIndex = index;
|
||||
};
|
||||
|
||||
const onScanNewLibraryClicked = async () => {
|
||||
const onScanNewLibraryClicked = async (library: LibraryResponseDto) => {
|
||||
closeAll();
|
||||
|
||||
if (selectedLibrary) {
|
||||
await handleScan(selectedLibrary.id);
|
||||
if (library) {
|
||||
await handleScan(library.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onScanSettingClicked = () => {
|
||||
const onScanSettingClicked = (index: number) => {
|
||||
closeAll();
|
||||
editScanSettings = selectedLibraryIndex;
|
||||
updateLibraryIndex = selectedLibraryIndex;
|
||||
editScanSettings = index;
|
||||
updateLibraryIndex = index;
|
||||
};
|
||||
|
||||
const onScanAllLibraryFilesClicked = async () => {
|
||||
const onScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
|
||||
closeAll();
|
||||
if (selectedLibrary) {
|
||||
await handleScanChanges(selectedLibrary.id);
|
||||
if (library) {
|
||||
await handleScanChanges(library.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onForceScanAllLibraryFilesClicked = async () => {
|
||||
const onForceScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
|
||||
closeAll();
|
||||
if (selectedLibrary) {
|
||||
await handleForceScan(selectedLibrary.id);
|
||||
if (library) {
|
||||
await handleForceScan(library.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveOfflineFilesClicked = async () => {
|
||||
const onRemoveOfflineFilesClicked = async (library: LibraryResponseDto) => {
|
||||
closeAll();
|
||||
if (selectedLibrary) {
|
||||
await handleRemoveOffline(selectedLibrary.id);
|
||||
if (library) {
|
||||
await handleRemoveOffline(library.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteLibraryClicked = async () => {
|
||||
const onDeleteLibraryClicked = async (library: LibraryResponseDto, index: number) => {
|
||||
closeAll();
|
||||
|
||||
if (!selectedLibrary) {
|
||||
if (!library) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirmedLibrary = await dialogController.show({
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshStats(selectedLibraryIndex);
|
||||
if (totalCount[selectedLibraryIndex] > 0) {
|
||||
deleteAssetCount = totalCount[selectedLibraryIndex];
|
||||
await refreshStats(index);
|
||||
if (totalCount[index] > 0) {
|
||||
deleteAssetCount = totalCount[index];
|
||||
|
||||
const isConfirmedLibraryAssetCount = await dialogController.show({
|
||||
id: 'delete-library-assets',
|
||||
|
@ -310,7 +290,7 @@
|
|||
}
|
||||
await handleDelete();
|
||||
} else {
|
||||
deletedLibrary = selectedLibrary;
|
||||
deletedLibrary = library;
|
||||
await handleDelete();
|
||||
}
|
||||
};
|
||||
|
@ -392,46 +372,38 @@
|
|||
{/if}
|
||||
|
||||
<td class=" text-ellipsis px-4 text-sm">
|
||||
<CircleIconButton
|
||||
<ButtonContextMenu
|
||||
align="top-right"
|
||||
direction="left"
|
||||
color="primary"
|
||||
size="16"
|
||||
icon={mdiDotsVertical}
|
||||
title={$t('library_options')}
|
||||
size="16"
|
||||
on:click={(e) => showMenu(e, library, index)}
|
||||
/>
|
||||
|
||||
{#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 />
|
||||
<MenuOption on:click={() => onScanNewLibraryClicked()} text={$t('scan_new_library_files')} />
|
||||
<MenuOption
|
||||
on:click={() => onScanAllLibraryFilesClicked()}
|
||||
text={$t('scan_all_library_files')}
|
||||
subtitle={$t('only_refreshes_modified_files')}
|
||||
/>
|
||||
<MenuOption
|
||||
on:click={() => onForceScanAllLibraryFilesClicked()}
|
||||
text={$t('force_re-scan_library_files')}
|
||||
subtitle={$t('refreshes_every_file')}
|
||||
/>
|
||||
<hr />
|
||||
<MenuOption
|
||||
on:click={() => onRemoveOfflineFilesClicked()}
|
||||
text={$t('remove_offline_files')}
|
||||
/>
|
||||
<MenuOption on:click={() => onDeleteLibraryClicked()}>
|
||||
<p class="text-red-600">{$t('delete_library')}</p>
|
||||
</MenuOption>
|
||||
{/if}
|
||||
</ContextMenu>
|
||||
</Portal>
|
||||
{/if}
|
||||
>
|
||||
<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')} />
|
||||
<hr />
|
||||
<MenuOption on:click={() => onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} />
|
||||
<MenuOption
|
||||
on:click={() => onScanAllLibraryFilesClicked(library)}
|
||||
text={$t('scan_all_library_files')}
|
||||
subtitle={$t('only_refreshes_modified_files')}
|
||||
/>
|
||||
<MenuOption
|
||||
on:click={() => onForceScanAllLibraryFilesClicked(library)}
|
||||
text={$t('force_re-scan_library_files')}
|
||||
subtitle={$t('refreshes_every_file')}
|
||||
/>
|
||||
<hr />
|
||||
<MenuOption
|
||||
on:click={() => onRemoveOfflineFilesClicked(library)}
|
||||
text={$t('remove_offline_files')}
|
||||
/>
|
||||
<MenuOption on:click={() => onDeleteLibraryClicked(library, index)}>
|
||||
<p class="text-red-600">{$t('delete_library')}</p>
|
||||
</MenuOption>
|
||||
</ButtonContextMenu>
|
||||
</td>
|
||||
</tr>
|
||||
{#if renameLibrary === index}
|
||||
|
|
Loading…
Reference in a new issue