Compare commits

..

4 Commits

Author SHA1 Message Date
Mees Frensel 698b96d597 refactor(web): stack-related service and actions 2026-06-30 20:14:56 +02:00
Yaros 4099fa6b4a fix(mobile): app doesn't exit full-screen mode (#29301)
* fix(mobile): app doesn't exit full-screen mode

* chore: rename restoreSystemUI to restoreEdgeToEdge
2026-06-24 20:48:01 -05:00
Daniel Dietzler 9751530af8 feat: plugin wrapper type safety (#29300) 2026-06-24 15:22:35 -04:00
Daniel Dietzler 0931a19c5c fix: run test suite for plugin changes (#29311) 2026-06-24 16:29:46 +00:00
36 changed files with 466 additions and 560 deletions
+2
View File
@@ -45,6 +45,8 @@ jobs:
- 'server/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
- 'packages/plugin-core/**'
- 'packages/plugin-sdk/**'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v3
IMMICH_VERSION=v2
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
+3 -1
View File
@@ -33,7 +33,7 @@
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",
"add_to_bottom_bar": "Add to",
"add_upload_to_stack": "Add upload to stack",
"add_upload_to_stack": "Upload and {isStack, select, true {add to stack} other {create stack}}",
"add_url": "Add URL",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
@@ -1733,6 +1733,7 @@
"removed_from_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites",
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
"removed_from_stack": "Removed asset from stack",
"removed_memory": "Removed memory",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
@@ -2008,6 +2009,7 @@
"source": "Source",
"stack": "Stack",
"stack_action_prompt": "{count} stacked",
"stack_created": "Stack created",
"stack_duplicates": "Stack duplicates",
"stack_selected_photos": "Stack selected photos",
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@@ -49,7 +50,7 @@ class DriftMemoryPage extends HookConsumerWidget {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
};
});
@@ -328,7 +329,7 @@ class DriftMemoryPage extends HookConsumerWidget {
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -76,7 +77,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -255,7 +256,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _onTapUp() async {
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
await (_showAppBar ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive) : restoreEdgeToEdge());
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
@@ -128,7 +129,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription?.cancel();
_stackChildrenKeepAlive?.close();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -251,10 +252,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
final immersive = !controls || (CurrentPlatform.isIOS && details);
unawaited(immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge());
}
@override
+14
View File
@@ -0,0 +1,14 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// Restore the system bars and return to edge-to-edge layout.
///
/// On Android 15+/API 36 edge-to-edge is enforced, so calling
/// setEnabledSystemUIMode(edgeToEdge) does NOT re-show bars that an immersive
/// mode (immersive / immersiveSticky) previously hid. Explicitly request all
/// overlays first, then return to edge-to-edge layout.
Future<void> restoreEdgeToEdge() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
+10 -1
View File
@@ -222,7 +222,16 @@
"name": "assetLock",
"title": "Move to locked folder",
"description": "Change visibility to locked",
"types": ["AssetV1"]
"types": ["AssetV1"],
"schema": {
"properties": {
"inverse": {
"title": "Inverse",
"description": "When true will unarchive any archived assets",
"type": "boolean"
}
}
}
},
{
"name": "assetTimeline",
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
+1 -1
View File
@@ -22,6 +22,6 @@ declare module 'main' {
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
export function assetTrash(): I32;
// export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
}
+19 -27
View File
@@ -1,13 +1,11 @@
import { wrapper } from '@immich/plugin-sdk';
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest';
const wrapper = getWrapper<manifestType>();
type AssetFileFilterConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
@@ -43,7 +41,7 @@ export const assetFileFilter = () => {
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
@@ -51,13 +49,7 @@ export const assetMissingTimeZoneFilter = () => {
};
export const assetLocationFilter = () => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
@@ -96,13 +88,13 @@ export const assetLocationFilter = () => {
};
export const assetTypeFilter = () => {
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
@@ -115,13 +107,13 @@ export const assetFavorite = () => {
};
export const assetVisibility = () => {
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
};
export const assetArchive = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
@@ -135,7 +127,7 @@ export const assetArchive = () => {
};
export const assetLock = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
@@ -148,13 +140,13 @@ export const assetLock = () => {
});
};
export const assetTrash = () => {
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
+95 -44
View File
@@ -1,53 +1,104 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
export const wrapper = <
T extends WorkflowType,
TConfig extends ConfigValue = ConfigValue,
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<T> | undefined,
) => {
const input = Host.inputString();
try {
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
type Property = {
type: 'string' | 'boolean' | 'number';
array?: boolean;
enum?: string[];
} & {
type: 'object';
properties: { [K: string]: Property };
required?: string[];
};
type RequiredProperties<
Properties extends { [K: string]: unknown },
Required extends string[] | undefined,
RequiredKeys extends string = Required extends undefined
? never
: NonNullable<Required>[number],
> = {
properties: Pick<Properties, RequiredKeys> &
Partial<Omit<Properties, RequiredKeys>>;
};
type GetConfigType<T extends Property> = 'enum' extends keyof T
? NonNullable<T['enum']>[number]
: T['type'] extends 'boolean'
? boolean
: T['type'] extends 'number'
? number
: T['type'] extends 'string'
? string
: T['type'] extends 'object'
? ConfigValue<T>
: never;
type ConfigValue<
T extends { properties: { [K: string]: Property }; required?: string[] },
Properties extends { [K: string]: Property } = T['properties'],
> = T extends never
? never
: RequiredProperties<
{
[K in keyof Properties]: Properties[K]['array'] extends true
? Array<GetConfigType<Properties[K]>>
: GetConfigType<Properties[K]>;
},
'required' extends keyof T ? T['required'] : undefined
>['properties'];
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & {
name: K;
})['types'][number],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) => {
const input = Host.inputString();
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
@@ -224,6 +224,7 @@ export class PluginRepository {
error: (message) => logger.error(message),
} as Console,
logLevel: asExtismLogLevel(logger.getLogLevel()),
enableWasiOutput: true,
},
),
destroy: (plugin) => plugin.close(),
@@ -102,7 +102,7 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
let stack: StackResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
@@ -127,7 +127,7 @@
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
stack = undefined;
}
};
@@ -149,6 +149,22 @@
}
};
const onStackCreate = (createdStack: StackResponseDto) => {
if (createdStack.assets.map((a) => a.id).includes(asset.id)) {
stack = createdStack;
}
};
const onStackUpdate = (updatedStack: StackResponseDto) => {
if (stack?.id === updatedStack.id) {
stack = updatedStack;
if (!stack.assets.map((a) => a.id).includes(asset.id)) {
// current asset was removed from stack, go to primary
cursor.current = stack.assets[0];
}
}
};
onMount(() => {
syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
@@ -319,18 +335,6 @@
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
}
break;
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
const assetInfo = await getAssetInfo({ id: asset.id });
cursor.current = { ...asset, people: assetInfo.people };
@@ -347,10 +351,6 @@
};
break;
}
case AssetAction.UNSTACK: {
closeViewer();
break;
}
// no default
}
@@ -475,7 +475,7 @@
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
<OnEvents {onAssetUpdate} />
<OnEvents {onAssetUpdate} {onStackCreate} onStackDelete={() => closeViewer()} {onStackUpdate} />
<svelte:document
bind:fullscreenElement
@@ -1,17 +1,12 @@
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/ArchiveAction.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/DeleteAction.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
@@ -21,6 +16,7 @@
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getStackActions } from '$lib/services/stack.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -40,7 +36,7 @@
asset: AssetResponseDto;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
stack?: StackResponseDto;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
@@ -54,7 +50,7 @@
asset,
album = null,
person = null,
stack = null,
stack,
preAction,
onAction,
onUndoDelete = undefined,
@@ -86,6 +82,7 @@
});
const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }));
const StackActions = $derived(getStackActions($t, stack, asset));
const sharedLink = getSharedLink();
</script>
@@ -150,19 +147,12 @@
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
{#if stack?.primaryAssetId !== asset.id}
<SetStackPrimaryAsset {stack} {asset} {onAction} />
{#if stack?.assets?.length > 2}
<RemoveAssetFromStack {asset} {stack} {onAction} />
{/if}
{/if}
{/if}
{/if}
<ActionMenuItem action={StackActions.AddUploads} />
<ActionMenuItem action={StackActions.Unstack} />
<ActionMenuItem action={StackActions.KeepThisDeleteOthers} />
<ActionMenuItem action={StackActions.SetPrimaryAsset} />
<ActionMenuItem action={StackActions.RemoveAsset} />
{#if album}
{@const { SetCover } = getAlbumAssetActions($t, album, asset)}
<ActionMenuItem action={SetCover} />
@@ -1,37 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { createStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiUploadMultiple } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
stack: StackResponseDto | null;
onAction: OnAction;
}
let { asset, stack, onAction }: Props = $props();
const handleAddUploadToStack = async () => {
const newAssetIds = await openFileUploadDialog({ multiple: true });
// Including the old stacks primary asset ID ensures that all assets of the
// old stack are automatically included in the new stack.
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
// First asset in the list will become the new primary asset.
const assetIds = [primaryAssetId, ...newAssetIds];
const newStack = await createStack({
stackCreateDto: {
assetIds,
},
});
onAction({ type: AssetAction.STACK, stack: newStack });
};
</script>
<MenuOption icon={mdiUploadMultiple} onClick={handleAddUploadToStack} text={$t('add_upload_to_stack')} />
@@ -1,38 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiPinOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
asset: AssetResponseDto;
onAction: OnAction;
}
let { stack, asset, onAction }: Props = $props();
const handleKeepThisDeleteOthers = async () => {
const isConfirmed = await modalManager.showDialog({
title: $t('keep_this_delete_others'),
prompt: $t('confirm_keep_this_delete_others'),
confirmText: $t('delete_others'),
});
if (!isConfirmed) {
return;
}
const keptAsset = await keepThisDeleteOthers(asset, stack);
if (keptAsset) {
onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
}
};
</script>
<MenuOption icon={mdiPinOutline} onClick={handleKeepThisDeleteOthers} text={$t('keep_this_delete_others')} />
@@ -1,31 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { removeAssetFromStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
stack: StackResponseDto;
onAction: OnAction;
}
let { asset, stack, onAction }: Props = $props();
const handleRemoveFromStack = async () => {
await removeAssetFromStack({
id: stack.id,
assetId: asset.id,
});
const updatedStack = {
...stack,
assets: stack.assets.filter((a) => a.id !== asset.id),
};
onAction({ type: AssetAction.REMOVE_ASSET_FROM_STACK, stack: updatedStack, asset });
};
</script>
<MenuOption icon={mdiImageMinusOutline} onClick={handleRemoveFromStack} text={$t('viewer_remove_from_stack')} />
@@ -1,26 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { updateStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiImageCheckOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
asset: AssetResponseDto;
onAction: OnAction;
}
let { stack, asset, onAction }: Props = $props();
const handleSetPrimaryAsset = async () => {
const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
if (updatedStack) {
onAction({ type: AssetAction.SET_STACK_PRIMARY_ASSET, stack: updatedStack });
}
};
</script>
<MenuOption icon={mdiImageCheckOutline} onClick={handleSetPrimaryAsset} text={$t('set_stack_primary_asset')} />
@@ -1,26 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { deleteStack } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { StackResponseDto } from '@immich/sdk';
import { mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
onAction: OnAction;
}
let { stack, onAction }: Props = $props();
const handleUnstack = async () => {
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((asset) => toTimelineAsset(asset)) });
}
};
</script>
<MenuOption icon={mdiImageOffOutline} onClick={handleUnstack} text={$t('unstack')} />
@@ -1,4 +1,4 @@
import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
import type { AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -8,10 +8,6 @@ type ActionMap = {
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
@@ -9,7 +9,6 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleErrorAsync } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
@@ -150,52 +149,6 @@
timelineManager.upsertAssets([action.asset]);
break;
}
case AssetAction.STACK: {
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
case AssetAction.UNSTACK: {
updateUnstackedAssetInTimeline(timelineManager, action.assets);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack?.primaryAssetId)
.map((asset) => asset.id),
});
}
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
// no default
}
};
@@ -1,45 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiImageMultipleOutline, mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
unstack?: boolean;
onStack: OnStack | undefined;
onUnstack: OnUnstack | undefined;
}
let { unstack = false, onStack, onUnstack }: Props = $props();
const handleStack = async () => {
const result = await stackAssets(assetMultiSelectManager.ownedAssets);
onStack?.(result);
assetMultiSelectManager.clear();
};
const handleUnstack = async () => {
const selectedAssets = assetMultiSelectManager.ownedAssets;
if (selectedAssets.length !== 1) {
return;
}
const { stack } = selectedAssets[0];
if (!stack) {
return;
}
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
}
assetMultiSelectManager.clear();
};
</script>
{#if unstack}
<MenuOption text={$t('unstack')} icon={mdiImageOffOutline} onClick={handleUnstack} />
{:else}
<MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} onClick={handleStack} />
{/if}
@@ -15,12 +15,13 @@
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import { handleStack } from '$lib/services/stack.service';
import { keyboardManager } from '$lib/stores/keyboard-manager.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { isModalOpen, modalManager } from '@immich/ui';
@@ -59,10 +60,7 @@
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.assets);
updateStackedAssetInTimeline(timelineManager, result);
await handleStack(assetInteraction.assets.map((asset) => asset.id));
onEscape?.();
};
-4
View File
@@ -6,10 +6,6 @@ export enum AssetAction {
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack',
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
SET_PERSON_FEATURED_PHOTO = 'set-person-featured-photo',
@@ -11,6 +11,7 @@ import type {
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
StackResponseDto,
SystemConfigDto,
TagResponseDto,
UserAdminResponseDto,
@@ -61,6 +62,11 @@ export type Events = {
SharedLinkUpdate: [SharedLinkResponseDto];
SharedLinkDelete: [SharedLinkResponseDto];
StackCreate: [StackResponseDto];
/** Unstacked, with assets to handle */
StackDelete: [{ id: string; assets: AssetResponseDto[] }];
StackUpdate: [StackResponseDto];
TagCreate: [TagResponseDto];
TagUpdate: [TagResponseDto];
TagDelete: [TreeNode];
@@ -17,6 +17,7 @@ import {
retrieveRange as retrieveRangeUtil,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { updateStackedAssetInTimeline } from '$lib/utils/actions';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
@@ -115,6 +116,23 @@ export class TimelineManager extends VirtualScrollManager {
this.#unsubscribes.push(
eventManager.on({
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
StackCreate: (stack) => updateStackedAssetInTimeline(this, stack),
StackDelete: ({ assets }) => {
this.update(
assets.map((asset) => asset.id),
(asset) => (asset.stack = null),
);
this.upsertAssets(assets.map((asset) => toTimelineAsset(asset)));
},
StackUpdate: (stack) => {
// unstack and re-stack
this.update(
stack.assets.map((asset) => asset.id),
(asset) => (asset.stack = null),
);
this.upsertAssets(stack.assets.map((asset) => toTimelineAsset(asset)));
updateStackedAssetInTimeline(this, stack);
},
}),
);
}
+216
View File
@@ -0,0 +1,216 @@
import {
createStack,
deleteAssets,
deleteStacks,
getStack,
removeAssetFromStack,
updateStack,
type AssetResponseDto,
type AssetStackResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiImageCheckOutline,
mdiImageMinusOutline,
mdiImageMultipleOutline,
mdiImageOffOutline,
mdiPinOutline,
mdiUploadMultiple,
} from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
export const getStackBulkActions = ($t: MessageFormatter) => {
const Stack: ActionItem = {
title: $t('stack'),
icon: mdiImageMultipleOutline,
$if: () => assetMultiSelectManager.ownedAssets.length > 1,
onAction: () => handleStack(assetMultiSelectManager.ownedAssets.map((asset) => asset.id)),
};
const Unstack: ActionItem = {
title: $t('unstack'),
icon: mdiImageOffOutline,
$if: () => assetMultiSelectManager.ownedAssets.every((asset) => !!asset.stack),
onAction: () => handleDeleteStacks(assetMultiSelectManager.ownedAssets.map(({ stack }) => stack!)),
};
return { Stack, Unstack };
};
export const getStackActions = ($t: MessageFormatter, stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
const authUser = authManager.authenticated ? authManager.user : undefined;
const isAssetOwner = !!(authUser && authUser.id === asset.ownerId);
const validStack = !!stack && isAssetOwner;
const AddUploads: ActionItem = {
title: $t('add_upload_to_stack', { values: { isStack: !!stack } }),
icon: mdiUploadMultiple,
$if: () => isAssetOwner,
onAction: () => handleAddUploadToStack(stack, asset),
};
const KeepThisDeleteOthers: ActionItem = {
title: $t('keep_this_delete_others'),
icon: mdiPinOutline,
$if: () => validStack,
onAction: () => handleKeepThisDeleteOthers(stack!, asset),
};
const RemoveAsset: ActionItem = {
title: $t('viewer_remove_from_stack'),
icon: mdiImageMinusOutline,
$if: () => validStack && stack?.primaryAssetId !== asset.id && stack?.assets?.length > 2,
onAction: () => handleRemoveFromStack(stack!, asset),
};
const SetPrimaryAsset: ActionItem = {
title: $t('set_stack_primary_asset'),
icon: mdiImageCheckOutline,
$if: () => validStack && stack!.primaryAssetId !== asset.id,
onAction: () => handleSetPrimaryAsset(stack!, asset),
};
const Unstack: ActionItem = {
title: $t('unstack'),
icon: mdiImageOffOutline,
$if: () => validStack,
onAction: () => handleDeleteStack(stack!),
};
return { AddUploads, KeepThisDeleteOthers, RemoveAsset, SetPrimaryAsset, Unstack };
};
const handleAddUploadToStack = async (stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
const $t = await getFormatter();
const newAssetIds = await openFileUploadDialog({ multiple: true });
// Including the old stack's primary asset ID ensures that all assets of the
// old stack are automatically included in the new stack.
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
// First asset in the list will become the new primary asset.
const assetIds = [primaryAssetId, ...newAssetIds];
try {
const stack = await createStack({ stackCreateDto: { assetIds } });
toastManager.primary($t('stack_created'));
eventManager.emit('StackCreate', stack);
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
}
};
const handleKeepThisDeleteOthers = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
const isConfirmed = await modalManager.showDialog({
title: $t('keep_this_delete_others'),
prompt: $t('confirm_keep_this_delete_others'),
confirmText: $t('delete_others'),
});
if (!isConfirmed) {
return;
}
try {
const assetsToDeleteIds = stack.assets.filter((a) => a.id !== asset.id).map((asset) => asset.id);
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
eventManager.emit('StackDelete', { id: stack.id, assets: [asset] });
eventManager.emit('AssetUpdate', { ...asset, stack: null });
} catch (error) {
handleError(error, $t('errors.failed_to_keep_this_delete_others'));
}
};
const handleRemoveFromStack = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await removeAssetFromStack({ id: stack.id, assetId: asset.id });
const updatedStack = {
...stack,
assets: stack.assets.filter((a) => a.id !== asset.id),
};
toastManager.primary($t('removed_from_stack'));
eventManager.emit('AssetUpdate', asset); // todo: check if this re-inserts into timeline
eventManager.emit('StackUpdate', updatedStack);
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
const handleSetPrimaryAsset = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
// todo: toast?
eventManager.emit('StackUpdate', updatedStack);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleStack = async (assetIds: string[]) => {
const $t = await getFormatter();
try {
const stack = await createStack({ stackCreateDto: { assetIds } });
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
eventManager.emit('StackCreate', stack);
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
}
};
export const handleDeleteStack = async (stack: StackResponseDto) => {
const $t = await getFormatter();
try {
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
const assetIds = stack.assets.map((asset) => asset.id);
toastManager.primary($t('unstacked_assets_count', { values: { count: assetIds.length } }));
eventManager.emit('StackDelete', stack);
return assetIds;
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
const handleDeleteStacks = async (assetStacks: AssetStackResponseDto[]) => {
const $t = await getFormatter();
try {
const stacks = await Promise.all(assetStacks.map((stack) => getStack(stack)));
await Promise.all(stacks.map((stack) => handleDeleteStack(stack)));
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
assetMultiSelectManager.clear();
};
+12 -40
View File
@@ -1,10 +1,9 @@
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets, type StackResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { StackResponse } from '$lib/utils/asset-utils';
import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void;
@@ -15,8 +14,6 @@ export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset })
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (result: StackResponse) => void;
export type OnUnstack = (assets: TimelineAsset[]) => void;
export type OnSetVisibility = (ids: string[]) => void;
export const deleteAssets = async (
@@ -62,43 +59,18 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
/**
* Update the asset stack state in the asset store based on the provided stack response.
* This function updates the stack information so that the icon is shown for the primary asset
* and removes any assets from the timeline that are marked for deletion.
*
* @param {TimelineManager} timelineManager - The timeline manager to update.
* @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete.
* and removes the non-primary assets from the timeline.
*/
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
if (stack != undefined) {
timelineManager.update(
[stack.primaryAssetId],
(asset) =>
(asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
}),
);
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, stack: StackResponseDto) {
timelineManager.update([stack.primaryAssetId], (asset) => {
asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
};
});
timelineManager.removeAssets(toDeleteIds);
}
}
/**
* Update the timeline manager to reflect the unstacked state of assets.
* This function updates the stack property of each asset to undefined, effectively unstacking them.
* It also adds the unstacked assets back to the timeline manager.
*
* @param timelineManager - The timeline manager to update.
* @param assets - The array of asset response DTOs to update in the timeline manager.
*/
export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
timelineManager.update(
assets.map((asset) => asset.id),
(asset) => {
asset.stack = null;
return { remove: false };
},
timelineManager.removeAssets(
stack.assets.filter((asset) => asset.id !== stack.primaryAssetId).map((asset) => asset.id),
);
timelineManager.upsertAssets(assets);
}
-83
View File
@@ -1,12 +1,8 @@
import {
AssetVisibility,
bulkTagAssets,
createStack,
deleteAssets,
deleteStacks,
getBaseUrl,
getDownloadInfo,
getStack,
untagAssets,
updateAsset,
updateAssets,
@@ -14,7 +10,6 @@ import {
type AssetTypeEnum,
type DownloadInfoDto,
type ExifResponseDto,
type StackResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
@@ -281,84 +276,6 @@ export const getOwnedAssetsWithWarning = (assets: TimelineAsset[], user: UserRes
return ids;
};
export type StackResponse = {
stack?: StackResponseDto;
toDeleteIds: string[];
};
export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => {
if (assets.length < 2) {
return { stack: undefined, toDeleteIds: [] };
}
const $t = get(t);
try {
const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
if (showNotification) {
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
}
return {
stack,
toDeleteIds: assets.slice(1).map((asset) => asset.id),
};
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
return { stack: undefined, toDeleteIds: [] };
}
};
export const deleteStack = async (stackIds: string[]) => {
const ids = [...new Set(stackIds)];
if (ids.length === 0) {
return;
}
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] } });
toastManager.primary($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'));
}
};
export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: StackResponseDto) => {
const $t = get(t);
try {
const assetsToDeleteIds = stack.assets.filter((asset) => asset.id !== keepAsset.id).map((asset) => asset.id);
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
keepAsset.stack = null;
return keepAsset;
} catch (error) {
handleError(error, $t('errors.failed_to_keep_this_delete_others'));
}
};
export const selectAllAssets = async (timelineManager: TimelineManager, assetInteraction: AssetMultiSelectManager) => {
if (assetInteraction.selectAll) {
// Selection is already ongoing
@@ -13,7 +13,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -22,13 +21,9 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getStackBulkActions } from '$lib/services/stack.service';
import { mapSettings } from '$lib/stores/preferences.store';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
@@ -46,7 +41,6 @@
let timelineManager = $state<TimelineManager>() as TimelineManager;
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -85,6 +79,7 @@
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
withStacked: true,
assetFilter: selectedClusterIds,
});
@@ -113,12 +108,14 @@
onEscape={handleEscape}
assetInteraction={assetMultiSelectManager}
showArchiveIcon
withStacked
/>
</div>
</aside>
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<Portal target="body">
@@ -135,13 +132,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
@@ -481,7 +481,10 @@
<ArchiveAction
menuItem
unarchive={assetMultiSelectManager.isAllArchived}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
onArchive={(ids, visibility) =>
timelineManager.update(ids, (asset) => {
asset.visibility = visibility;
})}
/>
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
<TagAction menuItem />
@@ -14,7 +14,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -26,13 +25,9 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getStackBulkActions } from '$lib/services/stack.service';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -45,7 +40,6 @@
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -114,6 +108,7 @@
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
@@ -128,13 +123,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
@@ -14,7 +14,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -24,18 +23,14 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, AssetOrderBy } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { getStackBulkActions } from '$lib/services/stack.service';
type Props = {
data: PageData;
@@ -52,7 +47,6 @@
};
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -108,6 +102,7 @@
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
@@ -122,13 +117,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem