Compare commits
9 Commits
feat/group
...
v1.137.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3263e50fc | ||
|
|
7391ea6ff9 | ||
|
|
f972b8d514 | ||
|
|
6b50d958f4 | ||
|
|
27c456eb75 | ||
|
|
e7d051db3c | ||
|
|
86d31d7d29 | ||
|
|
f416342eff | ||
|
|
d73335ecbc |
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.73",
|
"version": "2.2.74",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.73",
|
"version": "2.2.74",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.73",
|
"version": "2.2.74",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Once you have a new OAuth client application configured, Immich can be configure
|
|||||||
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
|
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
|
||||||
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
|
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
|
||||||
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
|
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
|
||||||
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
|
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (empty for unlimited quota) |
|
||||||
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
|
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
|
||||||
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
|
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
|
||||||
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
|
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
|
||||||
@@ -106,6 +106,89 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to
|
|||||||
|
|
||||||
## Example Configuration
|
## Example Configuration
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Authelia Example</summary>
|
||||||
|
|
||||||
|
### Authelia Example
|
||||||
|
|
||||||
|
Here's an example of OAuth configured for Authelia:
|
||||||
|
|
||||||
|
This assumes there exist an attribute `immichquota` in the user schema, which is used to set the user's storage quota in Immich.
|
||||||
|
The configuration concerning the quota is optional.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
authentication_backend:
|
||||||
|
ldap:
|
||||||
|
# The LDAP server configuration goes here.
|
||||||
|
# See: https://www.authelia.com/c/ldap
|
||||||
|
attributes:
|
||||||
|
extra:
|
||||||
|
immichquota: # The attribute name from LDAP
|
||||||
|
name: 'immich_quota'
|
||||||
|
multi_valued: false
|
||||||
|
value_type: 'integer'
|
||||||
|
identity_providers:
|
||||||
|
oidc:
|
||||||
|
## The other portions of the mandatory OpenID Connect 1.0 configuration go here.
|
||||||
|
## See: https://www.authelia.com/c/oidc
|
||||||
|
claims_policies:
|
||||||
|
immich_policy:
|
||||||
|
custom_claims:
|
||||||
|
immich_quota:
|
||||||
|
attribute: 'immich_quota'
|
||||||
|
scopes:
|
||||||
|
immich_scope:
|
||||||
|
claims:
|
||||||
|
- 'immich_quota'
|
||||||
|
|
||||||
|
clients:
|
||||||
|
- client_id: 'immich'
|
||||||
|
client_name: 'Immich'
|
||||||
|
# https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-a-client-identifier-or-client-secret
|
||||||
|
client_secret: $pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng'
|
||||||
|
public: false
|
||||||
|
require_pkce: false
|
||||||
|
redirect_uris:
|
||||||
|
- 'https://example.immich.app/auth/login'
|
||||||
|
- 'https://example.immich.app/user-settings'
|
||||||
|
- 'app.immich:///oauth-callback'
|
||||||
|
scopes:
|
||||||
|
- 'openid'
|
||||||
|
- 'profile'
|
||||||
|
- 'email'
|
||||||
|
- 'immich_scope'
|
||||||
|
claims_policy: 'immich_policy'
|
||||||
|
response_types:
|
||||||
|
- 'code'
|
||||||
|
grant_types:
|
||||||
|
- 'authorization_code'
|
||||||
|
id_token_signed_response_alg: 'RS256'
|
||||||
|
userinfo_signed_response_alg: 'RS256'
|
||||||
|
token_endpoint_auth_method: 'client_secret_post'
|
||||||
|
```
|
||||||
|
|
||||||
|
Configuration of OAuth in Immich System Settings
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------- |
|
||||||
|
| Issuer URL | `https://example.immich.app/.well-known/openid-configuration` |
|
||||||
|
| Client ID | immich |
|
||||||
|
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
|
||||||
|
| Token Endpoint Auth Method | client_secret_post |
|
||||||
|
| Scope | openid email profile immich_scope |
|
||||||
|
| ID Token Signed Response Algorithm | RS256 |
|
||||||
|
| Userinfo Signed Response Algorithm | RS256 |
|
||||||
|
| Storage Label Claim | uid |
|
||||||
|
| Storage Quota Claim | immich_quota |
|
||||||
|
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||||
|
| Button Text | Sign in with Authelia (optional) |
|
||||||
|
| Auto Register | Enabled (optional) |
|
||||||
|
| Auto Launch | Enabled (optional) |
|
||||||
|
| Mobile Redirect URI Override | Disable |
|
||||||
|
| Mobile Redirect URI | |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Authentik Example</summary>
|
<summary>Authentik Example</summary>
|
||||||
|
|
||||||
@@ -128,7 +211,7 @@ Configuration of OAuth in Immich System Settings
|
|||||||
| Signing Algorithm | RS256 |
|
| Signing Algorithm | RS256 |
|
||||||
| Storage Label Claim | preferred_username |
|
| Storage Label Claim | preferred_username |
|
||||||
| Storage Quota Claim | immich_quota |
|
| Storage Quota Claim | immich_quota |
|
||||||
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
|
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||||
| Button Text | Sign in with Authentik (optional) |
|
| Button Text | Sign in with Authentik (optional) |
|
||||||
| Auto Register | Enabled (optional) |
|
| Auto Register | Enabled (optional) |
|
||||||
| Auto Launch | Enabled (optional) |
|
| Auto Launch | Enabled (optional) |
|
||||||
@@ -159,7 +242,7 @@ Configuration of OAuth in Immich System Settings
|
|||||||
| Signing Algorithm | RS256 |
|
| Signing Algorithm | RS256 |
|
||||||
| Storage Label Claim | preferred_username |
|
| Storage Label Claim | preferred_username |
|
||||||
| Storage Quota Claim | immich_quota |
|
| Storage Quota Claim | immich_quota |
|
||||||
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
|
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||||
| Button Text | Sign in with Google (optional) |
|
| Button Text | Sign in with Google (optional) |
|
||||||
| Auto Register | Enabled (optional) |
|
| Auto Register | Enabled (optional) |
|
||||||
| Auto Launch | Enabled |
|
| Auto Launch | Enabled |
|
||||||
|
|||||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.137.0",
|
||||||
|
"url": "https://v1.137.0.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.136.0",
|
"label": "v1.136.0",
|
||||||
"url": "https://v1.136.0.archive.immich.app"
|
"url": "https://v1.136.0.archive.immich.app"
|
||||||
|
|||||||
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
"@eslint/eslintrc": "^3.1.0",
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
"../cli": {
|
"../cli": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.73",
|
"version": "2.2.74",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -653,6 +653,7 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"clear_all": "Clear all",
|
"clear_all": "Clear all",
|
||||||
"clear_all_recent_searches": "Clear all recent searches",
|
"clear_all_recent_searches": "Clear all recent searches",
|
||||||
|
"clear_file_cache": "Clear File Cache",
|
||||||
"clear_message": "Clear message",
|
"clear_message": "Clear message",
|
||||||
"clear_value": "Clear value",
|
"clear_value": "Clear value",
|
||||||
"client_cert_dialog_msg_confirm": "OK",
|
"client_cert_dialog_msg_confirm": "OK",
|
||||||
@@ -834,6 +835,7 @@
|
|||||||
"edit_birthday": "Edit Birthday",
|
"edit_birthday": "Edit Birthday",
|
||||||
"edit_date": "Edit date",
|
"edit_date": "Edit date",
|
||||||
"edit_date_and_time": "Edit date and time",
|
"edit_date_and_time": "Edit date and time",
|
||||||
|
"edit_date_and_time_action_prompt": "{count} date and time edited",
|
||||||
"edit_description": "Edit description",
|
"edit_description": "Edit description",
|
||||||
"edit_description_prompt": "Please select a new description:",
|
"edit_description_prompt": "Please select a new description:",
|
||||||
"edit_exclusion_pattern": "Edit exclusion pattern",
|
"edit_exclusion_pattern": "Edit exclusion pattern",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ platform :android do
|
|||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 205,
|
"android.injected.version.code" => 205,
|
||||||
"android.injected.version.name" => "1.136.0",
|
"android.injected.version.name" => "1.137.0",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ platform :ios do
|
|||||||
path: "./Runner.xcodeproj",
|
path: "./Runner.xcodeproj",
|
||||||
)
|
)
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.136.0"
|
version_number: "1.137.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class HashService {
|
|||||||
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
|
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
|
||||||
await _localAssetRepository.updateHashes(hashed);
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
|
await _storageRepository.clearCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,23 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateDateTime(List<String> ids, DateTime dateTime) {
|
||||||
|
return _db.batch((batch) async {
|
||||||
|
for (final id in ids) {
|
||||||
|
batch.update(
|
||||||
|
_db.remoteExifEntity,
|
||||||
|
RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)),
|
||||||
|
where: (e) => e.assetId.equals(id),
|
||||||
|
);
|
||||||
|
batch.update(
|
||||||
|
_db.remoteAssetEntity,
|
||||||
|
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
|
||||||
|
where: (e) => e.id.equals(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> stack(String userId, StackResponse stack) {
|
Future<void> stack(String userId, StackResponse stack) {
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
final stackIds = await _db.managers.stackEntity
|
final stackIds = await _db.managers.stackEntity
|
||||||
|
|||||||
@@ -66,4 +66,14 @@ class StorageRepository {
|
|||||||
}
|
}
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearCache() async {
|
||||||
|
final log = Logger('StorageRepository');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await PhotoManager.clearFileCache();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
log.warning("Error clearing cache", error, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,44 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class EditDateTimeActionButton extends ConsumerWidget {
|
class EditDateTimeActionButton extends ConsumerWidget {
|
||||||
const EditDateTimeActionButton({super.key});
|
final ActionSource source;
|
||||||
|
|
||||||
|
const EditDateTimeActionButton({super.key, required this.source});
|
||||||
|
|
||||||
|
_onTap(BuildContext context, WidgetRef ref) async {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
|
final successMessage = 'edit_date_and_time_action_prompt'.t(
|
||||||
|
context: context,
|
||||||
|
args: {'count': result.count.toString()},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||||
|
gravity: ToastGravity.BOTTOM,
|
||||||
|
toastType: result.success ? ToastType.success : ToastType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -12,6 +46,7 @@ class EditDateTimeActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 95.0,
|
maxWidth: 95.0,
|
||||||
iconData: Icons.edit_calendar_outlined,
|
iconData: Icons.edit_calendar_outlined,
|
||||||
label: "control_bottom_app_bar_edit_time".t(context: context),
|
label: "control_bottom_app_bar_edit_time".t(context: context),
|
||||||
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,12 +143,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
|
|
||||||
|
Future<void> editDateTime() async {
|
||||||
|
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
|
||||||
|
}
|
||||||
|
|
||||||
return SliverList.list(
|
return SliverList.list(
|
||||||
children: [
|
children: [
|
||||||
// Asset Date and Time
|
// Asset Date and Time
|
||||||
_SheetTile(
|
_SheetTile(
|
||||||
title: _getDateTime(context, asset),
|
title: _getDateTime(context, asset),
|
||||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
|
||||||
|
onTap: asset.hasRemote ? () async => await editDateTime() : null,
|
||||||
),
|
),
|
||||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
||||||
const SheetPeopleDetails(),
|
const SheetPeopleDetails(),
|
||||||
@@ -194,11 +200,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
class _SheetTile extends StatelessWidget {
|
class _SheetTile extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
|
final Widget? trailing;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final TextStyle? titleStyle;
|
final TextStyle? titleStyle;
|
||||||
final TextStyle? subtitleStyle;
|
final TextStyle? subtitleStyle;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _SheetTile({required this.title, this.titleStyle, this.leading, this.subtitle, this.subtitleStyle});
|
const _SheetTile({
|
||||||
|
required this.title,
|
||||||
|
this.titleStyle,
|
||||||
|
this.leading,
|
||||||
|
this.subtitle,
|
||||||
|
this.subtitleStyle,
|
||||||
|
this.trailing,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -234,8 +250,10 @@ class _SheetTile extends StatelessWidget {
|
|||||||
title: titleWidget,
|
title: titleWidget,
|
||||||
titleAlignment: ListTileTitleAlignment.center,
|
titleAlignment: ListTileTitleAlignment.center,
|
||||||
leading: leading,
|
leading: leading,
|
||||||
|
trailing: trailing,
|
||||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||||
subtitle: subtitleWidget,
|
subtitle: subtitleWidget,
|
||||||
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
|||||||
context.back();
|
context.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.back();
|
context.pop();
|
||||||
context.pushRoute(DriftPersonRoute(person: person));
|
context.pushRoute(DriftPersonRoute(person: person));
|
||||||
},
|
},
|
||||||
onNameTap: () => showNameEditModal(person),
|
onNameTap: () => showNameEditModal(person),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class GeneralBottomSheet extends ConsumerWidget {
|
|||||||
if (multiselect.hasLocal || multiselect.hasMerged) ...[
|
if (multiselect.hasLocal || multiselect.hasMerged) ...[
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/action.service.dart';
|
import 'package:immich_mobile/services/action.service.dart';
|
||||||
|
import 'package:immich_mobile/services/download.service.dart';
|
||||||
import 'package:immich_mobile/services/timeline.service.dart';
|
import 'package:immich_mobile/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -30,6 +33,7 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
final Logger _logger = Logger('ActionNotifier');
|
final Logger _logger = Logger('ActionNotifier');
|
||||||
late ActionService _service;
|
late ActionService _service;
|
||||||
late UploadService _uploadService;
|
late UploadService _uploadService;
|
||||||
|
late DownloadService _downloadService;
|
||||||
|
|
||||||
ActionNotifier() : super();
|
ActionNotifier() : super();
|
||||||
|
|
||||||
@@ -37,6 +41,29 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
void build() {
|
void build() {
|
||||||
_uploadService = ref.watch(uploadServiceProvider);
|
_uploadService = ref.watch(uploadServiceProvider);
|
||||||
_service = ref.watch(actionServiceProvider);
|
_service = ref.watch(actionServiceProvider);
|
||||||
|
_downloadService = ref.watch(downloadServiceProvider);
|
||||||
|
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||||
|
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||||
|
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadImageCallback(TaskStatusUpdate update) {
|
||||||
|
if (update.status == TaskStatus.complete) {
|
||||||
|
_downloadService.saveImageWithPath(update.task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadVideoCallback(TaskStatusUpdate update) {
|
||||||
|
if (update.status == TaskStatus.complete) {
|
||||||
|
_downloadService.saveVideo(update.task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
|
||||||
|
if (update.status == TaskStatus.complete) {
|
||||||
|
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
|
||||||
|
_downloadService.saveLivePhotos(update.task, livePhotosId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _getRemoteIdsForSource(ActionSource source) {
|
List<String> _getRemoteIdsForSource(ActionSource source) {
|
||||||
@@ -239,6 +266,21 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ActionResult?> editDateTime(ActionSource source, BuildContext context) async {
|
||||||
|
final ids = _getOwnedRemoteIdsForSource(source);
|
||||||
|
try {
|
||||||
|
final isEdited = await _service.editDateTime(ids, context);
|
||||||
|
if (!isEdited) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionResult(count: ids.length, success: true);
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe('Failed to edit date and time for assets', error, stack);
|
||||||
|
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
||||||
final ids = _getRemoteIdsForSource(source);
|
final ids = _getRemoteIdsForSource(source);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
|
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
|
||||||
|
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String()));
|
||||||
|
}
|
||||||
|
|
||||||
Future<StackResponse> stack(List<String> ids) async {
|
Future<StackResponse> stack(List<String> ids) async {
|
||||||
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
|||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -159,6 +160,44 @@ class ActionService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
|
||||||
|
DateTime? initialDate;
|
||||||
|
String? timeZone;
|
||||||
|
Duration? offset;
|
||||||
|
|
||||||
|
if (remoteIds.length == 1) {
|
||||||
|
final assetId = remoteIds.first;
|
||||||
|
final asset = await _remoteAssetRepository.get(assetId);
|
||||||
|
if (asset == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final exifData = await _remoteAssetRepository.getExif(assetId);
|
||||||
|
initialDate = asset.createdAt.toLocal();
|
||||||
|
offset = initialDate.timeZoneOffset;
|
||||||
|
timeZone = exifData?.timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dateTime = await showDateTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialDateTime: initialDate,
|
||||||
|
initialTZ: timeZone,
|
||||||
|
initialTZOffset: offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dateTime == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert dateTime to DateTime object
|
||||||
|
final parsedDateTime = DateTime.parse(dateTime);
|
||||||
|
|
||||||
|
await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime);
|
||||||
|
await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
||||||
int removedCount = 0;
|
int removedCount = 0;
|
||||||
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class UploadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> manualBackup(List<LocalAsset> localAssets) async {
|
Future<void> manualBackup(List<LocalAsset> localAssets) async {
|
||||||
|
await _storageRepository.clearCache();
|
||||||
List<UploadTask> tasks = [];
|
List<UploadTask> tasks = [];
|
||||||
for (final asset in localAssets) {
|
for (final asset in localAssets) {
|
||||||
final task = await _getUploadTask(
|
final task = await _getUploadTask(
|
||||||
@@ -120,6 +121,8 @@ class UploadService {
|
|||||||
/// Build the upload tasks
|
/// Build the upload tasks
|
||||||
/// Enqueue the tasks
|
/// Enqueue the tasks
|
||||||
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
|
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
|
||||||
|
await _storageRepository.clearCache();
|
||||||
|
|
||||||
shouldAbortQueuingTasks = false;
|
shouldAbortQueuingTasks = false;
|
||||||
|
|
||||||
final candidates = await _backupRepository.getCandidates(userId);
|
final candidates = await _backupRepository.getCandidates(userId);
|
||||||
@@ -159,6 +162,7 @@ class UploadService {
|
|||||||
Future<int> cancelBackup() async {
|
Future<int> cancelBackup() async {
|
||||||
shouldAbortQueuingTasks = true;
|
shouldAbortQueuingTasks = true;
|
||||||
|
|
||||||
|
await _storageRepository.clearCache();
|
||||||
await _uploadRepository.reset(kBackupGroup);
|
await _uploadRepository.reset(kBackupGroup);
|
||||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class _DateTimePicker extends HookWidget {
|
|||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor),
|
trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor),
|
||||||
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium).tr(),
|
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium),
|
||||||
onTap: pickDate,
|
onTap: pickDate,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
@@ -104,6 +105,10 @@ class BetaSyncSettings extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearFileCache() async {
|
||||||
|
await ref.read(storageRepositoryProvider).clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
return FutureBuilder<List<dynamic>>(
|
return FutureBuilder<List<dynamic>>(
|
||||||
future: loadCounts(),
|
future: loadCounts(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -241,6 +246,14 @@ class BetaSyncSettings extends HookConsumerWidget {
|
|||||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_SectionHeaderText(text: "actions".t(context: context)),
|
_SectionHeaderText(text: "actions".t(context: context)),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
"clear_file_cache".t(context: context),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
leading: const Icon(Icons.playlist_remove_rounded),
|
||||||
|
onTap: clearFileCache,
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
"export_database".t(context: context),
|
"export_database".t(context: context),
|
||||||
|
|||||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.136.0
|
- API version: 1.137.0
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.136.0+3000
|
version: 1.137.0+3001
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ void main() {
|
|||||||
registerFallbackValue(LocalAssetStub.image1);
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
|
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('HashService hashAssets', () {
|
group('HashService hashAssets', () {
|
||||||
|
|||||||
@@ -9469,7 +9469,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
|||||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.136.0
|
* 1.137.0
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
|
|||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^11.0.1",
|
"@nestjs/bullmq": "^11.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
14
web/package-lock.json
generated
14
web/package-lock.json
generated
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@immich/ui": "^0.23.5",
|
"@immich/ui": "^0.23.6",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.11.5",
|
"@photo-sphere-viewer/core": "^5.11.5",
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
@@ -1357,9 +1357,9 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@immich/ui": {
|
"node_modules/@immich/ui": {
|
||||||
"version": "0.23.5",
|
"version": "0.23.6",
|
||||||
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.5.tgz",
|
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.6.tgz",
|
||||||
"integrity": "sha512-1wlFMmfDmtGC+Kcc8cYTT00mQaSumR41KEOOOmVn5Rw/8z9pUhpNY8mGl1AxY4qhtnaz+G3dH6vowYzL23D+YQ==",
|
"integrity": "sha512-HYIguDx/nCXcvqLKhY1R/+Aks6mn8B9jIiNVQH6WODDPbvGFrvQT5uINhXHrjsdyuzKBVS6dps+lx9+9Z6z4rA==",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.136.0",
|
"version": "1.137.0",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@immich/ui": "^0.23.5",
|
"@immich/ui": "^0.23.6",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.11.5",
|
"@photo-sphere-viewer/core": "^5.11.5",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import Portal from '../portal/portal.svelte';
|
import Portal from '../portal/portal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
initialAssetId?: string;
|
||||||
assets: (TimelineAsset | AssetResponseDto)[];
|
assets: (TimelineAsset | AssetResponseDto)[];
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
disableAssetSelect?: boolean;
|
disableAssetSelect?: boolean;
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
initialAssetId = undefined,
|
||||||
assets = $bindable(),
|
assets = $bindable(),
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
disableAssetSelect = false,
|
disableAssetSelect = false,
|
||||||
@@ -117,7 +119,14 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentViewAssetIndex = 0;
|
let currentIndex = 0;
|
||||||
|
if (initialAssetId && assets.length > 0) {
|
||||||
|
const index = assets.findIndex(({ id }) => id === initialAssetId);
|
||||||
|
if (index !== -1) {
|
||||||
|
currentIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let shiftKeyIsDown = $state(false);
|
let shiftKeyIsDown = $state(false);
|
||||||
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||||
let slidingWindow = $state({ top: 0, bottom: 0 });
|
let slidingWindow = $state({ top: 0, bottom: 0 });
|
||||||
@@ -150,8 +159,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const viewAssetHandler = async (asset: TimelineAsset) => {
|
const viewAssetHandler = async (asset: TimelineAsset) => {
|
||||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
currentIndex = assets.findIndex((a) => a.id == asset.id);
|
||||||
await setAssetId(assets[currentViewAssetIndex].id);
|
await setAssetId(assets[currentIndex].id);
|
||||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -324,12 +333,12 @@
|
|||||||
if (onNext) {
|
if (onNext) {
|
||||||
asset = await onNext();
|
asset = await onNext();
|
||||||
} else {
|
} else {
|
||||||
if (currentViewAssetIndex >= assets.length - 1) {
|
if (currentIndex >= assets.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentViewAssetIndex = currentViewAssetIndex + 1;
|
currentIndex = currentIndex + 1;
|
||||||
asset = currentViewAssetIndex < assets.length ? assets[currentViewAssetIndex] : undefined;
|
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -374,12 +383,12 @@
|
|||||||
if (onPrevious) {
|
if (onPrevious) {
|
||||||
asset = await onPrevious();
|
asset = await onPrevious();
|
||||||
} else {
|
} else {
|
||||||
if (currentViewAssetIndex <= 0) {
|
if (currentIndex <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentViewAssetIndex = currentViewAssetIndex - 1;
|
currentIndex = currentIndex - 1;
|
||||||
asset = currentViewAssetIndex >= 0 ? assets[currentViewAssetIndex] : undefined;
|
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -412,10 +421,10 @@
|
|||||||
);
|
);
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
await goto(AppRoute.PHOTOS);
|
await goto(AppRoute.PHOTOS);
|
||||||
} else if (currentViewAssetIndex === assets.length) {
|
} else if (currentIndex === assets.length) {
|
||||||
await handlePrevious();
|
await handlePrevious();
|
||||||
} else {
|
} else {
|
||||||
await setAssetId(assets[currentViewAssetIndex].id);
|
await setAssetId(assets[currentIndex].id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
import { getFileSize } from '$lib/utils/asset-utils';
|
||||||
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
|
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto } from '@immich/sdk';
|
||||||
import { mdiHeart } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
@@ -16,43 +12,26 @@
|
|||||||
let { asset, onViewAsset }: Props = $props();
|
let { asset, onViewAsset }: Props = $props();
|
||||||
|
|
||||||
let assetData = $derived(JSON.stringify(asset, null, 2));
|
let assetData = $derived(JSON.stringify(asset, null, 2));
|
||||||
|
|
||||||
|
let boxWidth = $state(300);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
|
class="w-full aspect-square rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
|
||||||
|
bind:clientWidth={boxWidth}
|
||||||
|
title={assetData}
|
||||||
>
|
>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full h-full overflow-hidden rounded-lg">
|
||||||
<button type="button" onclick={() => onViewAsset(asset)} class="block relative w-full" aria-label={$t('keep')}>
|
<Thumbnail asset={toTimelineAsset(asset)} readonly onClick={() => onViewAsset(asset)} thumbnailSize={boxWidth} />
|
||||||
<!-- THUMBNAIL-->
|
|
||||||
<img
|
|
||||||
src={getAssetThumbnailUrl(asset.id)}
|
|
||||||
alt={$getAltText(toTimelineAsset(asset))}
|
|
||||||
title={assetData}
|
|
||||||
class="h-60 object-cover rounded-t-xl w-full"
|
|
||||||
draggable="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- OVERLAY CHIP -->
|
{#if !!asset.libraryId}
|
||||||
{#if !!asset.libraryId}
|
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-500">External</div>
|
||||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-300/90">External</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- FAVORITE ICON -->
|
|
||||||
{#if asset.isFavorite}
|
|
||||||
<div class="absolute bottom-2 start-2">
|
|
||||||
<Icon path={mdiHeart} size="24" class="text-white" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center mt-4 px-4 text-sm font-normal truncate" title={asset.originalFileName}>
|
||||||
<div class="flex justify-between items-center pl-2 pr-4 gap-2">
|
{asset.originalFileName}
|
||||||
<div class="grid gap-y-2 py-2 text-xs transition-colors dark:text-white">
|
</div>
|
||||||
<div class="text-left text-ellipsis truncate">{asset.originalFileName}</div>
|
<div class="text-center">
|
||||||
<span>{getAssetResolution(asset)}</span>
|
<p class="text-primary text-xl font-semibold py-3">{getFileSize(asset, 1)}</p>
|
||||||
</div>
|
|
||||||
<div class="dark:text-white text-lg font-bold whitespace-nowrap w-max">
|
|
||||||
{getFileSize(asset, 1)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||||
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { foldersStore } from '$lib/stores/folders.svelte';
|
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
|
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
|
||||||
@@ -104,6 +103,7 @@
|
|||||||
{#if data.pathAssets && data.pathAssets.length > 0}
|
{#if data.pathAssets && data.pathAssets.length > 0}
|
||||||
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
|
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
|
||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
|
initialAssetId={data.asset?.id}
|
||||||
assets={data.pathAssets}
|
assets={data.pathAssets}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
{viewport}
|
{viewport}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||||
{#if assets && data.assets.length > 0}
|
{#if assets && data.assets.length > 0}
|
||||||
{#each assets as asset (asset.id)}
|
{#each assets as asset (asset.id)}
|
||||||
<LargeAssetData {asset} onViewAsset={(asset) => setAsset(asset)} />
|
<LargeAssetData {asset} onViewAsset={(asset) => setAsset(asset)} />
|
||||||
|
|||||||
Reference in New Issue
Block a user