mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(web): Add to Multiple Albums (#20072)
* Multi add to album picker: - update modal for multi select - Update add-to-album and add-to-album-action to work with new array return from AlbumPickerModal - Add asset-utils.addAssetsToAlbums (incomplete) * initial addToAlbums endpoint * - fix endpoint - add test * - update return type - make open-api * - simplify return dto - handle notification * - fix returns - clean up * - update i18n - format & check * - checks * - correct successId count - fix assets_cannot_be_added language call * tests * foromat * refactor * - update successful add message to included total attempted * - fix web test - format i18n * - fix open-api * - fix imports to resolve checks * - PR suggestions * open-api * refactor addAssetsToAlbums * refactor it again * - fix error returns and tests * - swap icon for IconButton - don't nest the buttons * open-api * - Cleanup multi-select button to match Thumbnail * merge and openapi * - remove onclick from icon element * - fix double onClose call with keyboard shortcuts * - spelling and formatting - apply new api permission * - open-api * chore: styling * translation --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
e00556a34a
commit
9ff664ed36
24 changed files with 1280 additions and 55 deletions
|
|
@ -4,7 +4,7 @@
|
|||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
||||
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
|
|
@ -20,14 +20,23 @@
|
|||
let { asset, onAction, shared = false }: Props = $props();
|
||||
|
||||
const onClick = async () => {
|
||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||
|
||||
if (!album) {
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await addAssetsToAlbum(album.id, [asset.id]);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
await addAssetsToAlbum(album.id, [asset.id]);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||
} else {
|
||||
await addAssetsToAlbums(
|
||||
albums.map((a) => a.id),
|
||||
[asset.id],
|
||||
);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiCheckCircle } from '@mdi/js';
|
||||
import type { Action } from 'svelte/action';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
|
|
@ -10,10 +13,19 @@
|
|||
album: AlbumResponseDto;
|
||||
searchQuery?: string;
|
||||
selected: boolean;
|
||||
multiSelected?: boolean;
|
||||
onAlbumClick: () => void;
|
||||
onMultiSelect: () => void;
|
||||
}
|
||||
|
||||
let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props();
|
||||
let {
|
||||
album,
|
||||
searchQuery = '',
|
||||
selected = false,
|
||||
multiSelected = false,
|
||||
onAlbumClick,
|
||||
onMultiSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const scrollIntoViewIfSelected: Action = (node) => {
|
||||
$effect(() => {
|
||||
|
|
@ -37,33 +49,127 @@
|
|||
albumName.slice(findIndex + findLength),
|
||||
];
|
||||
});
|
||||
|
||||
const handleMultiSelectClicked = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
onMultiSelect();
|
||||
};
|
||||
|
||||
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||
let mouseOver = $state(false);
|
||||
const onMouseEnter = () => {
|
||||
if (usingMobileDevice) {
|
||||
return;
|
||||
}
|
||||
mouseOver = true;
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const preventContextMenu = (evt: Event) => evt.preventDefault();
|
||||
const disposeables: (() => void)[] = [];
|
||||
const clearLongPressTimer = () => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
for (const dispose of disposeables) {
|
||||
dispose();
|
||||
}
|
||||
disposeables.length = 0;
|
||||
};
|
||||
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
|
||||
let didPress = false;
|
||||
const start = () => {
|
||||
didPress = false;
|
||||
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
|
||||
timer = setTimeout(() => {
|
||||
onLongPress();
|
||||
element.addEventListener('contextmenu', preventContextMenu, { once: true });
|
||||
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
|
||||
didPress = true;
|
||||
}, 350);
|
||||
};
|
||||
const click = (e: MouseEvent) => {
|
||||
if (!didPress) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
element.addEventListener('click', click);
|
||||
element.addEventListener('pointerdown', start, true);
|
||||
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
|
||||
return {
|
||||
destroy: () => {
|
||||
element.removeEventListener('click', click);
|
||||
element.removeEventListener('pointerdown', start, true);
|
||||
element.removeEventListener('pointerup', clearLongPressTimer, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAlbumClick}
|
||||
use:scrollIntoViewIfSelected
|
||||
class="flex w-full gap-4 px-6 py-2 text-start transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
|
||||
class:bg-gray-200={selected}
|
||||
class:dark:bg-gray-700={selected}
|
||||
<div
|
||||
role="group"
|
||||
class={[
|
||||
'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
|
||||
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
|
||||
]}
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
>
|
||||
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
||||
alt={album.albumName}
|
||||
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex h-12 flex-col items-start justify-center overflow-hidden">
|
||||
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
|
||||
>
|
||||
<span class="flex gap-1 text-sm">
|
||||
<AlbumListItemDetails {album} />
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAlbumClick}
|
||||
use:scrollIntoViewIfSelected
|
||||
class="flex gap-4 px-2 py-2 text-start"
|
||||
class:bg-gray-200={selected}
|
||||
class:dark:bg-gray-700={selected}
|
||||
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
|
||||
>
|
||||
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
||||
alt={album.albumName}
|
||||
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
|
||||
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
|
||||
>
|
||||
<span class="flex gap-1 text-sm">
|
||||
<AlbumListItemDetails {album} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if mouseOver || multiSelected}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMultiSelectClicked}
|
||||
class="p-3 focus:outline-none hover:cursor-pointer"
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
aria-checked={selected}
|
||||
>
|
||||
{#if multiSelected}
|
||||
<div class="rounded-full">
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import type { OnAddToAlbum } from '$lib/utils/actions';
|
||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
||||
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
|
@ -18,15 +18,23 @@
|
|||
const { getAssets } = getAssetControlContext();
|
||||
|
||||
const onClick = async () => {
|
||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
||||
|
||||
if (!album) {
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetIds = [...getAssets()].map(({ id }) => id);
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
onAddToAlbum(assetIds, album.id);
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
onAddToAlbum(assetIds, album.id);
|
||||
} else {
|
||||
await addAssetsToAlbums(
|
||||
albums.map(({ id }) => id),
|
||||
assetIds,
|
||||
);
|
||||
onAddToAlbum(assetIds, albums[0].id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
|
|||
type: AlbumModalRowType.ALBUM_ITEM,
|
||||
album,
|
||||
selected,
|
||||
multiSelected: false,
|
||||
});
|
||||
|
||||
describe('Album Modal', () => {
|
||||
it('non-shared with no albums configured yet shows message and new', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const modalRows = converter.toModalRows('', [], [], -1);
|
||||
const modalRows = converter.toModalRows('', [], [], -1, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]);
|
||||
});
|
||||
|
||||
it('non-shared with no matching albums shows message and new', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1);
|
||||
const modalRows = converter.toModalRows(
|
||||
'matches_nothing',
|
||||
[],
|
||||
[albumFactory.build({ albumName: 'Holidays' })],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
|
||||
});
|
||||
|
|
@ -44,7 +51,7 @@ describe('Album Modal', () => {
|
|||
it('non-shared displays single albums', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1);
|
||||
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(false),
|
||||
|
|
@ -64,6 +71,7 @@ describe('Album Modal', () => {
|
|||
[holidayAlbum, constructionAlbum],
|
||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
|
|
@ -90,6 +98,7 @@ describe('Album Modal', () => {
|
|||
[holidayAlbum, constructionAlbum],
|
||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
|
|
@ -112,6 +121,7 @@ describe('Album Modal', () => {
|
|||
[holidayAlbum, constructionAlbum],
|
||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
|
|
@ -125,7 +135,7 @@ describe('Album Modal', () => {
|
|||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0);
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(true),
|
||||
|
|
@ -141,7 +151,7 @@ describe('Album Modal', () => {
|
|||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1);
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(false),
|
||||
|
|
@ -157,7 +167,7 @@ describe('Album Modal', () => {
|
|||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3);
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(false),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export enum AlbumModalRowType {
|
|||
export type AlbumModalRow = {
|
||||
type: AlbumModalRowType;
|
||||
selected?: boolean;
|
||||
multiSelected?: boolean;
|
||||
text?: string;
|
||||
album?: AlbumResponseDto;
|
||||
};
|
||||
|
|
@ -41,6 +42,7 @@ export class AlbumModalRowConverter {
|
|||
recentAlbums: AlbumResponseDto[],
|
||||
albums: AlbumResponseDto[],
|
||||
selectedRowIndex: number,
|
||||
multiSelectedAlbumIds: string[],
|
||||
): AlbumModalRow[] {
|
||||
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
|
||||
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
|
||||
|
|
@ -64,6 +66,7 @@ export class AlbumModalRowConverter {
|
|||
rows.push({
|
||||
type: AlbumModalRowType.ALBUM_ITEM,
|
||||
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
|
||||
multiSelected: multiSelectedAlbumIds.includes(album.id),
|
||||
album,
|
||||
});
|
||||
}
|
||||
|
|
@ -81,6 +84,7 @@ export class AlbumModalRowConverter {
|
|||
rows.push({
|
||||
type: AlbumModalRowType.ALBUM_ITEM,
|
||||
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
|
||||
multiSelected: multiSelectedAlbumIds.includes(album.id),
|
||||
album,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import type { Action } from 'svelte/action';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
interface Props {
|
||||
searchQuery?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue