mirror of
https://github.com/immich-app/immich.git
synced 2025-06-23 15:30:51 -04:00
* 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>
247 lines
8.0 KiB
Svelte
247 lines
8.0 KiB
Svelte
<script lang="ts">
|
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
|
import DeleteButton from './delete-button.svelte';
|
|
import { user } from '$lib/stores/user.store';
|
|
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
|
import { getAssetJobName } from '$lib/utils';
|
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
|
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
|
import {
|
|
mdiAccountCircleOutline,
|
|
mdiAlertOutline,
|
|
mdiArchiveArrowDownOutline,
|
|
mdiArchiveArrowUpOutline,
|
|
mdiArrowLeft,
|
|
mdiCogRefreshOutline,
|
|
mdiContentCopy,
|
|
mdiDatabaseRefreshOutline,
|
|
mdiDotsVertical,
|
|
mdiFolderDownloadOutline,
|
|
mdiHeart,
|
|
mdiHeartOutline,
|
|
mdiHistory,
|
|
mdiImageAlbum,
|
|
mdiImageMinusOutline,
|
|
mdiImageOutline,
|
|
mdiImageRefreshOutline,
|
|
mdiInformationOutline,
|
|
mdiMagnifyMinusOutline,
|
|
mdiMagnifyPlusOutline,
|
|
mdiMotionPauseOutline,
|
|
mdiPlaySpeed,
|
|
mdiPresentationPlay,
|
|
mdiShareVariantOutline,
|
|
mdiUpload,
|
|
} from '@mdi/js';
|
|
import { createEventDispatcher } from 'svelte';
|
|
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
|
import { t } from 'svelte-i18n';
|
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
|
|
|
export let asset: AssetResponseDto;
|
|
export let album: AlbumResponseDto | null = null;
|
|
export let showCopyButton: boolean;
|
|
export let showZoomButton: boolean;
|
|
export let showMotionPlayButton: boolean;
|
|
export let isMotionPhotoPlaying = false;
|
|
export let showDownloadButton: boolean;
|
|
export let showDetailButton: boolean;
|
|
export let showShareButton: boolean;
|
|
export let showSlideshow = false;
|
|
export let hasStackChildren = false;
|
|
export let onZoomImage: () => void;
|
|
export let onCopyImage: () => void;
|
|
|
|
$: isOwner = $user && asset.ownerId === $user?.id;
|
|
|
|
type EventTypes = {
|
|
back: void;
|
|
stopMotionPhoto: void;
|
|
playMotionPhoto: void;
|
|
download: void;
|
|
showDetail: void;
|
|
favorite: void;
|
|
delete: void;
|
|
permanentlyDelete: void;
|
|
toggleArchive: void;
|
|
addToAlbum: void;
|
|
restoreAsset: void;
|
|
addToSharedAlbum: void;
|
|
asProfileImage: void;
|
|
setAsAlbumCover: void;
|
|
runJob: AssetJobName;
|
|
playSlideShow: void;
|
|
unstack: void;
|
|
showShareModal: void;
|
|
};
|
|
|
|
const dispatch = createEventDispatcher<EventTypes>();
|
|
|
|
const onJobClick = (name: AssetJobName) => {
|
|
dispatch('runJob', name);
|
|
};
|
|
|
|
const onMenuClick = (eventName: keyof EventTypes) => {
|
|
dispatch(eventName);
|
|
};
|
|
</script>
|
|
|
|
<div
|
|
class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200"
|
|
>
|
|
<div class="text-white">
|
|
<CircleIconButton color="opaque" icon={mdiArrowLeft} title={$t('go_back')} on:click={() => dispatch('back')} />
|
|
</div>
|
|
<div
|
|
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
|
|
data-testid="asset-viewer-navbar-actions"
|
|
>
|
|
{#if showShareButton}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
icon={mdiShareVariantOutline}
|
|
on:click={() => dispatch('showShareModal')}
|
|
title={$t('share')}
|
|
/>
|
|
{/if}
|
|
{#if asset.isOffline}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
icon={mdiAlertOutline}
|
|
on:click={() => dispatch('showDetail')}
|
|
title={$t('asset_offline')}
|
|
/>
|
|
{/if}
|
|
{#if showMotionPlayButton}
|
|
{#if isMotionPhotoPlaying}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
icon={mdiMotionPauseOutline}
|
|
title={$t('stop_motion_photo')}
|
|
on:click={() => dispatch('stopMotionPhoto')}
|
|
/>
|
|
{:else}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
icon={mdiPlaySpeed}
|
|
title={$t('play_motion_photo')}
|
|
on:click={() => dispatch('playMotionPhoto')}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
{#if showZoomButton}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
hideMobile={true}
|
|
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
|
|
title={$t('zoom_image')}
|
|
on:click={onZoomImage}
|
|
/>
|
|
{/if}
|
|
{#if showCopyButton}
|
|
<CircleIconButton color="opaque" icon={mdiContentCopy} title={$t('copy_image')} on:click={onCopyImage} />
|
|
{/if}
|
|
|
|
{#if !isOwner && showDownloadButton}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
icon={mdiFolderDownloadOutline}
|
|
on:click={() => dispatch('download')}
|
|
title={$t('download')}
|
|
/>
|
|
{/if}
|
|
|
|
{#if showDetailButton}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
icon={mdiInformationOutline}
|
|
on:click={() => dispatch('showDetail')}
|
|
title={$t('info')}
|
|
/>
|
|
{/if}
|
|
|
|
{#if isOwner}
|
|
<CircleIconButton
|
|
color="opaque"
|
|
icon={asset.isFavorite ? mdiHeart : mdiHeartOutline}
|
|
on:click={() => dispatch('favorite')}
|
|
title={asset.isFavorite ? $t('unfavorite') : $t('to_favorite')}
|
|
/>
|
|
{/if}
|
|
|
|
{#if isOwner}
|
|
<DeleteButton
|
|
{asset}
|
|
on:delete={() => dispatch('delete')}
|
|
on:permanentlyDelete={() => dispatch('permanentlyDelete')}
|
|
/>
|
|
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
|
|
{#if showSlideshow}
|
|
<MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text={$t('slideshow')} />
|
|
{/if}
|
|
{#if showDownloadButton}
|
|
<MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text={$t('download')} />
|
|
{/if}
|
|
{#if asset.isTrashed}
|
|
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
|
|
{:else}
|
|
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
|
|
<MenuOption
|
|
icon={mdiShareVariantOutline}
|
|
on:click={() => onMenuClick('addToSharedAlbum')}
|
|
text={$t('add_to_shared_album')}
|
|
/>
|
|
{/if}
|
|
|
|
{#if isOwner}
|
|
{#if hasStackChildren}
|
|
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
|
|
{/if}
|
|
{#if album}
|
|
<MenuOption
|
|
text={$t('set_as_album_cover')}
|
|
icon={mdiImageOutline}
|
|
on:click={() => onMenuClick('setAsAlbumCover')}
|
|
/>
|
|
{/if}
|
|
{#if asset.type === AssetTypeEnum.Image}
|
|
<MenuOption
|
|
icon={mdiAccountCircleOutline}
|
|
on:click={() => onMenuClick('asProfileImage')}
|
|
text={$t('set_as_profile_picture')}
|
|
/>
|
|
{/if}
|
|
<MenuOption
|
|
on:click={() => onMenuClick('toggleArchive')}
|
|
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
|
|
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
|
|
/>
|
|
<MenuOption
|
|
icon={mdiUpload}
|
|
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
|
|
text={$t('replace_with_upload')}
|
|
/>
|
|
<hr />
|
|
<MenuOption
|
|
icon={mdiDatabaseRefreshOutline}
|
|
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
|
|
text={getAssetJobName(AssetJobName.RefreshMetadata)}
|
|
/>
|
|
<MenuOption
|
|
icon={mdiImageRefreshOutline}
|
|
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
|
|
text={getAssetJobName(AssetJobName.RegenerateThumbnail)}
|
|
/>
|
|
{#if asset.type === AssetTypeEnum.Video}
|
|
<MenuOption
|
|
icon={mdiCogRefreshOutline}
|
|
on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
|
|
text={getAssetJobName(AssetJobName.TranscodeVideo)}
|
|
/>
|
|
{/if}
|
|
{/if}
|
|
</ButtonContextMenu>
|
|
{/if}
|
|
</div>
|
|
</div>
|