mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 07:22:31 -04:00
Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job
This commit is contained in:
@@ -5,20 +5,31 @@
|
||||
import { getAssetJobName } from '$lib/utils';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { getContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { AssetJobName, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetJobName, AssetTypeEnum, type AssetResponseDto, type AlbumResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiAccountCircleOutline,
|
||||
mdiAlertOutline,
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiArchiveArrowUpOutline,
|
||||
mdiArrowLeft,
|
||||
mdiCogRefreshOutline,
|
||||
mdiContentCopy,
|
||||
mdiDatabaseRefreshOutline,
|
||||
mdiDeleteOutline,
|
||||
mdiDotsVertical,
|
||||
mdiFolderDownloadOutline,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
mdiImageAlbum,
|
||||
mdiImageMinusOutline,
|
||||
mdiImageOutline,
|
||||
mdiImageRefreshOutline,
|
||||
mdiInformationOutline,
|
||||
mdiMagnifyMinusOutline,
|
||||
mdiMagnifyPlusOutline,
|
||||
mdiMotionPauseOutline,
|
||||
mdiPlaySpeed,
|
||||
mdiPresentationPlay,
|
||||
mdiShareVariantOutline,
|
||||
} from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
@@ -26,6 +37,7 @@
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
export let showCopyButton: boolean;
|
||||
export let showZoomButton: boolean;
|
||||
export let showMotionPlayButton: boolean;
|
||||
@@ -42,6 +54,7 @@
|
||||
| 'addToAlbum'
|
||||
| 'addToSharedAlbum'
|
||||
| 'asProfileImage'
|
||||
| 'setAsAlbumCover'
|
||||
| 'download'
|
||||
| 'playSlideShow'
|
||||
| 'runJob'
|
||||
@@ -59,6 +72,7 @@
|
||||
addToAlbum: void;
|
||||
addToSharedAlbum: void;
|
||||
asProfileImage: void;
|
||||
setAsAlbumCover: void;
|
||||
runJob: AssetJobName;
|
||||
playSlideShow: void;
|
||||
unstack: void;
|
||||
@@ -173,37 +187,55 @@
|
||||
{#if isShowAssetOptions}
|
||||
<ContextMenu {...contextMenuPosition} direction="left">
|
||||
{#if showSlideshow}
|
||||
<MenuOption on:click={() => onMenuClick('playSlideShow')} text="Slideshow" />
|
||||
<MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text="Slideshow" />
|
||||
{/if}
|
||||
{#if showDownloadButton}
|
||||
<MenuOption on:click={() => onMenuClick('download')} text="Download" />
|
||||
<MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text="Download" />
|
||||
{/if}
|
||||
<MenuOption on:click={() => onMenuClick('addToAlbum')} text="Add to Album" />
|
||||
<MenuOption on:click={() => onMenuClick('addToSharedAlbum')} text="Add to Shared Album" />
|
||||
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" />
|
||||
<MenuOption
|
||||
icon={mdiShareVariantOutline}
|
||||
on:click={() => onMenuClick('addToSharedAlbum')}
|
||||
text="Add to shared album"
|
||||
/>
|
||||
|
||||
{#if isOwner}
|
||||
{#if hasStackChildren}
|
||||
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text="Un-stack" />
|
||||
{/if}
|
||||
{#if album}
|
||||
<MenuOption
|
||||
text="Set as album cover"
|
||||
icon={mdiImageOutline}
|
||||
on:click={() => onMenuClick('setAsAlbumCover')}
|
||||
/>
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<MenuOption
|
||||
icon={mdiAccountCircleOutline}
|
||||
on:click={() => onMenuClick('asProfileImage')}
|
||||
text="Set as profile picture"
|
||||
/>
|
||||
{/if}
|
||||
<MenuOption
|
||||
on:click={() => dispatch('toggleArchive')}
|
||||
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
|
||||
text={asset.isArchived ? 'Unarchive' : 'Archive'}
|
||||
/>
|
||||
{#if asset.type === AssetTypeEnum.Image}
|
||||
<MenuOption on:click={() => onMenuClick('asProfileImage')} text="As profile picture" />
|
||||
{/if}
|
||||
|
||||
{#if hasStackChildren}
|
||||
<MenuOption on:click={() => onMenuClick('unstack')} text="Un-Stack" />
|
||||
{/if}
|
||||
|
||||
<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)}
|
||||
/>
|
||||
|
||||
@@ -97,6 +97,9 @@
|
||||
let isShowActivity = false;
|
||||
let isLiked: ActivityResponseDto | null = null;
|
||||
let numberOfComments: number;
|
||||
let fullscreenElement: Element;
|
||||
|
||||
$: isFullScreen = fullscreenElement !== null;
|
||||
|
||||
$: {
|
||||
if (asset.stackCount && asset.stack) {
|
||||
@@ -512,6 +515,8 @@
|
||||
]}
|
||||
/>
|
||||
|
||||
<svelte:document bind:fullscreenElement />
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
@@ -562,6 +567,8 @@
|
||||
{#if $slideshowState != SlideshowState.None}
|
||||
<div class="z-[1000] absolute w-full flex">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
onSetToFullScreen={() => assetViewerHtmlElement.requestFullscreen()}
|
||||
onPrevious={() => navigateAsset('previous')}
|
||||
onNext={() => navigateAsset('next')}
|
||||
onClose={() => ($slideshowState = SlideshowState.StopSlideshow)}
|
||||
|
||||
@@ -3,23 +3,46 @@
|
||||
import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
|
||||
import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
|
||||
import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
export let isFullScreen: boolean;
|
||||
export let onNext = () => {};
|
||||
export let onPrevious = () => {};
|
||||
export let onClose = () => {};
|
||||
export let onSetToFullScreen = () => {};
|
||||
|
||||
const { restartProgress, stopProgress, slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore;
|
||||
|
||||
let progressBarStatus: ProgressBarStatus;
|
||||
let progressBar: ProgressBar;
|
||||
let showSettings = false;
|
||||
let showControls = true;
|
||||
let timer: NodeJS.Timeout;
|
||||
let isOverControls = false;
|
||||
|
||||
let unsubscribeRestart: () => void;
|
||||
let unsubscribeStop: () => void;
|
||||
|
||||
const resetTimer = () => {
|
||||
clearTimeout(timer);
|
||||
document.body.style.cursor = '';
|
||||
showControls = true;
|
||||
startTimer();
|
||||
};
|
||||
|
||||
const startTimer = () => {
|
||||
timer = setTimeout(() => {
|
||||
if (!isOverControls) {
|
||||
showControls = false;
|
||||
document.body.style.cursor = 'none';
|
||||
}
|
||||
}, 10_000);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
startTimer();
|
||||
unsubscribeRestart = restartProgress.subscribe((value) => {
|
||||
if (value) {
|
||||
progressBar.restart(value);
|
||||
@@ -52,19 +75,37 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="m-4 flex gap-2">
|
||||
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title="Exit Slideshow" />
|
||||
<CircleIconButton
|
||||
buttonSize="50"
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
|
||||
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
|
||||
/>
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title="Previous" />
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title="Next" />
|
||||
<CircleIconButton buttonSize="50" icon={mdiCog} on:click={() => (showSettings = !showSettings)} title="Next" />
|
||||
</div>
|
||||
<svelte:window on:mousemove={resetTimer} />
|
||||
|
||||
{#if showControls}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="m-4 flex gap-2"
|
||||
on:mouseenter={() => (isOverControls = true)}
|
||||
on:mouseleave={() => (isOverControls = false)}
|
||||
transition:fly={{ duration: 150 }}
|
||||
>
|
||||
<CircleIconButton buttonSize="50" icon={mdiClose} on:click={onClose} title="Exit Slideshow" />
|
||||
|
||||
<CircleIconButton
|
||||
buttonSize="50"
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
|
||||
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
|
||||
/>
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={onPrevious} title="Previous" />
|
||||
<CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={onNext} title="Next" />
|
||||
<CircleIconButton buttonSize="50" icon={mdiCog} on:click={() => (showSettings = !showSettings)} title="Next" />
|
||||
{#if !isFullScreen}
|
||||
<CircleIconButton
|
||||
buttonSize="50"
|
||||
icon={mdiFullscreen}
|
||||
on:click={onSetToFullScreen}
|
||||
title="Set Slideshow to fullscreen"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if showSettings}
|
||||
<SlideshowSettings onClose={() => (showSettings = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
|
||||
export let assetId: string;
|
||||
|
||||
let element: HTMLVideoElement | undefined = undefined;
|
||||
let isVideoLoading = true;
|
||||
|
||||
const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
|
||||
|
||||
const handleCanPlay = async (event: Event) => {
|
||||
@@ -29,6 +31,7 @@
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<video
|
||||
bind:this={element}
|
||||
autoplay
|
||||
playsinline
|
||||
controls
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import {
|
||||
mdiAccountEditOutline,
|
||||
mdiAccountMultipleCheckOutline,
|
||||
mdiCalendarEditOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
} from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import IconButton from '../elements/buttons/icon-button.svelte';
|
||||
@@ -83,10 +89,18 @@
|
||||
{#if showContextMenu}
|
||||
<Portal target="body">
|
||||
<ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
|
||||
<MenuOption on:click={() => onMenuClick('hide-person')} text="Hide Person" />
|
||||
<MenuOption on:click={() => onMenuClick('change-name')} text="Change name" />
|
||||
<MenuOption on:click={() => onMenuClick('set-birth-date')} text="Set date of birth" />
|
||||
<MenuOption on:click={() => onMenuClick('merge-people')} text="Merge People" />
|
||||
<MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text="Hide person" />
|
||||
<MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text="Change name" />
|
||||
<MenuOption
|
||||
on:click={() => onMenuClick('set-birth-date')}
|
||||
icon={mdiCalendarEditOutline}
|
||||
text="Set date of birth"
|
||||
/>
|
||||
<MenuOption
|
||||
on:click={() => onMenuClick('merge-people')}
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
text="Merge people"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { createAlbum, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { getMenuContext } from '../asset-select-context-menu.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
|
||||
export let shared = false;
|
||||
let showAlbumPicker = false;
|
||||
@@ -53,7 +54,11 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption on:click={() => (showAlbumPicker = true)} text={shared ? 'Add to Shared Album' : 'Add to Album'} />
|
||||
<MenuOption
|
||||
on:click={() => (showAlbumPicker = true)}
|
||||
text={shared ? 'Add to shared album' : 'Add to album'}
|
||||
icon={shared ? mdiShareVariantOutline : mdiImageAlbum}
|
||||
/>
|
||||
|
||||
{#if showAlbumPicker}
|
||||
<AlbumSelectionModal
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption {text} on:click={handleArchive} />
|
||||
<MenuOption {text} {icon} on:click={handleArchive} />
|
||||
{/if}
|
||||
|
||||
{#if !menuItem}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAssetJobMessage, getAssetJobName } from '$lib/utils';
|
||||
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetJobName, AssetTypeEnum, runAssetJobs } from '@immich/sdk';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
@@ -33,6 +33,6 @@
|
||||
|
||||
{#each jobs as job}
|
||||
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
|
||||
<MenuOption text={getAssetJobName(job)} on:click={() => handleRunJob(job)} />
|
||||
<MenuOption text={getAssetJobName(job)} icon={getAssetJobIcon(job)} on:click={() => handleRunJob(job)} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||
export let menuItem = false;
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
@@ -26,7 +27,7 @@
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text="Change date" on:click={() => (isShowChangeDate = true)} />
|
||||
<MenuOption text="Change date" icon={mdiCalendarEditOutline} on:click={() => (isShowChangeDate = true)} />
|
||||
{/if}
|
||||
{#if isShowChangeDate}
|
||||
<ChangeDate
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
|
||||
|
||||
export let menuItem = false;
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
@@ -26,7 +27,11 @@
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text="Change location" on:click={() => (isShowChangeLocation = true)} />
|
||||
<MenuOption
|
||||
text="Change location"
|
||||
icon={mdiMapMarkerMultipleOutline}
|
||||
on:click={() => (isShowChangeLocation = true)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isShowChangeLocation}
|
||||
<ChangeLocation
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
let isShowConfirmation = false;
|
||||
let loading = false;
|
||||
|
||||
$: label = force ? 'Permanently delete' : 'Delete';
|
||||
|
||||
const handleTrash = async () => {
|
||||
if (force) {
|
||||
isShowConfirmation = true;
|
||||
@@ -46,11 +48,11 @@
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={force ? 'Permanently Delete' : 'Delete'} on:click={handleTrash} />
|
||||
<MenuOption text={label} icon={mdiDeleteOutline} on:click={handleTrash} />
|
||||
{:else if loading}
|
||||
<CircleIconButton title="Loading" icon={mdiTimerSand} />
|
||||
{:else}
|
||||
<CircleIconButton title="Delete" icon={mdiDeleteOutline} on:click={handleTrash} />
|
||||
<CircleIconButton title={label} icon={mdiDeleteOutline} on:click={handleTrash} />
|
||||
{/if}
|
||||
|
||||
{#if isShowConfirmation}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { mdiCloudDownloadOutline } from '@mdi/js';
|
||||
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||
|
||||
export let filename = 'immich.zip';
|
||||
export let menuItem = false;
|
||||
@@ -21,10 +21,12 @@
|
||||
clearSelect();
|
||||
await downloadArchive(filename, { assetIds: assets.map((asset) => asset.id) });
|
||||
};
|
||||
|
||||
$: menuItemIcon = getAssets().size === 1 ? mdiFileDownloadOutline : mdiFolderDownloadOutline;
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text="Download" on:click={handleDownloadFiles} />
|
||||
<MenuOption text="Download" icon={menuItemIcon} on:click={handleDownloadFiles} />
|
||||
{:else}
|
||||
<CircleIconButton title="Download" icon={mdiCloudDownloadOutline} on:click={handleDownloadFiles} />
|
||||
{/if}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
export let menuItem = false;
|
||||
export let removeFavorite: boolean;
|
||||
|
||||
$: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
|
||||
$: text = removeFavorite ? 'Remove from favorites' : 'Favorite';
|
||||
$: icon = removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline;
|
||||
|
||||
let loading = false;
|
||||
@@ -57,7 +57,7 @@
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption {text} on:click={handleFavorite} />
|
||||
<MenuOption {text} {icon} on:click={handleFavorite} />
|
||||
{/if}
|
||||
|
||||
{#if !menuItem}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { getAlbumInfo, removeAssetFromAlbum, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiDeleteOutline } from '@mdi/js';
|
||||
import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text="Remove from album" on:click={() => (isShowConfirmation = true)} />
|
||||
<MenuOption text="Remove from album" icon={mdiImageRemoveOutline} on:click={() => (isShowConfirmation = true)} />
|
||||
{:else}
|
||||
<CircleIconButton title="Remove from album" icon={mdiDeleteOutline} on:click={() => (isShowConfirmation = true)} />
|
||||
{/if}
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
<script lang="ts">
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { BucketPosition, type AssetStore, isSelectAllCancelled } from '$lib/stores/assets.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { get } from 'svelte/store';
|
||||
import { mdiTimerSand, mdiSelectAll } from '@mdi/js';
|
||||
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets.store';
|
||||
import { mdiSelectAll, mdiTimerSand } from '@mdi/js';
|
||||
import { selectAllAssets } from '$lib/utils/asset-utils';
|
||||
|
||||
export let assetStore: AssetStore;
|
||||
export let assetInteractionStore: AssetInteractionStore;
|
||||
|
||||
let selecting = false;
|
||||
|
||||
const handleSelectAll = async () => {
|
||||
try {
|
||||
$isSelectAllCancelled = false;
|
||||
selecting = true;
|
||||
await selectAllAssets(assetStore, assetInteractionStore);
|
||||
};
|
||||
|
||||
const assetGridState = get(assetStore);
|
||||
for (const bucket of assetGridState.buckets) {
|
||||
if ($isSelectAllCancelled) {
|
||||
break;
|
||||
}
|
||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||
for (const asset of bucket.assets) {
|
||||
assetInteractionStore.selectAsset(asset);
|
||||
}
|
||||
}
|
||||
|
||||
selecting = false;
|
||||
} catch (error) {
|
||||
handleError(error, 'Error selecting all assets');
|
||||
}
|
||||
const handleCancel = () => {
|
||||
$isSelectingAllAssets = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if selecting}
|
||||
<CircleIconButton title="Delete" icon={mdiTimerSand} />
|
||||
{/if}
|
||||
{#if !selecting}
|
||||
{#if $isSelectingAllAssets}
|
||||
<CircleIconButton title="Cancel" icon={mdiTimerSand} on:click={handleCancel} />
|
||||
{:else}
|
||||
<CircleIconButton title="Select all" icon={mdiSelectAll} on:click={handleSelectAll} />
|
||||
{/if}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { mdiImageMultipleOutline } from '@mdi/js';
|
||||
|
||||
export let onStack: OnStack | undefined;
|
||||
|
||||
@@ -55,4 +56,4 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption text="Stack" on:click={handleStack} />
|
||||
<MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} />
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { BucketPosition, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
||||
import { BucketPosition, isSelectingAllAssets, type AssetStore, type Viewport } from '$lib/stores/assets.store';
|
||||
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { deleteAssets } from '$lib/utils/actions';
|
||||
import { shortcuts, type ShortcutOptions, matchesShortcut } from '$lib/utils/shortcut';
|
||||
import { type ShortcutOptions, shortcuts } from '$lib/utils/shortcut';
|
||||
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
|
||||
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -20,6 +20,7 @@
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { selectAllAssets } from '$lib/utils/asset-utils';
|
||||
|
||||
export let isSelectionMode = false;
|
||||
export let singleSelect = false;
|
||||
@@ -93,12 +94,14 @@
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: () => dispatch('escape') },
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
|
||||
];
|
||||
|
||||
if ($isMultiSelectState) {
|
||||
shortcuts.push(
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,12 +205,17 @@
|
||||
|
||||
let shiftKeyIsDown = false;
|
||||
|
||||
const deselectAllAssets = () => {
|
||||
$isSelectingAllAssets = false;
|
||||
assetInteractionStore.clearMultiselect();
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ($isSearchEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(event, { key: 'Shift', shift: true })) {
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = true;
|
||||
}
|
||||
@@ -218,7 +226,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(event, { key: 'Shift', shift: false })) {
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
shiftKeyIsDown = false;
|
||||
}
|
||||
@@ -380,7 +388,7 @@
|
||||
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
|
||||
<section
|
||||
id="asset-grid"
|
||||
class="scrollbar-hidden h-full overflow-y-auto pb-[60px] {isEmpty ? 'm-0' : 'mr-[60px]'}"
|
||||
class="scrollbar-hidden h-full overflow-y-auto pb-[60px] {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
bind:this={element}
|
||||
|
||||
@@ -29,9 +29,7 @@
|
||||
<CircleIconButton {title} {icon} on:click={handleShowMenu} />
|
||||
{#if showContextMenu}
|
||||
<ContextMenu {...contextMenuPosition}>
|
||||
<div class="flex flex-col rounded-lg">
|
||||
<slot />
|
||||
</div>
|
||||
<slot />
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
import { shortcuts } from '$lib/utils/shortcut';
|
||||
import { clickOutside } from '$lib/utils/click-outside';
|
||||
import { focusOutside } from '$lib/utils/focus-outside';
|
||||
|
||||
/**
|
||||
* Unique identifier for the combobox.
|
||||
@@ -40,6 +41,7 @@
|
||||
let searchQuery = selectedOption?.label || '';
|
||||
let selectedIndex: number | undefined;
|
||||
let optionRefs: HTMLElement[] = [];
|
||||
let input: HTMLInputElement;
|
||||
const inputId = `combobox-${id}`;
|
||||
const listboxId = `listbox-${id}`;
|
||||
|
||||
@@ -51,7 +53,6 @@
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
select: ComboBoxOption | undefined;
|
||||
click: void;
|
||||
}>();
|
||||
|
||||
const activate = () => {
|
||||
@@ -101,6 +102,8 @@
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
input?.focus();
|
||||
selectedIndex = undefined;
|
||||
selectedOption = undefined;
|
||||
searchQuery = '';
|
||||
dispatch('select', selectedOption);
|
||||
@@ -111,11 +114,16 @@
|
||||
<div
|
||||
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||
use:clickOutside={{ onOutclick: deactivate }}
|
||||
on:focusout={(e) => {
|
||||
if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) {
|
||||
deactivate();
|
||||
}
|
||||
}}
|
||||
use:focusOutside={{ onFocusOut: deactivate }}
|
||||
use:shortcuts={[
|
||||
{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: (event) => {
|
||||
event.stopPropagation();
|
||||
closeDropdown();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
{#if isActive}
|
||||
@@ -133,6 +141,7 @@
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={isOpen}
|
||||
autocomplete="off"
|
||||
bind:this={input}
|
||||
class:!pl-8={isActive}
|
||||
class:!rounded-b-none={isOpen}
|
||||
class:cursor-pointer={!isActive}
|
||||
@@ -213,9 +222,7 @@
|
||||
role="option"
|
||||
aria-selected={selectedIndex === 0}
|
||||
aria-disabled={true}
|
||||
class:bg-gray-100={selectedIndex === 0}
|
||||
class:dark:bg-gray-700={selectedIndex === 0}
|
||||
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default"
|
||||
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700"
|
||||
id={`${listboxId}-${0}`}
|
||||
on:click={() => closeDropdown()}
|
||||
>
|
||||
|
||||
@@ -23,12 +23,14 @@
|
||||
<div
|
||||
transition:slide={{ duration: 200, easing: quintOut }}
|
||||
bind:this={menuElement}
|
||||
class="absolute z-10 w-[200px] 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="left: {left}px; top: {top}px;"
|
||||
role="menu"
|
||||
use:clickOutside
|
||||
on:outclick
|
||||
on:escape
|
||||
>
|
||||
<slot />
|
||||
<div class="flex flex-col rounded-lg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script>
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
|
||||
export let text = '';
|
||||
export let subtitle = '';
|
||||
export let icon = '';
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -9,7 +12,14 @@
|
||||
role="menuitem"
|
||||
>
|
||||
{#if text}
|
||||
{text}
|
||||
{#if icon}
|
||||
<p class="flex gap-2">
|
||||
<Icon path={icon} size="18" />
|
||||
{text}
|
||||
</p>
|
||||
{:else}
|
||||
{text}
|
||||
{/if}
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { isSelectAllCancelled } from '$lib/stores/assets.store';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets.store';
|
||||
|
||||
export let showBackButton = true;
|
||||
export let backIcon = mdiClose;
|
||||
@@ -31,7 +31,7 @@
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
$isSelectAllCancelled = true;
|
||||
$isSelectingAllAssets = false;
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<ImmichLogo width="55%" noText={innerWidth < 768} />
|
||||
</a>
|
||||
<div class="flex justify-between gap-16 pr-6">
|
||||
<div class="hidden w-full max-w-5xl flex-1 sm:block">
|
||||
<div class="hidden w-full max-w-5xl flex-1 pl-4 tall:pl-0 sm:block">
|
||||
{#if $featureFlags.search}
|
||||
<SearchBar grayTheme={true} />
|
||||
{/if}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ describe('NotificationCard component', () => {
|
||||
vi.spyOn(window, 'clearTimeout');
|
||||
|
||||
sut = render(NotificationCard, {
|
||||
notificationInfo: {
|
||||
notification: {
|
||||
id: 1234,
|
||||
message: 'Notification message',
|
||||
timeout: 1000,
|
||||
@@ -25,7 +25,7 @@ describe('NotificationCard component', () => {
|
||||
|
||||
it('shows message and title', () => {
|
||||
sut = render(NotificationCard, {
|
||||
notificationInfo: {
|
||||
notification: {
|
||||
id: 1234,
|
||||
message: 'Notification message',
|
||||
timeout: 1000,
|
||||
|
||||
+9
-10
@@ -12,11 +12,14 @@ describe('NotificationList component', () => {
|
||||
const sut: RenderResult<NotificationList> = render(NotificationList);
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
// https://testing-library.com/docs/svelte-testing-library/faq#why-arent-transition-events-running
|
||||
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
|
||||
setTimeout(() => fn(Date.now()), 16);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows a notification when added and closes it automatically after the delay timeout', async () => {
|
||||
@@ -25,18 +28,14 @@ describe('NotificationList component', () => {
|
||||
notificationController.show({
|
||||
message: 'Notification',
|
||||
type: NotificationType.Info,
|
||||
timeout: 3000,
|
||||
timeout: 1,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).toBeInTheDocument());
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)?.children).toHaveLength(1));
|
||||
expect(get(notificationController.notificationList)).toHaveLength(1);
|
||||
|
||||
expect(_getNotificationListElement(sut)?.children).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(4000);
|
||||
// due to some weirdness in svelte (or testing-library) need to check if it has been removed from the store to make sure it works.
|
||||
await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
|
||||
expect(get(notificationController.notificationList)).toHaveLength(0);
|
||||
|
||||
// TODO: investigate why this element is not removed from the DOM even notification list is in fact 0.
|
||||
// await waitFor(() => expect(_getNotificationListElement(sut)).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,77 +2,47 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import {
|
||||
ImmichNotification,
|
||||
type Notification,
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { onMount } from 'svelte';
|
||||
import { mdiCloseCircleOutline, mdiInformationOutline, mdiWindowClose } from '@mdi/js';
|
||||
|
||||
export let notificationInfo: ImmichNotification;
|
||||
export let notification: Notification;
|
||||
|
||||
let infoPrimaryColor = '#4250AF';
|
||||
let errorPrimaryColor = '#E64132';
|
||||
let warningPrimaryColor = '#D08613';
|
||||
$: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
|
||||
|
||||
$: icon = notificationInfo.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline;
|
||||
|
||||
$: backgroundColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '#E0E2F0';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '#FBE8E6';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Warning) {
|
||||
return '#FFF6DC';
|
||||
}
|
||||
const backgroundColor: Record<NotificationType, string> = {
|
||||
[NotificationType.Info]: '#E0E2F0',
|
||||
[NotificationType.Error]: '#FBE8E6',
|
||||
[NotificationType.Warning]: '#FFF6DC',
|
||||
};
|
||||
|
||||
$: borderStyle = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return '1px solid #D8DDFF';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return '1px solid #F0E8E7';
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Warning) {
|
||||
return '1px solid #FFE6A5';
|
||||
}
|
||||
const borderColor: Record<NotificationType, string> = {
|
||||
[NotificationType.Info]: '#D8DDFF',
|
||||
[NotificationType.Error]: '#F0E8E7',
|
||||
[NotificationType.Warning]: '#FFE6A5',
|
||||
};
|
||||
|
||||
$: primaryColor = () => {
|
||||
if (notificationInfo.type === NotificationType.Info) {
|
||||
return infoPrimaryColor;
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Error) {
|
||||
return errorPrimaryColor;
|
||||
}
|
||||
|
||||
if (notificationInfo.type === NotificationType.Warning) {
|
||||
return warningPrimaryColor;
|
||||
}
|
||||
const primaryColor: Record<NotificationType, string> = {
|
||||
[NotificationType.Info]: '#4250AF',
|
||||
[NotificationType.Error]: '#E64132',
|
||||
[NotificationType.Warning]: '#D08613',
|
||||
};
|
||||
|
||||
let removeNotificationTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
removeNotificationTimeout = setTimeout(discard, notificationInfo.timeout);
|
||||
return () => clearTimeout(removeNotificationTimeout);
|
||||
const timeoutId = setTimeout(discard, notification.timeout);
|
||||
return () => clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
const discard = () => {
|
||||
notificationController.removeNotificationById(notificationInfo.id);
|
||||
notificationController.removeNotificationById(notification.id);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
const action = notificationInfo.action;
|
||||
if (action.type == 'discard') {
|
||||
const action = notification.action;
|
||||
if (action.type === 'discard') {
|
||||
discard();
|
||||
} else if (action.type == 'link') {
|
||||
window.open(action.target);
|
||||
@@ -83,17 +53,17 @@
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
transition:fade={{ duration: 250 }}
|
||||
style:background-color={backgroundColor()}
|
||||
style:border={borderStyle()}
|
||||
class="z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md hover:cursor-pointer"
|
||||
style:background-color={backgroundColor[notification.type]}
|
||||
style:border-color={borderColor[notification.type]}
|
||||
class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md hover:cursor-pointer"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleClick}
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<Icon path={icon} color={primaryColor()} size="20" />
|
||||
<h2 style:color={primaryColor()} class="font-medium" data-testid="title">
|
||||
{notificationInfo.type.toString()}
|
||||
<Icon path={icon} color={primaryColor[notification.type]} size="20" />
|
||||
<h2 style:color={primaryColor[notification.type]} class="font-medium" data-testid="title">
|
||||
{notification.type.toString()}
|
||||
</h2>
|
||||
</div>
|
||||
<button on:click|stopPropagation={discard}>
|
||||
@@ -102,6 +72,6 @@
|
||||
</div>
|
||||
|
||||
<p class="whitespace-pre-wrap pl-[28px] pr-[16px] text-sm" data-testid="message">
|
||||
{notificationInfo.message}
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
{#if $notificationList.length > 0}
|
||||
<section transition:fade={{ duration: 250 }} id="notification-list" class="fixed right-5 top-[80px] z-[99999999]">
|
||||
{#each $notificationList as notificationInfo (notificationInfo.id)}
|
||||
{#each $notificationList as notification (notification.id)}
|
||||
<div animate:flip={{ duration: 250, easing: quintOut }}>
|
||||
<NotificationCard {notificationInfo} />
|
||||
<NotificationCard {notification} />
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
@@ -6,57 +6,43 @@ export enum NotificationType {
|
||||
Warning = 'Warning',
|
||||
}
|
||||
|
||||
export class ImmichNotification {
|
||||
id = Date.now() + Math.random();
|
||||
type!: NotificationType;
|
||||
message!: string;
|
||||
action!: NotificationAction;
|
||||
timeout = 3000;
|
||||
}
|
||||
export type Notification = {
|
||||
id: number;
|
||||
type: NotificationType;
|
||||
message: string;
|
||||
/** The action to take when the notification is clicked */
|
||||
action: NotificationAction;
|
||||
/** Timeout in miliseconds */
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
type DiscardAction = { type: 'discard' };
|
||||
type NoopAction = { type: 'noop' };
|
||||
type LinkAction = { type: 'link'; target: string };
|
||||
export type NotificationAction = DiscardAction | NoopAction | LinkAction;
|
||||
|
||||
export class ImmichNotificationDto {
|
||||
/**
|
||||
* Notification type
|
||||
* @type {NotificationType} [Info, Error]
|
||||
*/
|
||||
type: NotificationType = NotificationType.Info;
|
||||
|
||||
/**
|
||||
* Notification message
|
||||
*/
|
||||
message = '';
|
||||
|
||||
/**
|
||||
* Timeout in miliseconds
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* The action to take when the notification is clicked
|
||||
*/
|
||||
action?: NotificationAction;
|
||||
}
|
||||
export type NotificationOptions = Partial<Exclude<Notification, 'id'>> & { message: string };
|
||||
|
||||
function createNotificationList() {
|
||||
const notificationList = writable<ImmichNotification[]>([]);
|
||||
const notificationList = writable<Notification[]>([]);
|
||||
let count = 1;
|
||||
|
||||
const show = (notificationInfo: ImmichNotificationDto) => {
|
||||
const newNotification = new ImmichNotification();
|
||||
newNotification.message = notificationInfo.message;
|
||||
newNotification.type = notificationInfo.type;
|
||||
newNotification.timeout = notificationInfo.timeout || 3000;
|
||||
newNotification.action = notificationInfo.action || { type: 'discard' };
|
||||
const show = (options: NotificationOptions) => {
|
||||
notificationList.update((currentList) => {
|
||||
currentList.push({
|
||||
id: count++,
|
||||
type: NotificationType.Info,
|
||||
action: { type: 'discard' },
|
||||
timeout: 3000,
|
||||
...options,
|
||||
});
|
||||
|
||||
notificationList.update((currentList) => [...currentList, newNotification]);
|
||||
return currentList;
|
||||
});
|
||||
};
|
||||
|
||||
const removeNotificationById = (id: number) => {
|
||||
notificationList.update((currentList) => currentList.filter((n) => n.id != id));
|
||||
notificationList.update((currentList) => currentList.filter((n) => n.id !== id));
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { shortcut } from '$lib/utils/shortcut';
|
||||
import { focusOutside } from '$lib/utils/focus-outside';
|
||||
|
||||
export let value = '';
|
||||
export let grayTheme: boolean;
|
||||
@@ -94,7 +95,7 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}>
|
||||
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }} use:focusOutside={{ onFocusOut }}>
|
||||
<form
|
||||
draggable="false"
|
||||
autocomplete="off"
|
||||
@@ -127,6 +128,7 @@
|
||||
bind:value
|
||||
bind:this={input}
|
||||
on:click={onFocusIn}
|
||||
on:focus={onFocusIn}
|
||||
disabled={showFilter}
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Escape' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { derived, writable } from 'svelte/store';
|
||||
|
||||
export interface AssetInteractionStore {
|
||||
selectAsset: (asset: AssetResponseDto) => void;
|
||||
selectAssets: (assets: AssetResponseDto[]) => void;
|
||||
removeAssetFromMultiselectGroup: (asset: AssetResponseDto) => void;
|
||||
addGroupToMultiselectGroup: (group: string) => void;
|
||||
removeGroupFromMultiselectGroup: (group: string) => void;
|
||||
@@ -76,6 +77,13 @@ export function createAssetInteractionStore(): AssetInteractionStore {
|
||||
selectedAssets.set(_selectedAssets);
|
||||
};
|
||||
|
||||
const selectAssets = (assets: AssetResponseDto[]) => {
|
||||
for (const asset of assets) {
|
||||
_selectedAssets.add(asset);
|
||||
}
|
||||
selectedAssets.set(_selectedAssets);
|
||||
};
|
||||
|
||||
const removeAssetFromMultiselectGroup = (asset: AssetResponseDto) => {
|
||||
_selectedAssets.delete(asset);
|
||||
selectedAssets.set(_selectedAssets);
|
||||
@@ -123,6 +131,7 @@ export function createAssetInteractionStore(): AssetInteractionStore {
|
||||
|
||||
return {
|
||||
selectAsset,
|
||||
selectAssets,
|
||||
removeAssetFromMultiselectGroup,
|
||||
addGroupToMultiselectGroup,
|
||||
removeGroupFromMultiselectGroup,
|
||||
|
||||
@@ -519,4 +519,4 @@ export class AssetStore {
|
||||
}
|
||||
}
|
||||
|
||||
export const isSelectAllCancelled = writable(false);
|
||||
export const isSelectingAllAssets = writable(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
unlinkOAuthAccount,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiImageRefreshOutline } from '@mdi/js';
|
||||
|
||||
interface DownloadRequestOptions<T = unknown> {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
@@ -196,6 +197,16 @@ export const getAssetJobMessage = (job: AssetJobName) => {
|
||||
return messages[job];
|
||||
};
|
||||
|
||||
export const getAssetJobIcon = (job: AssetJobName) => {
|
||||
const names: Record<AssetJobName, string> = {
|
||||
[AssetJobName.RefreshMetadata]: mdiDatabaseRefreshOutline,
|
||||
[AssetJobName.RegenerateThumbnail]: mdiImageRefreshOutline,
|
||||
[AssetJobName.TranscodeVideo]: mdiCogRefreshOutline,
|
||||
};
|
||||
|
||||
return names[job];
|
||||
};
|
||||
|
||||
export const copyToClipboard = async (secret: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
|
||||
import { downloadManager } from '$lib/stores/download';
|
||||
import { downloadRequest, getKey } from '$lib/utils';
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
import { handleError } from './handle-error';
|
||||
|
||||
export const addAssetsToAlbum = async (albumId: string, assetIds: Array<string>): Promise<BulkIdResponseDto[]> =>
|
||||
@@ -224,6 +227,35 @@ export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserRespo
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => {
|
||||
if (get(isSelectingAllAssets)) {
|
||||
// Selection is already ongoing
|
||||
return;
|
||||
}
|
||||
isSelectingAllAssets.set(true);
|
||||
|
||||
try {
|
||||
for (const bucket of assetStore.buckets) {
|
||||
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
|
||||
|
||||
if (!get(isSelectingAllAssets)) {
|
||||
break; // Cancelled
|
||||
}
|
||||
assetInteractionStore.selectAssets(bucket.assets);
|
||||
|
||||
// We use setTimeout to allow the UI to update. Otherwise, this may
|
||||
// cause a long delay between the start of 'select all' and the
|
||||
// effective update of the UI, depending on the number of assets
|
||||
// to select
|
||||
await delay(0);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, 'Error selecting all assets');
|
||||
} finally {
|
||||
isSelectingAllAssets.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
export const delay = async (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
interface Options {
|
||||
onFocusOut?: () => void;
|
||||
}
|
||||
|
||||
export function focusOutside(node: HTMLElement, options: Options = {}) {
|
||||
const { onFocusOut } = options;
|
||||
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) {
|
||||
onFocusOut();
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('focusout', handleFocusOut);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -64,11 +64,14 @@
|
||||
mdiArrowLeft,
|
||||
mdiDeleteOutline,
|
||||
mdiDotsVertical,
|
||||
mdiFileImagePlusOutline,
|
||||
mdiFolderDownloadOutline,
|
||||
mdiLink,
|
||||
mdiPlus,
|
||||
mdiShareVariantOutline,
|
||||
mdiPresentationPlay,
|
||||
mdiCogOutline,
|
||||
mdiImageOutline,
|
||||
mdiImagePlusOutline,
|
||||
} from '@mdi/js';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { PageData } from './$types';
|
||||
@@ -385,23 +388,25 @@
|
||||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add">
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add to...">
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
{#if isAllUserOwned}
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
{/if}
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
|
||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||
{#if isAllUserOwned}
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={() => assetStore.triggerUpdate()} />
|
||||
{/if}
|
||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||
{#if isOwned || isAllUserOwned}
|
||||
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
|
||||
{/if}
|
||||
{#if isAllUserOwned}
|
||||
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
{/if}
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
@@ -410,9 +415,9 @@
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}>
|
||||
<svelte:fragment slot="trailing">
|
||||
<CircleIconButton
|
||||
title="Add Photos"
|
||||
title="Add photos"
|
||||
on:click={() => (viewMode = ViewMode.SELECT_ASSETS)}
|
||||
icon={mdiFileImagePlusOutline}
|
||||
icon={mdiImagePlusOutline}
|
||||
/>
|
||||
|
||||
{#if isOwned}
|
||||
@@ -421,11 +426,6 @@
|
||||
on:click={() => (viewMode = ViewMode.SELECT_USERS)}
|
||||
icon={mdiShareVariantOutline}
|
||||
/>
|
||||
<CircleIconButton
|
||||
title="Delete album"
|
||||
on:click={() => (viewMode = ViewMode.CONFIRM_DELETE)}
|
||||
icon={mdiDeleteOutline}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if album.assetCount > 0}
|
||||
@@ -436,9 +436,22 @@
|
||||
<CircleIconButton title="Album options" on:click={handleOpenAlbumOptions} icon={mdiDotsVertical}>
|
||||
{#if viewMode === ViewMode.ALBUM_OPTIONS}
|
||||
<ContextMenu {...contextMenuPosition}>
|
||||
<MenuOption on:click={handleStartSlideshow} text="Slideshow" />
|
||||
<MenuOption on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" />
|
||||
<MenuOption on:click={() => (viewMode = ViewMode.OPTIONS)} text="Options" />
|
||||
<MenuOption icon={mdiPresentationPlay} text="Slideshow" on:click={handleStartSlideshow} />
|
||||
<MenuOption
|
||||
icon={mdiImageOutline}
|
||||
text="Select album cover"
|
||||
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiCogOutline}
|
||||
text="Options"
|
||||
on:click={() => (viewMode = ViewMode.OPTIONS)}
|
||||
/>
|
||||
<MenuOption
|
||||
icon={mdiDeleteOutline}
|
||||
text="Delete album"
|
||||
on:click={() => (viewMode = ViewMode.CONFIRM_DELETE)}
|
||||
/>
|
||||
</ContextMenu>
|
||||
{/if}
|
||||
</CircleIconButton>
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
<ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add">
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add to...">
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
<DeleteAssets onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
|
||||
<DownloadAction menuItem />
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
@@ -34,16 +34,16 @@
|
||||
<FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add">
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add to...">
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
<DeleteAssets onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
|
||||
<DownloadAction menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
@@ -44,7 +44,16 @@
|
||||
type AssetResponseDto,
|
||||
type PersonResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { mdiArrowLeft, mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import {
|
||||
mdiAccountBoxOutline,
|
||||
mdiAccountMultipleCheckOutline,
|
||||
mdiArrowLeft,
|
||||
mdiCalendarEditOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiEyeOutline,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { listNavigation } from '$lib/utils/list-navigation';
|
||||
@@ -395,18 +404,18 @@
|
||||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
|
||||
<CreateSharedLink />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add">
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add to...">
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
<DeleteAssets onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
|
||||
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
|
||||
<MenuOption text="Fix incorrect match" on:click={handleReassignAssets} />
|
||||
<MenuOption icon={mdiAccountMultipleCheckOutline} text="Fix incorrect match" on:click={handleReassignAssets} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
|
||||
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
@@ -414,13 +423,26 @@
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}>
|
||||
<svelte:fragment slot="trailing">
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
|
||||
<MenuOption text="Change feature photo" on:click={() => (viewMode = ViewMode.SELECT_PERSON)} />
|
||||
<MenuOption text="Set date of birth" on:click={() => (viewMode = ViewMode.BIRTH_DATE)} />
|
||||
<MenuOption text="Merge person" on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)} />
|
||||
<MenuOption
|
||||
text="Select featured photo"
|
||||
icon={mdiAccountBoxOutline}
|
||||
on:click={() => (viewMode = ViewMode.SELECT_PERSON)}
|
||||
/>
|
||||
<MenuOption
|
||||
text={data.person.isHidden ? 'Unhide person' : 'Hide person'}
|
||||
icon={data.person.isHidden ? mdiEyeOutline : mdiEyeOffOutline}
|
||||
on:click={() => toggleHidePerson()}
|
||||
/>
|
||||
<MenuOption
|
||||
text="Set date of birth"
|
||||
icon={mdiCalendarEditOutline}
|
||||
on:click={() => (viewMode = ViewMode.BIRTH_DATE)}
|
||||
/>
|
||||
<MenuOption
|
||||
text="Merge people"
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)}
|
||||
/>
|
||||
</AssetSelectContextMenu>
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
@@ -428,13 +450,13 @@
|
||||
|
||||
{#if viewMode === ViewMode.SELECT_PERSON}
|
||||
<ControlAppBar on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}>
|
||||
<svelte:fragment slot="leading">Select feature photo</svelte:fragment>
|
||||
<svelte:fragment slot="leading">Select featured photo</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<main class="relative h-screen overflow-hidden bg-immich-bg ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
||||
<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
||||
{#key refreshAssetGrid}
|
||||
<AssetGrid
|
||||
{assetStore}
|
||||
|
||||
@@ -55,23 +55,25 @@
|
||||
>
|
||||
<CreateSharedLink on:escape={() => (handleEscapeKey = true)} />
|
||||
<SelectAllAssets {assetStore} {assetInteractionStore} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add">
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add to...">
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
<DeleteAssets
|
||||
on:escape={() => (handleEscapeKey = true)}
|
||||
onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)}
|
||||
/>
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<DownloadAction menuItem />
|
||||
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
{#if $selectedAssets.size > 1}
|
||||
<StackAction onStack={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
on:escape={() => (handleEscapeKey = true)}
|
||||
onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)}
|
||||
/>
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
||||
@@ -209,18 +209,18 @@
|
||||
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
|
||||
<CreateSharedLink />
|
||||
<CircleIconButton title="Select all" icon={mdiSelectAll} on:click={handleSelectAll} />
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add">
|
||||
<AssetSelectContextMenu icon={mdiPlus} title="Add to...">
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</AssetSelectContextMenu>
|
||||
<DeleteAssets {onAssetDelete} />
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
|
||||
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
|
||||
<DownloadAction menuItem />
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
|
||||
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
|
||||
<DeleteAssets menuItem {onAssetDelete} />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
|
||||
@@ -76,13 +76,13 @@
|
||||
<LinkButton on:click={handleRestoreTrash}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
<Icon path={mdiHistory} size="18" />
|
||||
Restore All
|
||||
Restore all
|
||||
</div>
|
||||
</LinkButton>
|
||||
<LinkButton on:click={() => (isShowEmptyConfirmation = true)}>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
<Icon path={mdiDeleteOutline} size="18" />
|
||||
Empty Trash
|
||||
Empty trash
|
||||
</div>
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user