Compare commits

..

1 Commits

Author SHA1 Message Date
Mees Frensel
1b3d36d1e0 chore(web): refactor activity status 2026-03-17 15:04:54 +01:00
97 changed files with 462 additions and 537 deletions

View File

@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -42,10 +42,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
category: '/language:${{matrix.language}}'

View File

@@ -67,10 +67,10 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'

View File

@@ -29,10 +29,10 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -63,13 +63,13 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -30,10 +30,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -75,9 +75,9 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -119,9 +119,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -166,9 +166,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -208,9 +208,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -252,9 +252,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -290,9 +290,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -338,9 +338,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -385,9 +385,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -424,9 +424,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -496,9 +496,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
python-version: 3.11
- name: Install dependencies
@@ -661,9 +661,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -712,9 +712,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -774,9 +774,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -210,7 +210,7 @@ The provided restore process ensures your database is never in a broken state by
## Filesystem
Immich does not handle filesystem backups for you. You have to arrange these yourself! Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
Immich stores two types of content in the filesystem: (a) original, unmodified assets (photos and videos), and (b) generated content. We recommend backing up the entire contents of `UPLOAD_LOCATION`, but only the original content is critical, which is stored in the following folders:
1. `UPLOAD_LOCATION/library`
2. `UPLOAD_LOCATION/upload`

View File

@@ -64,20 +64,6 @@ test.describe('Photo Viewer', () => {
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('right-click targets the img element', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const box = await preview.boundingBox();
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2,
});
expect(tagAtCenter).toBe('IMG');
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);

View File

@@ -1651,7 +1651,6 @@
"only_favorites": "Only favorites",
"open": "Open",
"open_calendar": "Open calendar",
"open_in_browser": "Open in browser",
"open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters",

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget {
if (confirm != true) return;
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget {
false;
if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_permanently_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserActionButton extends ConsumerWidget {
final String remoteId;
final TimelineOrigin origin;
final bool iconOnly;
final bool menuItem;
final Color? iconColor;
const OpenInBrowserActionButton({
super.key,
required this.remoteId,
required this.origin,
this.iconOnly = false,
this.menuItem = false,
this.iconColor,
});
void _onTap() async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
String originPath = '';
switch (origin) {
case TimelineOrigin.favorite:
originPath = '/favorites';
break;
case TimelineOrigin.trash:
originPath = '/trash';
break;
case TimelineOrigin.archive:
originPath = '/archive';
break;
default:
break;
}
final url = '$serverEndpoint$originPath/photos/$remoteId';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
label: 'open_in_browser'.t(context: context),
iconData: Icons.open_in_browser,
iconColor: iconColor,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: _onTap,
);
}
}

View File

@@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_album_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -81,17 +81,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
bool _assetReloadRequested = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
if (page == null) return;
final target = page + direction;
final maxPage = _totalAssets - 1;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target);
_onAssetChanged(target);
}
@@ -139,6 +141,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
@@ -150,9 +153,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onAssetChanged(int index) async {
_currentPage = index;
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null) return;
AssetViewer._setAsset(ref, asset);
@@ -191,20 +193,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
case TimelineReloadEvent():
_onTimelineReloadEvent();
case ViewerReloadAssetEvent():
_onViewerReloadEvent();
_assetReloadRequested = true;
default:
}
}
void _onViewerReloadEvent() {
if (_totalAssets <= 1) return;
final index = _pageController.page?.round() ?? 0;
final target = index >= _totalAssets - 1 ? index - 1 : index + 1;
_pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut);
_onAssetChanged(target);
}
void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets;
@@ -214,24 +207,43 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index);
}
}
if (index != _currentPage) {
if (index >= totalAssets) {
index = totalAssets - 1;
_currentPage = index;
_pageController.jumpToPage(index);
_onAssetChanged(index);
} else if (currentAsset != null && assetIndex == null) {
_onAssetChanged(index);
}
if (_totalAssets != totalAssets) {
setState(() {
_totalAssets = totalAssets;
});
if (_assetReloadRequested) {
_assetReloadRequested = false;
_onAssetReloadEvent(index);
}
}
void _onAssetReloadEvent(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) return;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
// Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) return;
_onAssetChanged(index);
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
@@ -289,7 +301,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemCount: ref.read(timelineServiceProvider).totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),

View File

