Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job

This commit is contained in:
Jonathan Jogenfors
2024-03-22 22:55:07 +01:00
424 changed files with 4773 additions and 5062 deletions
@@ -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}
@@ -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,
@@ -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,
+1 -1
View File
@@ -519,4 +519,4 @@ export class AssetStore {
}
}
export const isSelectAllCancelled = writable(false);
export const isSelectingAllAssets = writable(false);
+11
View File
@@ -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);
+32
View File
@@ -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));
};
+21
View File
@@ -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>
+3 -3
View File
@@ -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}
+3 -3
View File
@@ -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}
+9 -7
View File
@@ -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>
+4 -4
View File
@@ -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>
+2 -2
View File
@@ -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>