mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +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 {
|
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
|
{#if role === AlbumUserRole.Viewer}
|
||||||
title={$t('options')}
|
<MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
|
||||||
on:click={(event) => showContextMenu(event, user)}
|
{:else}
|
||||||
icon={mdiDotsVertical}
|
<MenuOption
|
||||||
size="20"
|
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
|
||||||
/>
|
text={$t('disallow_edits')}
|
||||||
|
/>
|
||||||
{#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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<MenuOption on:click={() => handleMenuRemove(user)} text={$t('remove')} />
|
||||||
|
</ButtonContextMenu>
|
||||||
{: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,90 +175,72 @@
|
||||||
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={{
|
{#if showSlideshow}
|
||||||
onOutclick: () => (isShowAssetOptions = false),
|
<MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text={$t('slideshow')} />
|
||||||
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>
|
|
||||||
{/if}
|
{/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}
|
{/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">
|
<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">
|
<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 }}
|
||||||
>
|
>
|
||||||
<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 />
|
<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}
|
||||||
<section
|
<div
|
||||||
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
|
use:contextMenuNavigation={{
|
||||||
on:contextmenu|preventDefault={reopenContextMenu}
|
closeDropdown: closeContextMenu,
|
||||||
role="presentation"
|
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}>
|
<section
|
||||||
<slot />
|
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
|
||||||
</ContextMenu>
|
on:contextmenu|preventDefault={reopenContextMenu}
|
||||||
</section>
|
role="presentation"
|
||||||
|
>
|
||||||
|
<ContextMenu
|
||||||
|
{direction}
|
||||||
|
{x}
|
||||||
|
{y}
|
||||||
|
ariaActiveDescendant={$selectedIdStore}
|
||||||
|
ariaLabel={title}
|
||||||
|
bind:menuElement={menuContainer}
|
||||||
|
id={menuId}
|
||||||
|
isVisible
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ContextMenu>
|
||||||
|
</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,32 +464,19 @@
|
||||||
<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
|
<MenuOption
|
||||||
title={$t('album_options')}
|
icon={mdiImageOutline}
|
||||||
on:click={handleOpenAlbumOptions}
|
text={$t('select_album_cover')}
|
||||||
icon={mdiDotsVertical}
|
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
|
||||||
/>
|
/>
|
||||||
{#if viewMode === ViewMode.ALBUM_OPTIONS}
|
<MenuOption
|
||||||
<ContextMenu {...contextMenuPosition}>
|
icon={mdiCogOutline}
|
||||||
<MenuOption
|
text={$t('options')}
|
||||||
icon={mdiImageOutline}
|
on:click={() => (viewMode = ViewMode.OPTIONS)}
|
||||||
text={$t('select_album_cover')}
|
/>
|
||||||
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
|
<MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} on:click={() => handleRemoveAlbum()} />
|
||||||
/>
|
</ButtonContextMenu>
|
||||||
<MenuOption
|
|
||||||
icon={mdiCogOutline}
|
|
||||||
text={$t('options')}
|
|
||||||
on:click={() => (viewMode = ViewMode.OPTIONS)}
|
|
||||||
/>
|
|
||||||
<MenuOption
|
|
||||||
icon={mdiDeleteOutline}
|
|
||||||
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}
|
<hr />
|
||||||
<Portal target="body">
|
<MenuOption on:click={() => onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} />
|
||||||
<ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
|
<MenuOption
|
||||||
<MenuOption on:click={() => onRenameClicked()} text={$t('rename')} />
|
on:click={() => onScanAllLibraryFilesClicked(library)}
|
||||||
|
text={$t('scan_all_library_files')}
|
||||||
{#if selectedLibrary}
|
subtitle={$t('only_refreshes_modified_files')}
|
||||||
<MenuOption on:click={() => onEditImportPathClicked()} text={$t('edit_import_paths')} />
|
/>
|
||||||
<MenuOption on:click={() => onScanSettingClicked()} text={$t('scan_settings')} />
|
<MenuOption
|
||||||
<hr />
|
on:click={() => onForceScanAllLibraryFilesClicked(library)}
|
||||||
<MenuOption on:click={() => onScanNewLibraryClicked()} text={$t('scan_new_library_files')} />
|
text={$t('force_re-scan_library_files')}
|
||||||
<MenuOption
|
subtitle={$t('refreshes_every_file')}
|
||||||
on:click={() => onScanAllLibraryFilesClicked()}
|
/>
|
||||||
text={$t('scan_all_library_files')}
|
<hr />
|
||||||
subtitle={$t('only_refreshes_modified_files')}
|
<MenuOption
|
||||||
/>
|
on:click={() => onRemoveOfflineFilesClicked(library)}
|
||||||
<MenuOption
|
text={$t('remove_offline_files')}
|
||||||
on:click={() => onForceScanAllLibraryFilesClicked()}
|
/>
|
||||||
text={$t('force_re-scan_library_files')}
|
<MenuOption on:click={() => onDeleteLibraryClicked(library, index)}>
|
||||||
subtitle={$t('refreshes_every_file')}
|
<p class="text-red-600">{$t('delete_library')}</p>
|
||||||
/>
|
</MenuOption>
|
||||||
<hr />
|
</ButtonContextMenu>
|
||||||
<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}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#if renameLibrary === index}
|
{#if renameLibrary === index}
|
||||||
|
|
Loading…
Reference in a new issue