@@ -18,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
@@ -76,7 +75,6 @@ enum ActionButtonType {
viewInTimeline,
download,
upload,
openInBrowser,
unstack,
archive,
unarchive,
@@ -151,7 +149,6 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -239,13 +236,6 @@ enum ActionButtonType {
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
iconOnly: iconOnly,
menuItem: menuItem,
iconColor: context.originalTheme?.iconTheme.color,
),
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly,

View File

@@ -20,7 +20,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
@@ -52,11 +52,6 @@ export class MaintenanceWorkerController {
return this.service.getSystemConfig();
}
@Get('server/ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('server/version')
getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion();

View File

@@ -12,7 +12,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
@@ -121,10 +121,6 @@ export class MaintenanceWorkerService {
return ServerVersionResponseDto.fromSemVer(serverVersion);
}
ping(): ServerPingResponse {
return { res: 'pong' };
}
/**
* {@link _ApiService.ssr}
*/

View File

@@ -3,16 +3,13 @@ import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { NextFunction, RequestHandler } from 'express';
import multer from 'multer';
import multer, { StorageEngine, diskStorage } from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { pipeline } from 'node:stream';
import { Observable } from 'rxjs';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { RouteKey } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
@@ -29,6 +26,8 @@ export function getFiles(files: UploadFiles) {
};
}
type DiskStorageCallback = (error: Error | null, result: string) => void;
type ImmichMulterFile = Express.Multer.File & { uuid: string };
interface Callback<T> {
@@ -36,21 +35,34 @@ interface Callback<T> {
(error: null, result: T): void;
}
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
try {
return callback(null, target());
} catch (error: Error | any) {
return callback(error);
}
};
@Injectable()
export class FileUploadInterceptor implements NestInterceptor {
private handlers: {
userProfile: RequestHandler;
assetUpload: RequestHandler;
};
private defaultStorage: StorageEngine;
constructor(
private reflect: Reflector,
private assetService: AssetMediaService,
private storageRepository: StorageRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(FileUploadInterceptor.name);
this.defaultStorage = diskStorage({
filename: this.filename.bind(this),
destination: this.destination.bind(this),
});
const instance = multer({
fileFilter: this.fileFilter.bind(this),
storage: {
@@ -87,60 +99,60 @@ export class FileUploadInterceptor implements NestInterceptor {
}
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
try {
callback(null, this.assetService.canUploadFile(asUploadRequest(request, file)));
} catch (error: Error | any) {
callback(error);
}
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
}
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
(file as ImmichMulterFile).uuid = randomUUID();
request.on('error', (error) => {
this.logger.warn('Request error while uploading file, cleaning up', error);
this.assetService.onUploadError(request, file).catch(this.logger.error);
});
try {
(file as ImmichMulterFile).uuid = randomUUID();
const uploadRequest = asUploadRequest(request, file);
const path = join(
this.assetService.getUploadFolder(uploadRequest),
this.assetService.getUploadFilename(uploadRequest),
);
const writeStream = this.storageRepository.createWriteStream(path);
const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null;
let size = 0;
file.stream.on('data', (chunk) => {
hash?.update(chunk);
size += chunk.length;
});
pipeline(file.stream, writeStream, (error) => {
if (error) {
hash?.destroy();
return callback(error);
}
callback(null, {
path,
size,
checksum: hash?.digest(),
});
});
} catch (error: Error | any) {
callback(error);
if (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(request, file, callback);
return;
}
const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk));
this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) {
hash.destroy();
callback(error);
} else {
callback(null, { ...info, checksum: hash.digest() });
}
});
}
private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.storageRepository
.unlink(file.path)
.then(() => callback(null))
.catch(callback);
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(request, file, callback);
}
private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA: {
return true;
}
}
return false;
}
private getHandler(route: RouteKey) {

View File

@@ -63,7 +63,7 @@ export class StorageRepository {
}
createWriteStream(filepath: string): Writable {
return createWriteStream(filepath, { flags: 'w', flush: true });
return createWriteStream(filepath, { flags: 'w' });
}
createOrOverwriteFile(filepath: string, buffer: Buffer) {

View File

@@ -330,7 +330,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
width: null,
height: null,
});
@@ -360,7 +360,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
width: null,
height: null,
});

View File

@@ -19,7 +19,7 @@ import {
UserLike,
} from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newSha1, newUuid, newUuidV7 } from 'test/small.factory';
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFactory {
#owner!: UserFactory;
@@ -43,12 +43,10 @@ export class AssetFactory {
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
let now = Date.now();
return new AssetFactory({
id,
createdAt: new Date(now++),
updatedAt: new Date(now++),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
@@ -57,14 +55,14 @@ export class AssetFactory {
deviceId: '',
duplicateId: null,
duration: null,
fileCreatedAt: new Date(now++),
fileModifiedAt: new Date(now++),
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: new Date(now),
localDateTime: newDate(),
originalFileName,
originalPath: `/data/library/${originalFileName}`,
ownerId: newUuid(),

View File

@@ -36,7 +36,7 @@
onLoad={() => adaptiveImageLoader.onLoad(quality)}
onError={() => adaptiveImageLoader.onError(quality)}
bind:ref
class="h-full w-full bg-transparent pointer-events-auto"
class="h-full w-full bg-transparent"
{alt}
{role}
draggable={false}

View File

@@ -112,7 +112,7 @@
switch (dto.command) {
case QueueCommand.Empty: {
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
break;
}
}

View File

@@ -55,7 +55,7 @@
try {
await unlinkAllOAuthAccountsAdmin();
toastManager.primary();
toastManager.success();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -45,7 +45,7 @@
},
});
toastManager.primary($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
toastManager.success($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
if (!disabled) {
await handleSystemConfigSave({ notifications: configToEdit.notifications });

View File

@@ -65,7 +65,6 @@
{#each albums as album, index (album.id)}
<a
href={Route.viewAlbum(album)}
class="h-fit"
animate:flip={{ duration: 400 }}
oncontextmenu={(event) => oncontextmenu(event, album)}
>

View File

@@ -4,7 +4,6 @@
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { tv } from 'tailwind-variants';
interface Props {
id: string;
@@ -37,22 +36,14 @@
return;
}
};
const styles = tv({
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
variants: {
isOwned: {
true: 'hover:border-gray-400',
false: 'hover:border-transparent',
},
},
});
</script>
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onblur={handleUpdateName}
class={styles({ isOwned })}
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
? 'hover:border-gray-400'
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
type="text"
bind:value={newAlbumName}
disabled={!isOwned}

View File

@@ -40,7 +40,7 @@
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'));
toastManager.success($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}

View File

@@ -21,7 +21,7 @@
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.primary($t('restored_asset'));
toastManager.success($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}

View File

@@ -23,7 +23,7 @@
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.primary($t('album_cover_updated'));
toastManager.success($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}

View File

@@ -31,7 +31,7 @@
person,
});
toastManager.primary($t('feature_photo_updated'));
toastManager.success($t('feature_photo_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_set_feature_photo'));
}

View File

@@ -2,7 +2,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { ActivityResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { Button } from '@immich/ui';
import { mdiCommentOutline, mdiThumbUp, mdiThumbUpOutline } from '@mdi/js';
interface Props {
@@ -16,21 +16,32 @@
let { isLiked, numberOfComments, numberOfLikes, disabled, onFavorite }: Props = $props();
</script>
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} onclick={onFavorite} {disabled}>
<div class="flex gap-2 items-center justify-center">
<Icon icon={isLiked ? mdiThumbUp : mdiThumbUpOutline} size="24" class={isLiked ? 'text-primary' : 'text-fg'} />
{#if numberOfLikes}
<div class="text-l">{numberOfLikes.toLocaleString($locale)}</div>
{/if}
</div>
</button>
<button type="button" onclick={() => assetViewerManager.toggleActivityPanel()}>
<div class="flex gap-2 items-center justify-center">
<Icon icon={mdiCommentOutline} class="scale-x-[-1]" size="24" />
{#if numberOfComments}
<div class="text-l">{numberOfComments.toLocaleString($locale)}</div>
{/if}
</div>
</button>
<div class="flex p-1 items-center justify-center rounded-full gap-1 bg-subtle/70 border">
<Button
{disabled}
onclick={onFavorite}
leadingIcon={isLiked ? mdiThumbUp : mdiThumbUpOutline}
shape="round"
size="large"
variant="ghost"
color={isLiked ? 'primary' : 'secondary'}
class="p-3"
>
{#if numberOfLikes}
{numberOfLikes.toLocaleString($locale)}
{/if}
</Button>
<Button
onclick={() => assetViewerManager.toggleActivityPanel()}
leadingIcon={mdiCommentOutline}
shape="round"
size="large"
variant="ghost"
color="secondary"
class="p-3"
>
{#if numberOfComments}
{numberOfComments.toLocaleString($locale)}
{/if}
</Button>
</div>

View File

@@ -74,7 +74,7 @@
[ReactionType.Comment]: $t('comment_deleted'),
[ReactionType.Like]: $t('like_deleted'),
};
toastManager.primary(deleteMessages[reaction.type]);
toastManager.success(deleteMessages[reaction.type]);
} catch (error) {
handleError(error, $t('errors.unable_to_remove_reaction'));
}

View File

@@ -22,7 +22,6 @@
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user } from '$lib/stores/user.store';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
@@ -89,7 +88,7 @@
title: $t('go_back'),
type: $t('assets'),
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
$if: () => !!onClose && !isFaceEditMode.value,
$if: () => !!onClose,
onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }],
});

View File

@@ -22,7 +22,7 @@
}
try {
await updateAsset({ id: asset.id, updateAssetDto: { description } });
toastManager.primary($t('asset_description_updated'));
toastManager.success($t('asset_description_updated'));
} catch (error) {
handleError(error, $t('cannot_update_the_description'));
}

View File

@@ -6,7 +6,6 @@
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { shortcut } from '$lib/actions/shortcut';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
@@ -290,8 +289,6 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel }} />
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"

View File

@@ -67,7 +67,7 @@
if (failCount > 0) {
toastManager.warning($t('errors.unable_to_change_visibility', { values: { count: failCount } }));
}
toastManager.primary($t('visibility_changed', { values: { count: successCount } }));
toastManager.success($t('visibility_changed', { values: { count: successCount } }));
}
for (const person of people) {

View File

@@ -72,7 +72,7 @@
});
const mergedPerson = await getPerson({ id: person.id });
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('merged_people_count', { values: { count } }));
toastManager.success($t('merged_people_count', { values: { count } }));
onMerge(mergedPerson);
} catch (error) {
handleError(error, $t('cannot_merge_people'));

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
@@ -26,6 +25,7 @@
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props {
assetId: string;
@@ -126,7 +126,7 @@
}
}
toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } }));
toastManager.success($t('people_edits_count', { values: { count: numberOfChanges } }));
} catch (error) {
handleError(error, $t('errors.cant_apply_changes'));
}

View File

@@ -71,7 +71,7 @@
disableButtons = true;
const data = await createPerson({ personCreateDto: {} });
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
toastManager.primary($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
toastManager.success($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
} catch (error) {
handleError(error, $t('errors.unable_to_reassign_assets_new_person'));
} finally {
@@ -88,7 +88,7 @@
disableButtons = true;
if (selectedPerson) {
await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } });
toastManager.primary(
toastManager.success(
$t('reassigned_assets_to_existing_person', {
values: { count: assetIds.length, name: selectedPerson.name || null },
}),

View File

@@ -63,9 +63,8 @@
let playerInitialized = $state(false);
let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined);
const currentAssetId = $derived(current?.asset.id);
const currentMemoryAssetFull = $derived.by(async () =>
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
let viewerAssets = $derived([
@@ -206,7 +205,7 @@
}
await memoryStore.deleteMemory(current.memory.id);
toastManager.primary($t('removed_memory'));
toastManager.success($t('removed_memory'));
init(page);
};
@@ -217,7 +216,7 @@
const newSavedState = !current.memory.isSaved;
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
toastManager.success(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
init(page);
};
@@ -551,18 +550,14 @@
</div>
<div>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{/if}
{/await}
<IconButton
href={Route.photos({ at: current.asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
</div>
</div>
<!-- CONTROL BUTTONS -->

View File

@@ -52,7 +52,7 @@
? openFileUploadDialog()
: fileUploadHandler({ files }));
toastManager.primary();
toastManager.success();
} catch (error) {
handleError(error, $t('errors.unable_to_add_assets_to_shared_link'));
}

View File

@@ -345,10 +345,8 @@
{
shortcut: { key: 'Escape' },
onShortcut: (event) => {
if (isOpen) {
event.stopPropagation();
closeDropdown();
}
event.stopPropagation();
closeDropdown();
},
},
]}

View File

@@ -14,11 +14,11 @@
import { t } from 'svelte-i18n';
import SearchHistoryBox from './search-history-box.svelte';
type Props = {
interface Props {
value?: string;
grayTheme: boolean;
searchQuery?: MetadataSearchDto | SmartSearchDto;
};
}
let { value = $bindable(''), grayTheme, searchQuery = {} }: Props = $props();

View File

@@ -1,14 +1,21 @@
<script lang="ts" module>
export interface SearchCameraFilter {
make?: string;
model?: string;
lensModel?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import type { SearchCameraFilter } from '$lib/types';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
interface Props {
filters: SearchCameraFilter;
};
}
let { filters = $bindable() }: Props = $props();

View File

@@ -1,11 +1,18 @@
<script lang="ts" module>
export interface SearchDateFilter {
takenBefore?: DateTime;
takenAfter?: DateTime;
}
</script>
<script lang="ts">
import type { SearchDateFilter } from '$lib/types';
import { DatePicker, Text } from '@immich/ui';
import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
interface Props {
filters: SearchDateFilter;
};
}
let { filters = $bindable() }: Props = $props();

View File

@@ -1,11 +1,19 @@
<script lang="ts" module>
export interface SearchDisplayFilters {
isNotInAlbum: boolean;
isArchive: boolean;
isFavorite: boolean;
}
</script>
<script lang="ts">
import type { SearchDisplayFilters } from '$lib/types';
import { Checkbox, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
interface Props {
filters: SearchDisplayFilters;
};
}
let { filters = $bindable() }: Props = $props();
</script>

View File

@@ -1,15 +1,22 @@
<script lang="ts" module>
export interface SearchLocationFilter {
country?: string;
state?: string;
city?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import type { SearchLocationFilter } from '$lib/types';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
interface Props {
filters: SearchLocationFilter;
};
}
let { filters = $bindable() }: Props = $props();

View File

@@ -9,7 +9,6 @@
import { mdiArrowRight, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
import { tv } from 'tailwind-variants';
interface Props {
selectedPeople: SvelteSet<string>;
@@ -50,16 +49,6 @@
const nameLower = name.toLowerCase();
return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list;
};
const styles = tv({
base: 'flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all',
variants: {
selected: {
true: 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white',
false: 'border-transparent',
},
},
});
</script>
{#await peoplePromise}
@@ -85,7 +74,11 @@
{#each peopleList as person (person.id)}
<button
type="button"
class={styles({ selected: selectedPeople.has(person.id) })}
class="flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all {selectedPeople.has(
person.id,
)
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
: 'border-transparent'}"
onclick={() => togglePersonSelection(person.id)}
>
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />

View File

@@ -5,7 +5,7 @@
children?: import('svelte').Snippet<[{ itemCount: number }]>;
}
let { class: className, itemCount = $bindable(1), children }: Props = $props();
let { class: className = '', itemCount = $bindable(1), children }: Props = $props();
let container: HTMLElement | undefined = $state();
let contentRect: DOMRectReadOnly | undefined = $state();

View File

@@ -30,7 +30,7 @@
if ($stats.errors > 0) {
toastManager.danger($t('upload_errors', { values: { count: $stats.errors } }));
} else if ($stats.success > 0) {
toastManager.primary($t('upload_success'));
toastManager.success($t('upload_success'));
}
if ($stats.duplicates > 0) {
toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }));

View File

@@ -42,7 +42,7 @@
onFavorite?.(ids, isFavorite);
toastManager.primary(
toastManager.success(
isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),

View File

@@ -38,7 +38,7 @@
onRemove?.(ids);
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
toastManager.success($t('assets_removed_count', { values: { count } }));
clearSelect();
} catch (error) {

View File

@@ -24,7 +24,7 @@
const ids = [...getAssets()].map((a) => a.id);
await restoreAssets({ bulkIdsDto: { ids } });
onRestore?.(ids);
toastManager.primary($t('assets_restored_count', { values: { count: ids.length } }));
toastManager.success($t('assets_restored_count', { values: { count: ids.length } }));
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));

View File

@@ -22,7 +22,7 @@
try {
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
resetForm();
toastManager.primary($t('pin_code_changed_successfully'));
toastManager.success($t('pin_code_changed_successfully'));
} catch (error) {
handleError(error, $t('unable_to_change_pin_code'));
} finally {

View File

@@ -26,7 +26,7 @@
isLoading = true;
try {
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
toastManager.primary($t('pin_code_setup_successfully'));
toastManager.success($t('pin_code_setup_successfully'));
onCreated?.(newPinCode);
resetForm();
} catch (error) {

View File

@@ -49,22 +49,25 @@
const handleInput = (event: Event, index: number) => {
const target = event.target as HTMLInputElement;
const digits = target.value.replaceAll(/\D/g, '').slice(0, pinLength - index);
let currentPinValue = target.value;
if (digits.length === 0) {
if (target.value.length > 1) {
currentPinValue = value.slice(0, 1);
}
if (Number.isNaN(Number(value))) {
pinValues[index] = '';
value = pinValues.join('').trim();
target.value = '';
return;
}
for (let i = 0; i < digits.length; i++) {
pinValues[index + i] = digits[i];
}
pinValues[index] = currentPinValue;
value = pinValues.join('').trim();
const lastFilledIndex = Math.min(index + digits.length, pinLength - 1);
pinCodeInputElements[lastFilledIndex]?.focus();
if (value && index < pinLength - 1) {
focusNext(index);
}
if (value.length === pinLength) {
onFilled?.(value);
@@ -101,6 +104,12 @@
}
return;
}
default: {
if (Number.isNaN(Number(event.key))) {
event.preventDefault();
}
break;
}
}
}
</script>
@@ -116,6 +125,7 @@
{type}
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
bind:this={pinCodeInputElements[index]}
id="pin-code-{index}"
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"

View File

@@ -25,7 +25,7 @@
try {
await deleteSession({ id: device.id });
toastManager.primary($t('logged_out_device'));
toastManager.success($t('logged_out_device'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_device'));
} finally {
@@ -41,7 +41,7 @@
try {
await deleteAllSessions();
toastManager.primary($t('logged_out_all_devices'));
toastManager.success($t('logged_out_all_devices'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_all_devices'));
} finally {

View File

@@ -25,7 +25,7 @@
});
$preferences = newPreferences;
toastManager.primary($t('saved_settings'));
toastManager.success($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -53,7 +53,7 @@
$preferences = { ...data };
toastManager.primary($t('saved_settings'));
toastManager.success($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -26,7 +26,7 @@
$preferences.emailNotifications.albumInvite = data.emailNotifications.albumInvite;
$preferences.emailNotifications.albumUpdate = data.emailNotifications.albumUpdate;
toastManager.primary($t('saved_settings'));
toastManager.success($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -22,7 +22,7 @@
try {
loading = true;
user = await oauth.link(globalThis.location);
toastManager.primary($t('linked_oauth_account'));
toastManager.success($t('linked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_link_oauth_account'));
} finally {
@@ -36,7 +36,7 @@
const handleUnlink = async () => {
try {
user = await oauth.unlink();
toastManager.primary($t('unlinked_oauth_account'));
toastManager.success($t('unlinked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_account'));
}

View File

@@ -23,7 +23,7 @@
Object.assign(editedUser, data);
$user = data;
toastManager.primary($t('saved_profile'));
toastManager.success($t('saved_profile'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_profile'));
}

View File

@@ -142,7 +142,7 @@ export class EditManager {
eventManager.emit('AssetEditsApplied', assetId);
toastManager.primary(t('editor_edits_applied_success'));
toastManager.success(t('editor_edits_applied_success'));
this.hasAppliedEdits = true;
return true;

View File

@@ -20,7 +20,7 @@
await deleteProfileImage();
}
toastManager.primary($t('saved_profile'));
toastManager.success($t('saved_profile'));
$user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
onClose();

View File

@@ -38,7 +38,7 @@
id: personToBeMergedInto.id,
mergePersonDto: { ids: [personToMerge.id] },
});
toastManager.primary($t('merge_people_successfully'));
toastManager.success($t('merge_people_successfully'));
onClose([personToMerge, personToBeMergedInto]);
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));

View File

@@ -69,7 +69,7 @@
}
const file = new File([blob], 'profile-picture.png', { type: 'image/png' });
const { profileImagePath, profileChangedAt } = await createProfileImage({ createProfileImageDto: { file } });
toastManager.primary($t('profile_picture_set'));
toastManager.success($t('profile_picture_set'));
$user.profileImagePath = profileImagePath;
$user.profileChangedAt = profileChangedAt;

View File

@@ -1,5 +1,28 @@
<script lang="ts" module>
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import type { SearchDateFilter } from '../components/shared-components/search-bar/search-date-section.svelte';
import type { SearchDisplayFilters } from '../components/shared-components/search-bar/search-display-section.svelte';
import type { SearchLocationFilter } from '../components/shared-components/search-bar/search-location-section.svelte';
export type SearchFilter = {
query: string;
ocr?: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
camera: SearchCameraFilter;
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number | null;
};
</script>
<script lang="ts">
import SearchCameraSection from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
import SearchCameraSection, {
type SearchCameraFilter,
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
import SearchDateSection from '$lib/components/shared-components/search-bar/search-date-section.svelte';
import SearchDisplaySection from '$lib/components/shared-components/search-bar/search-display-section.svelte';
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
@@ -8,9 +31,7 @@
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import { preferences } from '$lib/stores/user.store';
import type { SearchFilter } from '$lib/types';
import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
@@ -20,10 +41,10 @@
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
type Props = {
interface Props {
searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
};
}
let { searchQuery, onClose }: Props = $props();
@@ -45,57 +66,52 @@
return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART;
}
const asFilter = (searchQuery: SmartSearchDto | MetadataSearchDto): SearchFilter => {
let query = '';
if ('query' in searchQuery && searchQuery.query) {
query = searchQuery.query;
}
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
let query = '';
if ('query' in searchQuery && searchQuery.query) {
query = searchQuery.query;
}
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
return {
query,
ocr: searchQuery.ocr,
queryType: defaultQueryType(),
queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined,
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
'tagIds' in searchQuery
? searchQuery.tagIds === null
? null
: new SvelteSet(searchQuery.tagIds)
: new SvelteSet(),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
lensModel: withNullAsUndefined(searchQuery.lensModel),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite ?? false,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
};
};
let filter: SearchFilter = $state(asFilter(searchQuery));
let filter: SearchFilter = $state({
query,
ocr: searchQuery.ocr,
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
'tagIds' in searchQuery
? searchQuery.tagIds === null
? null
: new SvelteSet(searchQuery.tagIds)
: new SvelteSet(),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
lensModel: withNullAsUndefined(searchQuery.lensModel),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite ?? false,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
});
const resetForm = () => {
filter = {
@@ -129,7 +145,6 @@
let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
queryAssetId: filter.queryAssetId || undefined,
ocr: filter.queryType === 'ocr' ? query : undefined,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,

View File

@@ -163,7 +163,7 @@ const notifyAddToAlbums = (
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.primary(
toastManager.success(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
@@ -269,7 +269,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
await deleteAlbum({ id: album.id });
eventManager.emit('AlbumDelete', album);
if (notify) {
toastManager.primary();
toastManager.success();
}
return true;
} catch (error) {

View File

@@ -80,7 +80,7 @@ export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpda
try {
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
eventManager.emit('ApiKeyUpdate', response);
toastManager.primary($t('saved_api_key'));
toastManager.success($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
@@ -98,7 +98,7 @@ export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
try {
await deleteApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyDelete', apiKey);
toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } }));
toastManager.success($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}

View File

@@ -11,7 +11,7 @@ import { vitest } from 'vitest';
vitest.mock('@immich/ui', () => ({
toastManager: {
primary: vitest.fn(),
success: vitest.fn(),
},
}));
@@ -67,7 +67,7 @@ describe('AssetService', () => {
const asset = assetFactory.build({ originalFileName: 'asset.heic' });
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
expect(toastManager.success).toHaveBeenCalledWith('formatter');
});
it('should use the motion asset originalFileName when showing toasts', async () => {
@@ -79,7 +79,7 @@ describe('AssetService', () => {
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect($t).toHaveBeenNthCalledWith(2, 'downloading_asset_filename', { values: { filename: 'asset.mov' } });
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
expect(toastManager.success).toHaveBeenCalledWith('formatter');
});
});
});

View File

@@ -334,7 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
}
try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
toastManager.success($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
@@ -352,7 +352,7 @@ const handleFavorite = async (asset: AssetResponseDto) => {
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } });
toastManager.primary($t('added_to_favorites'));
toastManager.success($t('added_to_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
@@ -364,7 +364,7 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } });
toastManager.primary($t('removed_from_favorites'));
toastManager.success($t('removed_from_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
@@ -387,7 +387,7 @@ const handleRunAssetJob = async (dto: AssetJobsDto) => {
try {
await runAssetJobs({ assetJobsDto: dto });
toastManager.primary(getAssetJobMessage($t, dto.name));
toastManager.success(getAssetJobMessage($t, dto.name));
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}

View File

@@ -8,7 +8,7 @@ export const handleCreateJob = async (dto: JobCreateDto) => {
try {
await createJob({ jobCreateDto: dto });
toastManager.primary($t('admin.job_created'));
toastManager.success($t('admin.job_created'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));

View File

@@ -161,7 +161,7 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
try {
const library = await createLibrary({ createLibraryDto: dto });
eventManager.emit('LibraryCreate', library);
toastManager.primary($t('admin.library_created', { values: { library: library.name } }));
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
return library;
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
@@ -174,7 +174,7 @@ export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: Upda
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.primary($t('admin.library_updated'));
toastManager.success($t('admin.library_updated'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
@@ -205,7 +205,7 @@ const handleDeleteLibrary = async (library: LibraryResponseDto) => {
try {
await deleteLibrary({ id: library.id });
eventManager.emit('LibraryDelete', { id: library.id });
toastManager.primary($t('admin.library_deleted'));
toastManager.success($t('admin.library_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_library'));
}
@@ -225,7 +225,7 @@ export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder
updateLibraryDto: { importPaths: [...library.importPaths, folder] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.primary($t('admin.library_updated'));
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -246,7 +246,7 @@ export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldVa
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.primary($t('admin.library_updated'));
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -273,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.primary($t('admin.library_updated'));
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}
@@ -293,7 +293,7 @@ export const handleAddLibraryExclusionPattern = async (library: LibraryResponseD
updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.primary($t('admin.library_updated'));
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -314,7 +314,7 @@ export const handleEditExclusionPattern = async (library: LibraryResponseDto, ol
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.primary($t('admin.library_updated'));
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -339,7 +339,7 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
},
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.primary($t('admin.library_updated'));
toastManager.success($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}

View File

@@ -57,7 +57,7 @@ const handleFavoritePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: true } });
eventManager.emit('PersonUpdate', response);
toastManager.primary($t('added_to_favorites'));
toastManager.success($t('added_to_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
}
@@ -69,7 +69,7 @@ const handleUnfavoritePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: false } });
eventManager.emit('PersonUpdate', response);
toastManager.primary($t('removed_from_favorites'));
toastManager.success($t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
}
@@ -80,7 +80,7 @@ const handleHidePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: true } });
toastManager.primary($t('changed_visibility_successfully'));
toastManager.success($t('changed_visibility_successfully'));
eventManager.emit('PersonUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
@@ -92,7 +92,7 @@ const handleShowPerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: false } });
toastManager.primary($t('changed_visibility_successfully'));
toastManager.success($t('changed_visibility_successfully'));
eventManager.emit('PersonUpdate', response);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
@@ -104,7 +104,7 @@ export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, bir
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
toastManager.primary($t('date_of_birth_saved'));
toastManager.success($t('date_of_birth_saved'));
eventManager.emit('PersonUpdate', response);
return true;
} catch (error) {

View File

@@ -129,7 +129,7 @@ export const handleEmptyQueue = async (queue: QueueResponseDto) => {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: false } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
@@ -155,7 +155,7 @@ const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: true } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.primary();
toastManager.success();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -95,7 +95,7 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto,
const response = await updateSharedLink({ id: sharedLink.id, sharedLinkEditDto: dto });
eventManager.emit('SharedLinkUpdate', { album: sharedLink.album, ...response });
toastManager.primary($t('saved'));
toastManager.success($t('saved'));
return true;
} catch (error) {
@@ -118,7 +118,7 @@ const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
try {
await removeSharedLink({ id: sharedLink.id });
eventManager.emit('SharedLinkDelete', sharedLink);
toastManager.primary($t('deleted_shared_link'));
toastManager.success($t('deleted_shared_link'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_shared_link'));
}
@@ -150,7 +150,7 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons
}
const count = results.filter((item) => item.success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
toastManager.success($t('assets_removed_count', { values: { count } }));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_remove_assets_from_shared_link'));

View File

@@ -62,7 +62,7 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
const newConfig = await updateConfig({ systemConfigDto });
eventManager.emit('SystemConfigUpdate', newConfig);
toastManager.primary($t('settings_saved'));
toastManager.success($t('settings_saved'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_settings'));
}

View File

@@ -42,7 +42,7 @@ export const handleCreateTag = async (tagValue: string) => {
return;
}
toastManager.primary($t('tag_created', { values: { tag: tag.value } }));
toastManager.success($t('tag_created', { values: { tag: tag.value } }));
eventManager.emit('TagCreate', tag);
return true;
@@ -61,7 +61,7 @@ export const handleUpdateTag = async (tag: TreeNode, dto: TagUpdateDto) => {
try {
const response = await updateTag({ id: tag.id, tagUpdateDto: dto });
toastManager.primary($t('tag_updated', { values: { tag: tag.value } }));
toastManager.success($t('tag_updated', { values: { tag: tag.value } }));
eventManager.emit('TagUpdate', response);
return true;
@@ -91,7 +91,7 @@ const handleDeleteTag = async (tag: TreeNode) => {
try {
await deleteTag({ id: tagId });
eventManager.emit('TagDelete', tag);
toastManager.primary();
toastManager.success();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -31,7 +31,7 @@ export const handleEmptyTrash = async () => {
try {
const { count } = await emptyTrash();
toastManager.primary($t('assets_permanently_deleted_count', { values: { count } }));
toastManager.success($t('assets_permanently_deleted_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_empty_trash'));
}
@@ -47,7 +47,7 @@ export const handleRestoreTrash = async () => {
try {
const { count } = await restoreTrash();
toastManager.primary($t('assets_restored_count', { values: { count } }));
toastManager.success($t('assets_restored_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_trash'));
}

View File

@@ -109,7 +109,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
try {
const response = await createUserAdmin({ userAdminCreateDto: dto });
eventManager.emit('UserAdminCreate', response);
toastManager.primary();
toastManager.success();
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
@@ -122,7 +122,7 @@ export const handleUpdateUserAdmin = async (user: UserAdminResponseDto, dto: Use
try {
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
eventManager.emit('UserAdminUpdate', response);
toastManager.primary();
toastManager.success();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_user'));
@@ -136,7 +136,7 @@ export const handleDeleteUserAdmin = async (user: UserAdminResponseDto, dto: Use
try {
const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: dto });
eventManager.emit('UserAdminDelete', result);
toastManager.primary();
toastManager.success();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_user'));
@@ -149,7 +149,7 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => {
try {
const response = await restoreUserAdmin({ id: user.id });
eventManager.emit('UserAdminRestore', response);
toastManager.primary();
toastManager.success();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_restore_user'));
@@ -190,7 +190,7 @@ const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
const dto = { password: generatePassword(), shouldChangePassword: true };
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
eventManager.emit('UserAdminUpdate', response);
toastManager.primary();
toastManager.success();
await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_password'));
@@ -208,7 +208,7 @@ const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
try {
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
eventManager.emit('UserAdminUpdate', response);
toastManager.primary($t('pin_code_reset_successfully'));
toastManager.success($t('pin_code_reset_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_reset_pin_code'));
}

View File

@@ -39,7 +39,7 @@ export const handleResetPinCode = async (dto: PinCodeResetDto) => {
try {
await resetPinCode({ pinCodeResetDto: dto });
toastManager.primary($t('pin_code_reset_successfully'));
toastManager.success($t('pin_code_reset_successfully'));
eventManager.emit('UserPinCodeReset');
return true;
} catch (error) {
@@ -52,7 +52,7 @@ export const handleChangePassword = async (dto: ChangePasswordDto) => {
try {
await changePassword({ changePasswordDto: dto });
toastManager.primary($t('updated_password'));
toastManager.success($t('updated_password'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_change_password'));

View File

@@ -397,7 +397,7 @@ export const handleToggleWorkflowEnabled = async (
});
eventManager.emit('WorkflowUpdate', updated);
toastManager.primary($t('workflow_updated'));
toastManager.success($t('workflow_updated'));
return updated;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
@@ -419,7 +419,7 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
try {
await deleteWorkflow({ id: workflow.id });
eventManager.emit('WorkflowDelete', workflow);
toastManager.primary($t('workflow_deleted'));
toastManager.success($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));

View File

@@ -1,9 +1,6 @@
import { MediaType } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity';
export interface ReleaseEvent {
isAvailable: boolean;
@@ -51,41 +48,3 @@ export type AssetControlContext = {
getOwnedAssets: () => TimelineAsset[]; // Only assets owned by the user
clearSelect: () => void;
};
export type SearchCameraFilter = {
make?: string;
model?: string;
lensModel?: string;
};
export type SearchDateFilter = {
takenBefore?: DateTime;
takenAfter?: DateTime;
};
export type SearchDisplayFilters = {
isNotInAlbum: boolean;
isArchive: boolean;
isFavorite: boolean;
};
export type SearchLocationFilter = {
country?: string;
state?: string;
city?: string;
};
export type SearchFilter = {
query: string;
ocr?: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
queryAssetId?: string;
camera: SearchCameraFilter;
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number | null;
};

View File

@@ -49,7 +49,7 @@ export const tagAssets = async ({
if (showNotification) {
const $t = await getFormatter();
toastManager.primary($t('tagged_assets', { values: { count: assetIds.length } }));
toastManager.success($t('tagged_assets', { values: { count: assetIds.length } }));
}
return assetIds;
@@ -70,7 +70,7 @@ export const removeTag = async ({
if (showNotification) {
const $t = await getFormatter();
toastManager.primary($t('removed_tagged_assets', { values: { count: assetIds.length } }));
toastManager.success($t('removed_tagged_assets', { values: { count: assetIds.length } }));
}
return assetIds;
@@ -364,7 +364,7 @@ export const deleteStack = async (stackIds: string[]) => {
await deleteStacks({ bulkIdsDto: { ids: [...ids] } });
toastManager.primary($t('unstacked_assets_count', { values: { count } }));
toastManager.success($t('unstacked_assets_count', { values: { count } }));
const assets = stacks.flatMap((stack) => stack.assets);
for (const asset of assets) {
@@ -385,7 +385,7 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
toastManager.success($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
keepAsset.stack = null;
return keepAsset;
@@ -440,7 +440,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
});
asset.isArchived = data.isArchived;
toastManager.primary(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
toastManager.success(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } }));
}
@@ -459,7 +459,7 @@ export const archiveAssets = async (assets: { id: string }[], visibility: AssetV
});
}
toastManager.primary(
toastManager.success(
visibility === AssetVisibility.Archive
? $t('archived_count', { values: { count: ids.length } })
: $t('unarchived_count', { values: { count: ids.length } }),

View File

@@ -200,7 +200,7 @@
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.primary($t('album_cover_updated'));
toastManager.success($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}
@@ -437,8 +437,8 @@
{/if}
</Timeline>
{#if showActivityStatus && !activityManager.isLoading}
<div class="absolute z-2 bottom-0 end-0 mb-6 me-6 justify-self-end">
{#if showActivityStatus}
<div class="absolute z-2 bottom-0 end-0 mb-6 me-12">
<ActivityStatus
disabled={!album.isActivityEnabled}
isLiked={activityManager.isLiked}

View File

@@ -157,7 +157,7 @@
break;
}
}
toastManager.primary($t('change_name_successfully'));
toastManager.success($t('change_name_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));
}
@@ -178,7 +178,7 @@
return person;
});
toastManager.primary($t('changed_visibility_successfully'));
toastManager.success($t('changed_visibility_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
}
@@ -198,7 +198,7 @@
return person;
});
toastManager.primary(updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
toastManager.success(updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } }));
}

View File

@@ -149,7 +149,7 @@
}
try {
person = await updatePerson({ id: person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
toastManager.primary($t('feature_photo_updated'));
toastManager.success($t('feature_photo_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_set_feature_photo'));
}
@@ -210,7 +210,7 @@
try {
person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
toastManager.primary($t('change_name_successfully'));
toastManager.success($t('change_name_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));
}

View File

@@ -67,7 +67,7 @@
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
let terms = $derived<SearchTerms>(searchQuery ? JSON.parse(searchQuery) : {});
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
$effect(() => {
// we want this to *only* be reactive on `terms`
@@ -137,13 +137,15 @@
const searchDto: SearchTerms = {
page: nextPage,
withExif: true,
isVisible: true,
language: $lang,
...terms,
};
try {
const { albums, assets } =
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
? await searchSmart({ smartSearchDto: { ...searchDto, language: $lang } })
? await searchSmart({ smartSearchDto: searchDto })
: await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items);
@@ -228,7 +230,7 @@
const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => {
cancelMultiselect(assetInteraction);
if (terms.isNotInAlbum) {
if (terms.isNotInAlbum.toString() == 'true') {
const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
}

View File

@@ -95,7 +95,7 @@
const message = featureFlagsManager.value.trash
? $t('assets_moved_to_trash_count', { values: { count: trashedCount } })
: $t('permanently_deleted_assets_count', { values: { count: trashedCount } });
toastManager.primary(message);
toastManager.success(message);
};
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
@@ -167,7 +167,7 @@
duplicates = [];
toastManager.primary($t('resolved_all_duplicates'));
toastManager.success($t('resolved_all_duplicates'));
page.url.searchParams.delete('index');
await goto(Route.duplicatesUtility());
},

View File

@@ -131,7 +131,7 @@
previousWorkflow = updated;
editWorkflow = updated;
toastManager.primary($t('workflow_update_success'), {
toastManager.success($t('workflow_update_success'), {
closable: true,
});
} catch (error) {