Compare commits

..

1 Commits

Author SHA1 Message Date
Santo Shakil 686da0656d fix(mobile): only toggle backup from the switch, not the whole row
tapping anywhere on the enable backup row flipped backup on or off, so it was easy to toggle by accident. now only the switch does it.
2026-06-21 00:29:28 +06:00
18 changed files with 266 additions and 203 deletions
+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.
@@ -106,65 +106,57 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
borderRadius: const BorderRadius.all(Radius.circular(18.5)),
color: context.colorScheme.surfaceContainerLow,
),
child: Material(
color: context.colorScheme.surfaceContainerLow,
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
onTap: () => _onToggle(!_isEnabled),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.primaryColor.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.1),
],
),
),
child: isProcessing
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(Icons.cloud_upload_outlined, color: context.primaryColor, size: 24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
context.primaryColor.withValues(alpha: 0.2),
context.primaryColor.withValues(alpha: 0.1),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
),
child: isProcessing
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: Icon(Icons.cloud_upload_outlined, color: context.primaryColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(
"enable_backup".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
),
],
),
if (errorCount > 0)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
Flexible(
child: Text(
"enable_backup".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
),
],
),
),
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
],
if (errorCount > 0)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
),
),
],
),
),
),
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
],
),
),
),
@@ -20,6 +20,7 @@
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
@@ -68,6 +69,7 @@
onAssetChange?: (asset: AssetResponseDto) => void;
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
@@ -83,6 +85,7 @@
onAssetChange,
preAction,
onAction,
onUndoDelete,
onClose,
onRemoveFromAlbum,
onRandom,
@@ -311,6 +314,11 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
@@ -493,6 +501,7 @@
{stack}
preAction={handlePreAction}
onAction={handleAction}
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
@@ -4,9 +4,11 @@
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 SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
@@ -23,8 +25,9 @@
import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleTrashOrDelete } from '$lib/services/asset.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -34,7 +37,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, shortcut, Tooltip, type ActionItem } from '@immich/ui';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -45,6 +48,7 @@
stack?: StackResponseDto | null;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
@@ -58,6 +62,7 @@
stack = null,
preAction,
onAction,
onUndoDelete = undefined,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
@@ -83,10 +88,6 @@
const sharedLink = getSharedLink();
</script>
<svelte:document
use:shortcut={{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => handleTrashOrDelete(asset, true) }}
/>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
@@ -127,8 +128,10 @@
{/if}
<ActionButton action={Actions.Edit} />
<ActionButton action={Actions.Delete} />
<ActionButton action={Actions.PermanentlyDelete} />
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
{#if !sharedLink}
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
@@ -136,7 +139,10 @@
<ActionMenuItem action={Actions.Download} />
<ActionMenuItem action={Actions.DownloadOriginal} />
<ActionMenuItem action={Actions.Restore} />
{#if !isLocked && asset.isTrashed}
<RestoreAction {asset} {onAction} />
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
@@ -0,0 +1,48 @@
import type { AssetResponseDto } from '@immich/sdk';
import '@testing-library/jest-dom';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
});
it('displays a button to move the asset to the trash bin', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
describe('but if the asset is already trashed', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: true });
});
it('displays a button to permanently delete the asset', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});
});
@@ -0,0 +1,75 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
preAction: PreAction;
onUndoDelete?: OnUndoDelete;
}
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
const trashOrDelete = async (forceRequest?: boolean) => {
const timelineAsset = toTimelineAsset(asset);
const force = forceDefault || forceRequest;
if (force) {
if ($showDeleteModal) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.primary($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
return;
}
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
await deleteAssetsUtil(
false,
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
[timelineAsset],
onUndoDelete,
);
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
onclick={() => trashOrDelete()}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset = $bindable(), onAction }: Props = $props();
const handleRestoreAsset = async () => {
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
</script>
<MenuOption icon={mdiHistory} onClick={handleRestoreAsset} text={$t('restore')} />
@@ -5,6 +5,9 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[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 };
@@ -22,7 +22,6 @@
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
interface Props {
sharedLink: SharedLinkResponseDto;
@@ -64,20 +63,15 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
await goto(Route.photos());
break;
}
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
// Only used for single asset shared link
if (assetIds.includes(assets[0].id)) {
await goto(Route.photos());
}
};
</script>
{#if sharedLink?.allowUpload || assets.length > 1}
@@ -138,8 +132,6 @@
{/if}
</header>
{:else if assets.length === 1}
<OnEvents {onAssetsDelete} />
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
@@ -4,7 +4,6 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
@@ -286,7 +285,9 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
@@ -304,17 +305,6 @@
}
};
const onAssetsDelete = async (assetIds: string[]) => {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets = assets.filter((asset) => !assetIds.includes(asset.id));
if (assets.length === 0) {
return await goto(Route.photos());
}
if (assetIds.includes(assetCursor.current.id) && nextAsset) {
await navigateToAsset(nextAsset);
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
@@ -348,8 +338,6 @@
<svelte:document onselectstart={onSelectStart} use:shortcuts={shortcutList} onscroll={() => updateSlidingWindow()} />
<OnEvents {onAssetsDelete} />
{#if assets.length > 0}
<div
style:position="relative"
@@ -1,12 +1,12 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
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';
@@ -78,14 +78,6 @@
};
};
/** Find the next asset to show or close the viewer */
const navigateOrCloseViewer = async (id: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(id));
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
const asset = assetViewerManager.asset;
@@ -117,20 +109,35 @@
const handleRemoveFromAlbum = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
if (!assetIds.includes(assetCursor.current.id)) {
return;
}
// keep the cleanup workflow in viewer by moving to adjacent asset first
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current.id));
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// must update manager before performing any navigation
timelineManager.removeAssets([action.asset.id]);
await navigateOrCloseViewer(action.asset.id);
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset.id));
break;
}
// no default
@@ -192,19 +199,9 @@
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
}
};
const onAssetsRestore = async (assets: AssetResponseDto[]) => {
timelineManager.upsertAssets(assets.map((a) => toTimelineAsset(a)));
if (assets.length !== 1) {
// don't reopen asset viewer if multiple assets were restored (bulk action)
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
if (assets.length === 0) {
return;
}
@@ -237,8 +234,6 @@
});
</script>
<OnEvents {onAssetsDelete} {onAssetsRestore} />
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
@@ -254,6 +249,7 @@
handleAction(action);
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onRandom={handleRandom}
onRemoveFromAlbum={handleRemoveFromAlbum}
onClose={handleClose}
+3
View File
@@ -3,6 +3,9 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
@@ -36,7 +36,6 @@ export type Events = {
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetsRestore: [AssetResponseDto[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
@@ -31,12 +31,6 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
-77
View File
@@ -3,9 +3,7 @@ import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
deleteAssets,
getAssetInfo,
restoreAssets,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -17,15 +15,12 @@ import {
mdiCogRefreshOutline,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDeleteForeverOutline,
mdiDeleteOutline,
mdiDownload,
mdiDownloadBox,
mdiFaceRecognition,
mdiHeadSyncOutline,
mdiHeart,
mdiHeartOutline,
mdiHistory,
mdiImageRefreshOutline,
mdiInformationOutline,
mdiMagnifyMinusOutline,
@@ -39,18 +34,14 @@ import {
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
@@ -105,7 +96,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const isDeletionPermanent = asset.isTrashed || !featureFlagsManager.value.trash;
const Share: ActionItem = {
title: $t('share'),
@@ -252,29 +242,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }],
};
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDeleteOutline,
$if: () => isOwner && !isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset),
shortcuts: { key: 'Delete' },
};
const PermanentlyDelete: ActionItem = {
title: $t('permanently_delete'),
icon: mdiDeleteForeverOutline,
$if: () => isOwner && isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset, true),
shortcuts: { key: 'Delete', shift: true },
};
const Restore: ActionItem = {
title: $t('restore'),
icon: mdiHistory,
$if: () => asset.visibility !== AssetVisibility.Locked && asset.isTrashed,
onAction: () => handleRestore(asset),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -319,9 +286,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag,
TagPeople,
Edit,
Delete,
PermanentlyDelete,
Restore,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
@@ -403,47 +367,6 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
}
};
export const handleTrashOrDelete = async (asset: AssetResponseDto, force?: boolean) => {
const $t = await getFormatter();
if (force && get(showDeleteModal)) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force } });
eventManager.emit('AssetsDelete', [asset.id]);
if (force) {
toastManager.primary($t('permanently_deleted_asset'));
} else {
toastManager.primary(
{
description: $t('moved_to_trash'),
button: { label: $t('undo'), color: 'secondary', onclick: () => handleRestore(asset) },
},
{ timeout: 5000 },
);
}
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
};
const handleRestore = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
eventManager.emit('AssetsRestore', [asset]);
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import LargeAssetData from './LargeAssetData.svelte';
@@ -36,14 +37,16 @@
return asset;
};
const onAssetsDelete = async (assetIds: string[]) => {
if (assetIds.includes(assetCursor.current.id)) {
const preAction = async (payload: Action) => {
if (payload.type == 'trash') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
assetViewerManager.showAssetViewer(false);
}
};
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id));
};
@@ -81,6 +84,7 @@
cursor={assetCursor}
showNavigation={assets.length > 1}
{onRandom}
{preAction}
onClose={() => {
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));