mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor(server): stacks (#11453)
* refactor: stacks * mobile: get it built * chore: feedback * fix: sync and duplicates * mobile: remove old stack reference * chore: add primary asset id * revert change to asset entity * mobile: refactor mobile api * mobile: sync stack info after creating stack * mobile: update timeline after deleting stack * server: update asset updatedAt when stack is deleted * mobile: simplify action * mobile: rename to match dto property * fix: web test --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
ca52cbace1
commit
8338657eaa
63 changed files with 2321 additions and 1152 deletions
|
|
@ -1,17 +1,17 @@
|
|||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { unstackAssets } from '$lib/utils/asset-utils';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { deleteStack } from '$lib/utils/asset-utils';
|
||||
import type { StackResponseDto } from '@immich/sdk';
|
||||
import { mdiImageMinusOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction } from './action';
|
||||
|
||||
export let stackedAssets: AssetResponseDto[];
|
||||
export let stack: StackResponseDto;
|
||||
export let onAction: OnAction;
|
||||
|
||||
const handleUnstack = async () => {
|
||||
const unstackedAssets = await unstackAssets(stackedAssets);
|
||||
const unstackedAssets = await deleteStack([stack.id]);
|
||||
if (unstackedAssets) {
|
||||
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@
|
|||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetJobName, getSharedLink } from '$lib/utils';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiCogRefreshOutline,
|
||||
|
|
@ -37,10 +43,9 @@
|
|||
|
||||
export let asset: AssetResponseDto;
|
||||
export let album: AlbumResponseDto | null = null;
|
||||
export let stackedAssets: AssetResponseDto[];
|
||||
export let stack: StackResponseDto | null = null;
|
||||
export let showDetailButton: boolean;
|
||||
export let showSlideshow = false;
|
||||
export let hasStackChildren = false;
|
||||
export let onZoomImage: () => void;
|
||||
export let onCopyImage: () => void;
|
||||
export let onAction: OnAction;
|
||||
|
|
@ -136,8 +141,8 @@
|
|||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
{#if hasStackChildren}
|
||||
<UnstackAction {stackedAssets} {onAction} />
|
||||
{#if stack}
|
||||
<UnstackAction {stack} {onAction} />
|
||||
{/if}
|
||||
{#if album}
|
||||
<SetAlbumCoverAction {asset} {album} />
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
type ActivityResponseDto,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
getStack,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { mdiImageBrokenVariant } from '@mdi/js';
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
|
|
@ -74,7 +76,6 @@
|
|||
}>();
|
||||
|
||||
let appearsInAlbums: AlbumResponseDto[] = [];
|
||||
let stackedAssets: AssetResponseDto[] = [];
|
||||
let shouldPlayMotionPhoto = false;
|
||||
let sharedLink = getSharedLink();
|
||||
let enableDetailPanel = asset.hasMetadata;
|
||||
|
|
@ -92,22 +93,28 @@
|
|||
|
||||
$: isFullScreen = fullscreenElement !== null;
|
||||
|
||||
$: {
|
||||
if (asset.stackCount && asset.stack) {
|
||||
stackedAssets = asset.stack;
|
||||
stackedAssets = [...stackedAssets, asset].sort(
|
||||
(a, b) => new Date(b.fileCreatedAt).getTime() - new Date(a.fileCreatedAt).getTime(),
|
||||
);
|
||||
let stack: StackResponseDto | null = null;
|
||||
|
||||
// if its a stack, add the next stack image in addition to the next asset
|
||||
if (asset.stackCount > 1) {
|
||||
preloadAssets.push(stackedAssets[1]);
|
||||
}
|
||||
const refreshStack = async () => {
|
||||
if (isSharedLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stackedAssets.map((a) => a.id).includes(asset.id)) {
|
||||
stackedAssets = [];
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
}
|
||||
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
|
||||
if (stack && stack?.assets.length > 1) {
|
||||
preloadAssets.push(stack.assets[1]);
|
||||
}
|
||||
};
|
||||
|
||||
$: if (asset) {
|
||||
handlePromiseError(refreshStack());
|
||||
}
|
||||
|
||||
$: {
|
||||
|
|
@ -215,15 +222,6 @@
|
|||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
|
||||
if (asset.stackCount && asset.stack) {
|
||||
stackedAssets = asset.stack;
|
||||
stackedAssets = [...stackedAssets, asset].sort(
|
||||
(a, b) => new Date(a.fileCreatedAt).getTime() - new Date(b.fileCreatedAt).getTime(),
|
||||
);
|
||||
} else {
|
||||
stackedAssets = [];
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -392,8 +390,10 @@
|
|||
await handleGetAllAlbums();
|
||||
break;
|
||||
}
|
||||
|
||||
case AssetAction.UNSTACK: {
|
||||
await closeViewer();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -420,10 +420,9 @@
|
|||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
{stackedAssets}
|
||||
{stack}
|
||||
showDetailButton={enableDetailPanel}
|
||||
showSlideshow={!!assetStore}
|
||||
hasStackChildren={stackedAssets.length > 0}
|
||||
onZoomImage={zoomToggle}
|
||||
onCopyImage={copyImage}
|
||||
onAction={handleAction}
|
||||
|
|
@ -568,7 +567,8 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stackedAssets.length > 0 && withStacked}
|
||||
{#if stack && withStacked}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div
|
||||
id="stack-slideshow"
|
||||
class="z-[1002] flex place-item-center place-content-center absolute bottom-0 w-full col-span-4 col-start-1 overflow-x-auto horizontal-scrollbar"
|
||||
|
|
|
|||
|
|
@ -170,14 +170,14 @@
|
|||
|
||||
<!-- Stacked asset -->
|
||||
|
||||
{#if asset.stackCount && showStackedIcon}
|
||||
{#if asset.stack && showStackedIcon}
|
||||
<div
|
||||
class="absolute {asset.type == AssetTypeEnum.Image && asset.livePhotoVideoId == undefined
|
||||
? 'top-0 right-0'
|
||||
: 'top-7 right-1'} z-20 flex place-items-center gap-1 text-xs font-medium text-white"
|
||||
>
|
||||
<span class="pr-2 pt-2 flex place-items-center gap-1">
|
||||
<p>{asset.stackCount.toLocaleString($locale)}</p>
|
||||
<p>{asset.stack.assetCount.toLocaleString($locale)}</p>
|
||||
<Icon path={mdiCameraBurst} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
|
||||
import { stackAssets, unstackAssets } from '$lib/utils/asset-utils';
|
||||
import { stackAssets, deleteStack } from '$lib/utils/asset-utils';
|
||||
import type { OnStack, OnUnstack } from '$lib/utils/actions';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
|
|
@ -30,8 +30,7 @@
|
|||
if (!stack) {
|
||||
return;
|
||||
}
|
||||
const assets = [selectedAssets[0], ...stack];
|
||||
const unstackedAssets = await unstackAssets(assets);
|
||||
const unstackedAssets = await deleteStack([stack.id]);
|
||||
if (unstackedAssets) {
|
||||
onUnstack?.(unstackedAssets);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
$: isFromExternalLibrary = !!asset.libraryId;
|
||||
$: assetData = JSON.stringify(asset, null, 2);
|
||||
$: stackCount = asset.stackCount;
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
@ -55,17 +54,17 @@
|
|||
{isSelected ? $t('keep') : $t('to_trash')}
|
||||
</div>
|
||||
|
||||
<!-- EXTERNAL LIBRARY / STACK COUNT CHIP-->
|
||||
<!-- EXTERNAL LIBRARY / STACK COUNT CHIP -->
|
||||
<div class="absolute top-2 right-3">
|
||||
{#if isFromExternalLibrary}
|
||||
<div class="bg-immich-primary/90 px-2 py-1 rounded-xl text-xs text-white">
|
||||
{$t('external')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if stackCount != null && stackCount != 0}
|
||||
{#if asset.stack?.assetCount}
|
||||
<div class="bg-immich-primary/90 px-2 py-1 my-0.5 rounded-xl text-xs text-white">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="mr-1">{stackCount}</div>
|
||||
<div class="mr-1">{asset.stack.assetCount}</div>
|
||||
<Icon path={mdiImageMultipleOutline} size="18" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ import { createAlbum } from '$lib/utils/album-utils';
|
|||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import {
|
||||
addAssetsToAlbum as addAssets,
|
||||
createStack,
|
||||
deleteStacks,
|
||||
getAssetInfo,
|
||||
getBaseUrl,
|
||||
getDownloadInfo,
|
||||
getStack,
|
||||
updateAsset,
|
||||
updateAssets,
|
||||
type AlbumResponseDto,
|
||||
|
|
@ -335,79 +338,60 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification =
|
|||
return false;
|
||||
}
|
||||
|
||||
const parent = assets[0];
|
||||
const children = assets.slice(1);
|
||||
const ids = children.map(({ id }) => id);
|
||||
const $t = get(t);
|
||||
|
||||
try {
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids,
|
||||
stackParentId: parent.id,
|
||||
},
|
||||
});
|
||||
const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
|
||||
if (showNotification) {
|
||||
notificationController.show({
|
||||
message: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
|
||||
type: NotificationType.Info,
|
||||
button: {
|
||||
text: $t('view_stack'),
|
||||
onClick: () => assetViewingStore.setAssetId(stack.primaryAssetId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, asset] of assets.entries()) {
|
||||
asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null;
|
||||
}
|
||||
|
||||
return assets.slice(1).map((asset) => asset.id);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_stack_assets'));
|
||||
return false;
|
||||
}
|
||||
|
||||
let grandChildren: AssetResponseDto[] = [];
|
||||
for (const asset of children) {
|
||||
asset.stackParentId = parent.id;
|
||||
if (asset.stack) {
|
||||
// Add grand-children to new parent
|
||||
grandChildren = grandChildren.concat(asset.stack);
|
||||
// Reset children stack info
|
||||
asset.stackCount = null;
|
||||
asset.stack = [];
|
||||
}
|
||||
}
|
||||
|
||||
parent.stack ??= [];
|
||||
parent.stack = parent.stack.concat(children, grandChildren);
|
||||
parent.stackCount = parent.stack.length + 1;
|
||||
|
||||
if (showNotification) {
|
||||
notificationController.show({
|
||||
message: $t('stacked_assets_count', { values: { count: parent.stackCount } }),
|
||||
type: NotificationType.Info,
|
||||
button: {
|
||||
text: $t('view_stack'),
|
||||
onClick() {
|
||||
return assetViewingStore.setAssetId(parent.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const unstackAssets = async (assets: AssetResponseDto[]) => {
|
||||
const ids = assets.map(({ id }) => id);
|
||||
const $t = get(t);
|
||||
try {
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids,
|
||||
removeParent: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_unstack_assets'));
|
||||
export const deleteStack = async (stackIds: string[]) => {
|
||||
const ids = [...new Set(stackIds)];
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const asset of assets) {
|
||||
asset.stackParentId = null;
|
||||
asset.stackCount = null;
|
||||
asset.stack = [];
|
||||
|
||||
const $t = get(t);
|
||||
|
||||
try {
|
||||
const stacks = await Promise.all(ids.map((id) => getStack({ id })));
|
||||
const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0);
|
||||
|
||||
await deleteStacks({ bulkIdsDto: { ids: [...ids] } });
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('unstacked_assets_count', { values: { count } }),
|
||||
});
|
||||
|
||||
const assets = stacks.flatMap((stack) => stack.assets);
|
||||
for (const asset of assets) {
|
||||
asset.stack = null;
|
||||
}
|
||||
|
||||
return assets;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_unstack_assets'));
|
||||
}
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('unstacked_assets_count', { values: { count: assets.length } }),
|
||||
});
|
||||
return assets;
|
||||
};
|
||||
|
||||
export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => {
|
||||
|
|
|
|||
|
|
@ -25,5 +25,4 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
|||
checksum: Sync.each(() => faker.string.alphanumeric(28)),
|
||||
isOffline: Sync.each(() => faker.datatype.boolean()),
|
||||
hasMetadata: Sync.each(() => faker.datatype.boolean()),
|
||||
stackCount: null,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue