feat: add shortcuts to asset-grid

This commit is contained in:
martabal 2024-01-19 15:49:30 +01:00
parent b4d1470586
commit e6e7f78f9f
No known key found for this signature in database
GPG Key ID: C00196E3148A52BD
12 changed files with 205 additions and 127 deletions

View File

@ -1,57 +1,32 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiArchiveArrowUpOutline, mdiArchiveArrowDownOutline, mdiTimerSand } from '@mdi/js';
import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets, type OnArchive } from '$lib/utils/actions';
import { isAllArchived } from '$lib/stores/asset-interaction.store';
export let onArchive: OnArchive | undefined = undefined;
export let menuItem = false;
export let unarchive = false;
$: text = unarchive ? 'Unarchive' : 'Archive';
$: icon = unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline;
$: text = $isAllArchived ? 'Unarchive' : 'Archive';
$: icon = $isAllArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline;
let loading = false;
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = !unarchive;
const isArchived = !$isAllArchived;
loading = true;
try {
const assets = Array.from(getOwnedAssets()).filter((asset) => asset.isArchived !== isArchived);
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
}
for (const asset of assets) {
asset.isArchived = isArchived;
}
onArchive?.(ids, isArchived);
notificationController.show({
message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`,
type: NotificationType.Info,
});
const assets = Array.from(getOwnedAssets()).filter((asset) => asset.isArchived !== isArchived);
if (await archiveAssets(isArchived, onArchive, assets)) {
clearSelect();
} catch (error) {
handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`);
} finally {
loading = false;
}
loading = false;
};
</script>

View File

@ -1,58 +1,33 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js';
import type { OnFavorite } from '$lib/utils/actions';
import { favoriteAssets, type OnFavorite } from '$lib/utils/actions';
import { isAllFavorite } from '$lib/stores/asset-interaction.store';
export let onFavorite: OnFavorite | undefined = undefined;
export let menuItem = false;
export let removeFavorite: boolean;
$: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
$: icon = removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline;
$: text = $isAllFavorite ? 'Remove from Favorites' : 'Favorite';
$: icon = $isAllFavorite ? mdiHeartMinusOutline : mdiHeartOutline;
let loading = false;
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleFavorite = async () => {
const isFavorite = !removeFavorite;
const isFavorite = !$isAllFavorite;
loading = true;
try {
const assets = Array.from(getOwnedAssets()).filter((asset) => asset.isFavorite !== isFavorite);
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
notificationController.show({
message: isFavorite ? `Added ${ids.length} to favorites` : `Removed ${ids.length} from favorites`,
type: NotificationType.Info,
});
const assets = Array.from(getOwnedAssets()).filter((asset) => asset.isFavorite !== isFavorite);
if (await favoriteAssets(isFavorite, onFavorite, assets)) {
clearSelect();
} catch (error) {
handleError(error, `Unable to ${isFavorite ? 'add to' : 'remove from'} favorites`);
} finally {
loading = false;
}
loading = false;
};
</script>

View File

@ -1,10 +1,11 @@
<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 { selectAll } from '$lib/utils/actions';
import type { AssetStore } from '$lib/stores/assets.store';
export let assetStore: AssetStore;
export let assetInteractionStore: AssetInteractionStore;
@ -12,25 +13,11 @@
let selecting = false;
const handleSelectAll = async () => {
try {
$isSelectAllCancelled = false;
selecting = true;
selecting = true;
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);
}
}
selectAll(get(assetStore), assetStore, assetInteractionStore);
selecting = false;
} catch (e) {
handleError(e, 'Error selecting all assets');
}
selecting = false;
};
</script>

View File

@ -2,7 +2,12 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import {
isAllUserOwned,
type AssetInteractionStore,
isAllFavorite,
isAllArchived,
} 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 { locale, showDeleteModal } from '$lib/stores/preferences.store';
@ -19,8 +24,11 @@
import AssetDateGroup from './asset-date-group.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, deleteAssets, favoriteAssets, selectAll } from '$lib/utils/actions';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { user } from '$lib/stores/user.store';
import { get } from 'svelte/store';
export let isSelectionMode = false;
export let singleSelect = false;
@ -47,6 +55,9 @@
$: idsSelectedAssets = Array.from($selectedAssets)
.filter((a) => !a.isExternal)
.map((a) => a.id);
$: $isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === $user.id);
$: $isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
$: $isAllArchived = Array.from($selectedAssets).every((asset) => asset.isArchived);
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
const dispatch = createEventDispatcher<{ select: AssetResponseDto; escape: void }>();
@ -70,24 +81,30 @@
assetStore.disconnect();
});
const trashOrDelete = (force: boolean = false) => {
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
deleteAssets(!(isTrashEnabled && !force), (assetId) => assetStore.removeAsset(assetId), idsSelectedAssets);
assetInteractionStore.clearMultiselect();
if (
await deleteAssets(!(isTrashEnabled && !force), (assetId) => assetStore.removeAsset(assetId), idsSelectedAssets)
) {
assetInteractionStore.clearMultiselect();
}
};
const handleKeyboardPress = (event: KeyboardEvent) => {
const handleKeyboardPress = async (event: KeyboardEvent) => {
if ($isSearchEnabled || shouldIgnoreShortcut(event)) {
return;
}
const key = event.key;
const shiftKey = event.shiftKey;
const ctrlKey = event.ctrlKey;
const assets = Array.from($selectedAssets);
if (!$showAssetViewer) {
switch (key) {
case 'Escape':
dispatch('escape');
case '/':
event.preventDefault();
goto(AppRoute.EXPLORE);
return;
case '?':
if (event.shiftKey) {
@ -95,9 +112,43 @@
showShortcuts = !showShortcuts;
}
return;
case '/':
event.preventDefault();
goto(AppRoute.EXPLORE);
case 'a':
case 'A':
if ($isMultiSelectState) {
if (ctrlKey) {
event.preventDefault();
selectAll(get(assetStore), assetStore, assetInteractionStore);
return;
}
if ($isAllUserOwned) {
if (
await archiveAssets(
!$isAllArchived,
(assetIds) => {
for (const assetId of assetIds) {
assetStore.removeAsset(assetId);
}
},
assets,
)
) {
assetInteractionStore.clearMultiselect();
}
}
}
return;
case 'd':
case 'D':
if ($isMultiSelectState) {
assetInteractionStore.clearMultiselect();
if (assets.length === 1) {
await downloadFile(assets[0]);
return;
}
await downloadArchive('immich.zip', { assetIds: assets.map((asset) => asset.id) });
}
return;
case 'Delete':
if ($isMultiSelectState) {
@ -113,6 +164,16 @@
trashOrDelete(force);
}
return;
case 'Escape':
dispatch('escape');
return;
case 'f':
if ($isMultiSelectState && $isAllUserOwned) {
if (await favoriteAssets(!$isAllFavorite, undefined, assets)) {
assetInteractionStore.clearMultiselect();
}
}
return;
}
}
};

View File

@ -147,3 +147,7 @@ export function createAssetInteractionStore(): AssetInteractionStore {
},
};
}
export const isAllUserOwned = writable<boolean>();
export const isAllFavorite = writable<boolean>();
export const isAllArchived = writable<boolean>();

View File

@ -1,6 +1,9 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import { api } from '@api';
import { api, AssetResponseDto } from '@api';
import { handleError } from './handle-error';
import { isSelectAllCancelled, type AssetStore, BucketPosition } from '$lib/stores/assets.store';
import { get } from 'svelte/store';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
export type OnDelete = (assetId: string) => void;
export type OnRestore = (ids: string[]) => void;
@ -8,7 +11,7 @@ export type OnArchive = (ids: string[], isArchived: boolean) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (ids: string[]) => void;
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]): Promise<boolean> => {
try {
await api.assetApi.deleteAssets({ assetBulkDeleteDto: { ids, force } });
for (const id of ids) {
@ -19,7 +22,92 @@ export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids:
message: `${force ? 'Permanently deleted' : 'Trashed'} ${ids.length} assets`,
type: NotificationType.Info,
});
return true;
} catch (e) {
handleError(e, 'Error deleting assets');
}
return false;
};
export const favoriteAssets = async (
isFavorite: boolean,
onFavorite: OnFavorite | undefined,
assets: AssetResponseDto[],
): Promise<boolean> => {
try {
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
notificationController.show({
message: isFavorite ? `Added ${ids.length} to favorites` : `Removed ${ids.length} from favorites`,
type: NotificationType.Info,
});
return true;
} catch (error) {
handleError(error, `Unable to ${isFavorite ? 'add to' : 'remove from'} favorites`);
}
return false;
};
export const archiveAssets = async (
isArchived: boolean,
onArchive: OnArchive | undefined,
assets: AssetResponseDto[],
) => {
try {
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
}
for (const asset of assets) {
asset.isArchived = isArchived;
}
onArchive?.(ids, isArchived);
notificationController.show({
message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`,
type: NotificationType.Info,
});
return true;
} catch (error) {
handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`);
}
return false;
};
export const selectAll = async (
assetGridState: AssetStore,
assetStore: AssetStore,
assetInteractionStore: AssetInteractionStore,
): Promise<boolean> => {
isSelectAllCancelled.set(false);
try {
for (const bucket of assetGridState.buckets) {
if (get(isSelectAllCancelled)) {
break;
}
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
for (const asset of bucket.assets) {
assetInteractionStore.selectAsset(asset);
}
}
return true;
} catch (e) {
handleError(e, 'Error selecting all assets');
}
return false;
};

View File

@ -29,7 +29,7 @@
} from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute, dateFormats } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { createAssetInteractionStore, isAllUserOwned } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { AssetStore } from '$lib/stores/assets.store';
@ -110,8 +110,6 @@
const { selectedAssets: timelineSelected } = timelineInteractionStore;
$: isOwned = $user.id == album.ownerId;
$: isAllUserOwned = Array.from($selectedAssets).every((asset) => asset.ownerId === $user.id);
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
$: {
if (isShowActivity) {
assetGridWidth = globalWidth - (globalWidth < 768 ? 360 : 460);
@ -440,15 +438,15 @@
<AddToAlbum shared />
</AssetSelectContextMenu>
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
{#if isAllUserOwned}
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
{#if $isAllUserOwned}
<FavoriteAction menuItem />
<ArchiveAction menuItem />
{/if}
<ArchiveAction menuItem />
<DownloadAction menuItem filename="{album.albumName}.zip" />
{#if isOwned || isAllUserOwned}
{#if isOwned || $isAllUserOwned}
<RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
{/if}
{#if isAllUserOwned}
{#if $isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
<ChangeDate menuItem />
<ChangeLocation menuItem />

View File

@ -23,13 +23,11 @@
const assetStore = new AssetStore({ isArchived: true });
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
</script>
{#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<ArchiveAction unarchive onArchive={(ids) => assetStore.removeAssets(ids)} />
<ArchiveAction onArchive={(ids) => assetStore.removeAssets(ids)} />
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title="Add">
@ -39,7 +37,7 @@
<DeleteAssets onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
<AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
<DownloadAction menuItem />
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<FavoriteAction menuItem />
</AssetSelectContextMenu>
</AssetSelectControlBar>
{/if}

View File

@ -25,14 +25,12 @@
const assetStore = new AssetStore({ isFavorite: true });
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
</script>
<!-- Multiselection mode app bar -->
{#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<FavoriteAction removeFavorite onFavorite={(ids) => assetStore.removeAssets(ids)} />
<FavoriteAction onFavorite={(ids) => assetStore.removeAssets(ids)} />
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title="Add">
@ -42,7 +40,7 @@
<DeleteAssets onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
<DownloadAction menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} />
<ArchiveAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
</AssetSelectContextMenu>

View File

@ -108,8 +108,6 @@
isSearchingPeople = false;
};
$: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
$: $onPersonThumbnail === data.person.id &&
(thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`);
@ -394,8 +392,8 @@
<DeleteAssets onAssetDelete={(assetId) => $assetStore.removeAsset(assetId)} />
<AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
<FavoriteAction menuItem />
<ArchiveAction menuItem onArchive={(ids) => $assetStore.removeAssets(ids)} />
<MenuOption text="Fix incorrect match" on:click={handleReassignAssets} />
<ChangeDate menuItem />
<ChangeLocation menuItem />

View File

@ -31,8 +31,6 @@
const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
const handleEscape = () => {
if ($showAssetViewer) {
return;
@ -65,7 +63,7 @@
onAssetDelete={(assetId) => assetStore.removeAsset(assetId)}
/>
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<FavoriteAction menuItem />
<DownloadAction menuItem />
<ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
{#if $selectedAssets.size > 1}

View File

@ -96,8 +96,6 @@
let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0;
$: isAllArchived = Array.from(selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
$: searchResultAssets = data.results?.assets.items;
const onAssetDelete = (assetId: string) => {
@ -122,8 +120,8 @@
<AssetSelectContextMenu icon={mdiDotsVertical} title="Add">
<DownloadAction menuItem />
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<ArchiveAction menuItem unarchive={isAllArchived} />
<FavoriteAction menuItem />
<ArchiveAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
</AssetSelectContextMenu>