Compare commits

...

22 Commits

Author SHA1 Message Date
Alex The Bot
3f1cf44717 Version v1.71.0 2023-07-28 18:32:33 +00:00
martin
2d83ac4125 fix(web): focus & clear individual search term (#3452) 2023-07-28 13:03:23 -05:00
cfitzw
7147486b6a Sort album assets from newest to oldest (#3444) 2023-07-28 09:47:27 -05:00
Alex
89ddbac8bc chore: build report 2023-07-28 09:06:25 -05:00
Dmitry Brazhenko
e071b82e8a feat (web/server) 360 degrees Web panoramas [attempt 2] (#3412)
* commit 1 (isPanorama: boolean)

* working solution for projectiontypeenum

* fix

* format fix

* fix

* fix

* fix

* fix

* enum projectiontype

* working solution with exif

* fix

* reverted >

* fix format

* reverted auto-magic api.ts prettification

* fix

* reverted api.ts autogenerated

* api ts regenerated

* Update web/src/lib/components/assets/thumbnail/thumbnail.svelte

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* Update web/src/lib/components/asset-viewer/asset-viewer.svelte

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* exifProjectionType

* Update server/src/microservices/processors/metadata-extraction.processor.ts

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* projectionType?: string = ProjectionType.NONE;

* not null

* projectionType!: ProjectionType;

* opeapi generator fix

* fixes

* fix

* fix

* generate api

* asset.exifInifo?.projectionType

* Update server/src/domain/asset/response-dto/exif-response.dto.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Update server/src/microservices/processors/metadata-extraction.processor.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* enum -> varchar;projectiontypeenum->projectiontype

* asset-viewer fixed prettiffier

* @Column({}) single line

* enum | string

* make api

* enum | string

* enum | str fix

* fix

* chore: use string instead of enum

* chore: open api

* fix: checks

---------

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-07-27 23:29:09 -05:00
martin
13b2b2fc4e fix(web): clickable items in the searchbar (#3441) 2023-07-27 22:06:42 -05:00
Dhrumil Shah
fe9ef1a3ea feat(mobile) - Add better offline support (#3279)
* WIP: Adding init support for offline-loading

* WIP: found bug and fixed with offline browing adv setting

* WIP: big some bugs with first login

* WIP: static analysis fixes

* PR: Removed setting for offline browing

* PR: static analysis - remove imports

* PR: Refactored user login state

* PR: changed logger log level as it happens a lot

* PR: change log var to _log

* PR: addressing comments

* WIP: bug fixes

* WIP: static analysis on the logger variable
2023-07-27 22:05:27 -05:00
martin
afb0d0f54d feat(web): suggest to merge people faces when renaming a person name (#3399)
* feat: propose to merge faced based on the name

* responsive

* drop down menu

* add border

* improvements

* improvements

* improvements

* add comments

* responsive

* responsive

* feat: use FullScreenModal

* responsive

* pr feeback

* pr feeback

* pr feeback

* responsive

* pr feeback

* pr feeback

* styling

* fix test

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-28 03:04:20 +00:00
martin
26085ff82b fix: clicking outside the shortcuts pannel (#3433)
* use FullScreenModal for ShowShortcuts

* pr feeback
2023-07-27 14:42:27 -05:00
Alex The Bot
2872886e77 Version v1.70.0 2023-07-27 03:40:16 +00:00
martin
a21112e4ab fix: people in shared assets (#3431)
* fix: people in shared assets

* use empty array
2023-07-26 21:14:50 -05:00
Jason Rasmussen
f3edf43158 chore: log listen address (#3428) 2023-07-26 18:29:35 +00:00
martin
1c5926553a fix: dialog overflow when creating a user (#3422) 2023-07-25 09:29:40 -05:00
faupau
05fa3092bf fix(web): fixes previous pull request: set asset as profile image (#3415)
* set photoviewer 100% width, fixes transparent ede

* remove unnecessary class

* format fix

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-25 05:17:59 +00:00
martyfuhry
7d3ec8af37 fix(mobile): Memory lane now updates to the correct day if the app is resumed the next day (#3414)
* Adds todayProvider to memory lane

* Revert "Adds todayProvider to memory lane"

This reverts commit 67ae58b513.

* Invalidate memory provider on app resume
2023-07-24 11:39:10 -05:00
Mark Monteiro
8db008ef0b Remove unnecessary PG_DATA environement variable from docker-compose.yml (#3394)
* Remove unnecessary PG_DATA environement variable from docker-compose.yml

There is no need to set the PostgreSQL data directory to the default location, it just adds an additional unnecessary line to the docker-compose file.

In addition, the PG_DATA isn't even the correct environment variable name (it should be PGDATA, see: https://hub.docker.com/_/postgres/), so this environment variable was never doing anything to begin with.

* Update docker-compose.dev.yml

* Update docker-compose.prod.yml

* Update docker-compose.test.yml
2023-07-23 21:11:27 -05:00
Alex
e493e05e99 fix(server): better facial recognition order (#3386) 2023-07-23 21:10:56 -05:00
martin
b83e535010 feat(web): show available shortcuts (#3342)
* feat(web): show available shortcuts

* pr feeback

* feat: new shortcut for deselect

* fix: remove new shortcut

* responsive
2023-07-23 21:09:06 -05:00
Daniele Ricci
111372edc1 fix(cli): fix wording in usage guide (#3378) 2023-07-23 21:06:27 -05:00
Mark Monteiro
625a899f64 Update environment-variables.md (#3402)
Add documentation for the environment variables that enable Docker secrets support

Support for these variables was implemented in #1254 and #3282
2023-07-23 17:53:52 -05:00
Alex
aaf0496f74 chore(server): Update Immich CLI version (#3403) 2023-07-23 17:53:20 -05:00
Alex
4977926c88 post mobile release 2023-07-23 13:51:48 -05:00
72 changed files with 1043 additions and 264 deletions

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1304,6 +1304,12 @@ export interface ExifResponseDto {
* @memberof ExifResponseDto
*/
'description'?: string | null;
/**
*
* @type {string}
* @memberof ExifResponseDto
*/
'projectionType'?: string | null;
}
/**
*

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -36,7 +36,7 @@ program
)
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.argument('[paths...]', 'One or more paths to assets to be imported')
.action((paths, options) => {
options.import = true;
options.excludePatterns = options.ignore;

View File

@@ -115,7 +115,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:

View File

@@ -85,7 +85,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
restart: always

View File

@@ -37,7 +37,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- /var/lib/postgresql/data
networks:

View File

@@ -69,7 +69,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
restart: always

View File

@@ -184,3 +184,24 @@ Typesense URL example JSON before encoding:
| `MACHINE_LEARNING_CLASSIFICATION_MODEL` | Classification Model | `microsoft/resnet-50` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | ML Cache Location | `/cache` | machine learning |
| `TRANSFORMERS_CACHE` | ML Transformers Cache Location | `/cache` | machine learning |
## Docker Secrets
The following variables support the use of [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for additional security.
To use any of these, replace the regular environment variable with the equivalent `_FILE` environment variable. The value of
the `_FILE` variable should be set to the path of a file containing the variable value.
| Regular Variable | Equivalent Docker Secrets '\_FILE' Variable |
| :----------------: | :-----------------------------------------: |
| `DB_HOSTNAME` | `DB_HOSTNAME_FILE`<sup>\*1</sup> |
| `DB_DATABASE_NAME` | `DB_DATABASE_NAME_FILE`<sup>\*1</sup> |
| `DB_USERNAME` | `DB_USERNAME_FILE`<sup>\*1</sup> |
| `DB_PASSWORD` | `DB_PASSWORD_FILE`<sup>\*1</sup> |
| `REDIS_PASSWORD` | `REDIS_PASSWORD_FILE`<sup>\*2</sup> |
\*1: See the [official documentation](https://github.com/docker-library/docs/tree/master/postgres#docker-secrets) for
details on how to use Docker Secrets in the Postgres image.
\*2: See [this comment](https://github.com/docker-library/redis/issues/46#issuecomment-335326234) for an example of how
to use use a Docker secret for the password in the Redis container.

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.69.0"
version = "1.71.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -13,4 +13,4 @@ key.properties
**/*.jks
# Fastlane
/fastlane/report.xml
fastlane/report.xml

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 92,
"android.injected.version.name" => "1.69.0",
"android.injected.version.code" => 94,
"android.injected.version.name" => "1.71.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')

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000296">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="64.042552">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="68.788432">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.676557">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.76592">
</testcase>

View File

@@ -31,4 +31,6 @@ Runner/GeneratedPluginRegistrant.*
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
!default.perspectivev3
fastlane/report.xml

View File

@@ -157,4 +157,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1

View File

@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 97;
CURRENT_PROJECT_VERSION = 110;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 97;
CURRENT_PROJECT_VERSION = 110;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 97;
CURRENT_PROJECT_VERSION = 110;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -59,11 +59,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.57.0</string>
<string>1.70.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>97</string>
<string>110</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.69.0"
version_number: "1.71.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000407">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.988375">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.108738">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="45.42439">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="28.952846">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.381359">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.821481">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="94.653021">
<testcase classname="fastlane.lanes" name="4: build_app" time="99.212621">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.237354">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.366701">
</testcase>

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -156,6 +157,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
ref.invalidate(memoryFutureProvider);
break;
case AppLifecycleState.inactive:

View File

@@ -37,6 +37,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final ApiService _apiService;
final Isar _db;
final _log = Logger("AuthenticationNotifier");
Future<bool> login(
String email,
@@ -145,38 +146,66 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> setSuccessLoginInfo({
required String accessToken,
required String serverUrl,
bool offlineLogin = false,
}) async {
_apiService.setAccessToken(accessToken);
UserResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId =
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
bool shouldChangePassword = false;
User? user;
bool retResult = false;
User? offlineUser = Store.tryGet(StoreKey.currentUser);
// If the user is offline and there is a user saved on the device,
// if not try an online login
if (offlineLogin && offlineUser != null) {
user = offlineUser;
retResult = false;
} else {
UserResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
}
}
if (userResponseDto != null) {
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
shouldChangePassword = userResponseDto.shouldChangePassword;
user = User.fromDto(userResponseDto);
retResult = true;
}
else {
_log.severe("Unable to get user information from the server.");
return false;
}
}
if (userResponseDto != null) {
final deviceId = await FlutterUdid.consistentUdid;
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
state = state.copyWith(
isAuthenticated: true,
userId: user.id,
userEmail: user.email,
firstName: user.firstName,
lastName: user.lastName,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: shouldChangePassword,
deviceId: deviceId,
);
state = state.copyWith(
isAuthenticated: true,
userId: userResponseDto.id,
userEmail: userResponseDto.email,
firstName: userResponseDto.firstName,
lastName: userResponseDto.lastName,
profileImagePath: userResponseDto.profileImagePath,
isAdmin: userResponseDto.isAdmin,
shouldChangePassword: userResponseDto.shouldChangePassword,
deviceId: deviceId,
);
}
return true;
return retResult;
}
}

View File

@@ -4,29 +4,40 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class AuthGuard extends AutoRouteGuard {
final ApiService _apiService;
final _log = Logger("AuthGuard");
AuthGuard(this._apiService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
try {
var res = await _apiService.authenticationApi.validateAccessToken();
if (res != null && res.authStatus) {
resolver.next(true);
} else {
if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login
_log.fine("User token is invalid. Redirecting to login");
router.replaceAll([const LoginRoute()]);
}
} on ApiException catch (e) {
if (e.code == HttpStatus.badRequest &&
e.innerException is SocketException) {
// offline?
resolver.next(true);
_log.fine(
"Unable to validate user token. User may be offline and offline browsing is allowed.",
);
} else {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]);
return;
}
} catch (e) {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]);
return;
}
}

View File

@@ -16,6 +16,7 @@ class User {
required this.isAdmin,
this.isPartnerSharedBy = false,
this.isPartnerSharedWith = false,
this.profileImagePath = '',
});
Id get isarId => fastHash(id);
@@ -28,6 +29,7 @@ class User {
lastName = dto.lastName,
isPartnerSharedBy = false,
isPartnerSharedWith = false,
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin;
@Index(unique: true, replace: false, type: IndexType.hash)
@@ -39,6 +41,7 @@ class User {
bool isPartnerSharedBy;
bool isPartnerSharedWith;
bool isAdmin;
String profileImagePath;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@Backlink(to: 'sharedUsers')
@@ -54,6 +57,7 @@ class User {
lastName == other.lastName &&
isPartnerSharedBy == other.isPartnerSharedBy &&
isPartnerSharedWith == other.isPartnerSharedWith &&
profileImagePath == other.profileImagePath &&
isAdmin == other.isAdmin;
}
@@ -67,5 +71,6 @@ class User {
lastName.hashCode ^
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode;
}

View File

@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/store.dart';
@@ -62,6 +64,10 @@ class ApiService {
Future<String> _resolveEndpoint(String serverUrl) async {
final url = sanitizeUrl(serverUrl);
if (!await _isEndpointAvailable(serverUrl)) {
throw ApiException(503, "Server is not reachable");
}
// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint;
@@ -70,6 +76,29 @@ class ApiService {
return url;
}
Future<bool> _isEndpointAvailable(String serverUrl) async {
final Client client = Client();
if (!serverUrl.endsWith('/api')) {
serverUrl += '/api';
}
// Throw Socket or Timeout exceptions,
// we do not care if the endpoints hits an HTTP error
try {
await client
.get(
Uri.parse(serverUrl),
)
.timeout(const Duration(seconds: 5));
} on TimeoutException catch (_) {
return false;
} on SocketException catch (_) {
return false;
}
return true;
}
Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();

View File

@@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class SplashScreenPage extends HookConsumerWidget {
const SplashScreenPage({Key? key}) : super(key: key);
@@ -17,24 +19,41 @@ class SplashScreenPage extends HookConsumerWidget {
final apiService = ref.watch(apiServiceProvider);
final serverUrl = Store.tryGet(StoreKey.serverUrl);
final accessToken = Store.tryGet(StoreKey.accessToken);
final log = Logger("SplashScreenPage");
void performLoggingIn() async {
bool isSuccess = false;
bool deviceIsOffline = false;
if (accessToken != null && serverUrl != null) {
try {
// Resolve API server endpoint from user provided serverUrl
await apiService.resolveAndSetEndpoint(serverUrl);
} catch (e) {
} on ApiException catch (e) {
// okay, try to continue anyway if offline
if (e.code == 503) {
deviceIsOffline = true;
log.fine("Device seems to be offline upon launch");
} else {
log.severe(e);
}
} catch (e) {
log.severe(e);
}
isSuccess =
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
accessToken: accessToken,
serverUrl: serverUrl,
offlineLogin: deviceIsOffline,
);
}
if (isSuccess) {
// If the device is offline and there is a currentUser stored locallly
// Proceed into the app
if (deviceIsOffline && Store.tryGet(StoreKey.currentUser) != null) {
AutoRouter.of(context).replace(const TabControllerRoute());
} else if (isSuccess) {
// If device was able to login through the internet successfully
final hasPermission =
await ref.read(galleryPermissionNotifier.notifier).hasPermission;
if (hasPermission) {
@@ -43,6 +62,7 @@ class SplashScreenPage extends HookConsumerWidget {
}
AutoRouter.of(context).replace(const TabControllerRoute());
} else {
// User was unable to login through either offline or online methods
AutoRouter.of(context).replace(const LoginRoute());
}
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.69.0
- API version: 1.71.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -28,6 +28,7 @@ Name | Type | Description | Notes
**state** | **String** | | [optional]
**country** | **String** | | [optional]
**description** | **String** | | [optional]
**projectionType** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -33,6 +33,7 @@ class ExifResponseDto {
this.state,
this.country,
this.description,
this.projectionType,
});
int? fileSizeInByte;
@@ -75,6 +76,8 @@ class ExifResponseDto {
String? description;
String? projectionType;
@override
bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto &&
other.fileSizeInByte == fileSizeInByte &&
@@ -96,7 +99,8 @@ class ExifResponseDto {
other.city == city &&
other.state == state &&
other.country == country &&
other.description == description;
other.description == description &&
other.projectionType == projectionType;
@override
int get hashCode =>
@@ -120,10 +124,11 @@ class ExifResponseDto {
(city == null ? 0 : city!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(country == null ? 0 : country!.hashCode) +
(description == null ? 0 : description!.hashCode);
(description == null ? 0 : description!.hashCode) +
(projectionType == null ? 0 : projectionType!.hashCode);
@override
String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, description=$description]';
String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, description=$description, projectionType=$projectionType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -227,6 +232,11 @@ class ExifResponseDto {
} else {
// json[r'description'] = null;
}
if (this.projectionType != null) {
json[r'projectionType'] = this.projectionType;
} else {
// json[r'projectionType'] = null;
}
return json;
}
@@ -272,6 +282,7 @@ class ExifResponseDto {
state: mapValueOfType<String>(json, r'state'),
country: mapValueOfType<String>(json, r'country'),
description: mapValueOfType<String>(json, r'description'),
projectionType: mapValueOfType<String>(json, r'projectionType'),
);
}
return null;

View File

@@ -116,6 +116,11 @@ void main() {
// TODO
});
// String projectionType
test('to test the property `projectionType`', () async {
// TODO
});
});

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.69.0+92
version: 1.71.0+94
isar_version: &isar_version 3.1.0+1
environment:

View File

@@ -4439,7 +4439,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.69.0",
"version": "1.71.0",
"contact": {}
},
"tags": [],
@@ -5553,6 +5553,11 @@
"type": "string",
"nullable": true,
"default": null
},
"projectionType": {
"type": "string",
"nullable": true,
"default": null
}
}
},

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.69.0",
"version": "1.71.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.69.0",
"version": "1.71.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
@@ -28,12 +28,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "^19.0.0",
"exiftool-vendored": "^22.0.0",
"exiftool-vendored.pl": "^12.54.0",
"fluent-ffmpeg": "^2.1.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"immich": "^0.39.0",
"immich": "^0.40.1",
"ioredis": "^5.3.1",
"joi": "^17.5.0",
"local-reverse-geocoder": "0.12.5",
@@ -4064,9 +4064,9 @@
}
},
"node_modules/batch-cluster": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-11.0.0.tgz",
"integrity": "sha512-8iwqa+rKTaakOHkqdcXDT5L5117pa+FoP8/yAKpNdL44ZnC4V2NEA/sIg0ZO0O9NkpdjLk0A3efRFM5nVizqHw==",
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
"integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg==",
"engines": {
"node": ">=14"
}
@@ -5976,25 +5976,25 @@
}
},
"node_modules/exiftool-vendored": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-19.0.0.tgz",
"integrity": "sha512-Zes7TZrYWxts92mbF2Gs3drtWZucm4qsaeYaE6A+OOqmeD9UGaGisqIbyh9MilJrLi+ZHzWEJZtDj37QFf6xsA==",
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-22.0.0.tgz",
"integrity": "sha512-gBOQ4C2GLjxKPDPRuUbMOz91mG6IFA22L+Z/IQzFotFu20vc7YroqHALf/ophCbANA5sNSArbVDPijP7n/20Jg==",
"dependencies": {
"@photostructure/tz-lookup": "^7.0.0",
"@types/luxon": "^3.2.0",
"batch-cluster": "^11.0.0",
"@types/luxon": "^3.3.0",
"batch-cluster": "^12.1.0",
"he": "^1.2.0",
"luxon": "^3.2.1"
"luxon": "^3.3.0"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.54.0",
"exiftool-vendored.pl": "12.54.0"
"exiftool-vendored.exe": "12.62.0",
"exiftool-vendored.pl": "12.62.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.54.0.tgz",
"integrity": "sha512-Dc4W6e0NtQfYuJIYK4piHfDJnd2jvA04e0aaq9R3Q1oO34KC5e+L1D2C7lFuZXqPQLYC1x3GYc/GVv5e+SkkrQ==",
"version": "12.62.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.62.0.tgz",
"integrity": "sha512-xNFkvmjwnMg6ivtmkc67w1qD23fIy06nRpMpGuBpTwTqAVatHV+vk7T75zyvLoXRRpd1rKID9XAVLGJCE/iiMQ==",
"optional": true,
"os": [
"win32"
@@ -6008,15 +6008,6 @@
"!win32"
]
},
"node_modules/exiftool-vendored/node_modules/exiftool-vendored.pl": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.54.0.tgz",
"integrity": "sha512-RBBowsYcM6EvbWoBkg2dOqHpH3WIzN7bIzHc+o+LquqCTo3doZwECClD/6PNHVSMQsl2Z0fEf75sNq2msooMSg==",
"optional": true,
"os": [
"!win32"
]
},
"node_modules/exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -7003,9 +6994,9 @@
}
},
"node_modules/immich": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz",
"integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==",
"version": "0.40.1",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.40.1.tgz",
"integrity": "sha512-pU0Ua+FAsOiqrPC8NbSA521QW0k56Sw0GZ5rrPyqEMb2dcYPDOqEFcEk/1INqoQpPxy+CF9ZOCHNWxsEc7L1Rw==",
"dependencies": {
"axios": "^0.26.0",
"chalk": "^2.4.1",
@@ -15584,9 +15575,9 @@
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="
},
"batch-cluster": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-11.0.0.tgz",
"integrity": "sha512-8iwqa+rKTaakOHkqdcXDT5L5117pa+FoP8/yAKpNdL44ZnC4V2NEA/sIg0ZO0O9NkpdjLk0A3efRFM5nVizqHw=="
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
"integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg=="
},
"bcrypt": {
"version": "5.1.0",
@@ -17011,31 +17002,23 @@
}
},
"exiftool-vendored": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-19.0.0.tgz",
"integrity": "sha512-Zes7TZrYWxts92mbF2Gs3drtWZucm4qsaeYaE6A+OOqmeD9UGaGisqIbyh9MilJrLi+ZHzWEJZtDj37QFf6xsA==",
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-22.0.0.tgz",
"integrity": "sha512-gBOQ4C2GLjxKPDPRuUbMOz91mG6IFA22L+Z/IQzFotFu20vc7YroqHALf/ophCbANA5sNSArbVDPijP7n/20Jg==",
"requires": {
"@photostructure/tz-lookup": "^7.0.0",
"@types/luxon": "^3.2.0",
"batch-cluster": "^11.0.0",
"exiftool-vendored.exe": "12.54.0",
"exiftool-vendored.pl": "12.54.0",
"@types/luxon": "^3.3.0",
"batch-cluster": "^12.1.0",
"exiftool-vendored.exe": "12.62.0",
"exiftool-vendored.pl": "12.62.0",
"he": "^1.2.0",
"luxon": "^3.2.1"
},
"dependencies": {
"exiftool-vendored.pl": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.54.0.tgz",
"integrity": "sha512-RBBowsYcM6EvbWoBkg2dOqHpH3WIzN7bIzHc+o+LquqCTo3doZwECClD/6PNHVSMQsl2Z0fEf75sNq2msooMSg==",
"optional": true
}
"luxon": "^3.3.0"
}
},
"exiftool-vendored.exe": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.54.0.tgz",
"integrity": "sha512-Dc4W6e0NtQfYuJIYK4piHfDJnd2jvA04e0aaq9R3Q1oO34KC5e+L1D2C7lFuZXqPQLYC1x3GYc/GVv5e+SkkrQ==",
"version": "12.62.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.62.0.tgz",
"integrity": "sha512-xNFkvmjwnMg6ivtmkc67w1qD23fIy06nRpMpGuBpTwTqAVatHV+vk7T75zyvLoXRRpd1rKID9XAVLGJCE/iiMQ==",
"optional": true
},
"exiftool-vendored.pl": {
@@ -17786,9 +17769,9 @@
"dev": true
},
"immich": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz",
"integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==",
"version": "0.40.1",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.40.1.tgz",
"integrity": "sha512-pU0Ua+FAsOiqrPC8NbSA521QW0k56Sw0GZ5rrPyqEMb2dcYPDOqEFcEk/1INqoQpPxy+CF9ZOCHNWxsEc7L1Rw==",
"requires": {
"axios": "^0.26.0",
"chalk": "^2.4.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.69.0",
"version": "1.71.0",
"description": "",
"author": "",
"private": true,
@@ -58,12 +58,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "^19.0.0",
"exiftool-vendored": "^22.0.0",
"exiftool-vendored.pl": "^12.54.0",
"fluent-ffmpeg": "^2.1.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"immich": "^0.39.0",
"immich": "^0.40.1",
"ioredis": "^5.3.1",
"joi": "^17.5.0",
"local-reverse-geocoder": "0.12.5",

View File

@@ -11,6 +11,7 @@ export interface AssetStatsOptions {
export interface AssetSearchOptions {
isVisible?: boolean;
type?: AssetType;
order?: 'ASC' | 'DESC';
}
export interface LivePhotoSearchOptions {

View File

@@ -24,6 +24,7 @@ export class ExifResponseDto {
state?: string | null = null;
country?: string | null = null;
description?: string | null = null;
projectionType?: string | null = null;
}
export function mapExif(entity: ExifEntity): ExifResponseDto {
@@ -48,5 +49,6 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
state: entity.state,
country: entity.country,
description: entity.description,
projectionType: entity.projectionType,
};
}

View File

@@ -29,7 +29,7 @@ export class FacialRecognitionService {
async handleQueueRecognizeFaces({ force }: IBaseJob) {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
? this.assetRepository.getAll(pagination, { order: 'DESC' })
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
});

View File

@@ -36,7 +36,7 @@ export class AlbumRepository implements IAlbumRepository {
},
order: {
assets: {
fileCreatedAt: 'ASC',
fileCreatedAt: 'DESC',
},
},
});

View File

@@ -207,12 +207,13 @@ export class AssetService {
const allowExif = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId);
const data = allowExif ? mapAsset(asset) : mapAssetWithoutExif(asset);
if (allowExif) {
return mapAsset(asset);
} else {
return mapAssetWithoutExif(asset);
if (data.ownerId !== authUser.id) {
data.people = [];
}
return data;
}
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {

View File

@@ -31,5 +31,5 @@ export async function bootstrap() {
const server = await app.listen(port);
server.requestTimeout = 30 * 60 * 1000;
logger.log(`Immich Server is listening on ${port} [v${SERVER_VERSION}] [${envName}] `);
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${SERVER_VERSION}] [${envName}] `);
}

View File

@@ -43,6 +43,9 @@ export class ExifEntity {
@Column({ type: 'float', nullable: true })
longitude!: number | null;
@Column({ type: 'varchar', nullable: true })
projectionType!: string | null;
@Column({ type: 'varchar', nullable: true })
city!: string | null;

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Panoramas1690217088596 implements MigrationInterface {
name = 'Panoramas1690217088596';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "projectionType" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "projectionType"`);
}
}

View File

@@ -116,7 +116,7 @@ export class AssetRepository implements IAssetRepository {
},
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
createdAt: options.order ?? 'ASC',
},
});
}

View File

@@ -17,5 +17,5 @@ export async function bootstrap() {
await app.get(AppService).init();
await app.listen(port);
logger.log(`Immich Microservices is listening on ${port} [v${SERVER_VERSION}] [${envName}] `);
logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${SERVER_VERSION}] [${envName}] `);
}

View File

@@ -336,6 +336,12 @@ export class MetadataExtractionProcessor {
await this.extractEmbeddedVideo(asset, offset, null, fileCreatedAt);
}
}
const projectionType = getExifProperty('ProjectionType');
if (projectionType) {
newExif.projectionType = String(projectionType).toUpperCase();
}
newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
const motionAsset = await this.assetRepository.findLivePhotoMatch({

View File

@@ -625,6 +625,7 @@ const assetInfo: ExifResponseDto = {
state: 'state',
country: 'country',
description: 'description',
projectionType: null,
};
const assetResponse: AssetResponseDto = {
@@ -882,6 +883,7 @@ export const sharedLinkStub = {
livePhotoVideoId: null,
originalFileName: 'asset_1.jpeg',
exifInfo: {
projectionType: null,
livePhotoCID: null,
assetId: 'id_1',
description: 'description',

103
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "immich-web",
"version": "1.0.0",
"dependencies": {
"@egjs/svelte-view360": "^4.0.0-beta.7",
"@zoom-image/svelte": "^0.1.8",
"axios": "^0.27.2",
"buffer": "^6.0.3",
@@ -1840,6 +1841,47 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@cfcs/core": {
"version": "0.0.24",
"resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.24.tgz",
"integrity": "sha512-feB38qu+eDk0Pggh/yR7gjaNmvUYA2uCxHP3Pz2MLE4LZ/9jPdtu8bzCSI47yTEhWyZCF5Pk698hdz8IN2mTjA==",
"dependencies": {
"@egjs/component": "^3.0.4"
}
},
"node_modules/@egjs/component": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.4.tgz",
"integrity": "sha512-sXA7bGbIeLF2OAw/vpka66c6QBBUPcA4UUhR4WGJfnp2XWdiI8QrnJGJMr/UxpE/xnevX9tN3jvNPlW8WkHl3g=="
},
"node_modules/@egjs/imready": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@egjs/imready/-/imready-1.4.1.tgz",
"integrity": "sha512-JIOBs4lB7FYdsKi5uvz2j3SObX8eShtZjtqlOH41tm185aJOQZwiKBK8+V4MxzG4X6DqVhpdN8UcuVwBbElfsg==",
"dependencies": {
"@cfcs/core": "^0.0.24",
"@egjs/component": "^3.0.1"
}
},
"node_modules/@egjs/svelte-view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/svelte-view360/-/svelte-view360-4.0.0-beta.7.tgz",
"integrity": "sha512-qFNbLNME8H7QU2lg8SCKUTPoBXVdBcM5m8zmlDRE72esCTguDzUq2szXD7L1JWcb2lYPTFl3HVp/sZlcQ/1HpQ==",
"dependencies": {
"@egjs/view360": "4.0.0-beta.7"
}
},
"node_modules/@egjs/view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/view360/-/view360-4.0.0-beta.7.tgz",
"integrity": "sha512-prVTTxuQ1/k59NM7G0tm58k2vPHGoaExoFr5E7MoJaSGF56Otj4okQHAxxosXH87aQLN0feZMtBlsKz0b/7zEw==",
"dependencies": {
"@egjs/component": "^3.0.2",
"@egjs/imready": "^1.3.0",
"@types/webxr": "^0.5.1",
"gl-matrix": "^3.4.3"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.17.19",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
@@ -3821,6 +3863,11 @@
"integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==",
"dev": true
},
"node_modules/@types/webxr": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.2.tgz",
"integrity": "sha512-szL74BnIcok9m7QwYtVmQ+EdIKwbjPANudfuvDrAF8Cljg9MKUlIoc1w5tjj9PMpeSH3U1Xnx//czQybJ0EfSw=="
},
"node_modules/@types/yargs": {
"version": "17.0.22",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz",
@@ -6290,6 +6337,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gl-matrix": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
"integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
},
"node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -13334,6 +13386,47 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@cfcs/core": {
"version": "0.0.24",
"resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.24.tgz",
"integrity": "sha512-feB38qu+eDk0Pggh/yR7gjaNmvUYA2uCxHP3Pz2MLE4LZ/9jPdtu8bzCSI47yTEhWyZCF5Pk698hdz8IN2mTjA==",
"requires": {
"@egjs/component": "^3.0.4"
}
},
"@egjs/component": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.4.tgz",
"integrity": "sha512-sXA7bGbIeLF2OAw/vpka66c6QBBUPcA4UUhR4WGJfnp2XWdiI8QrnJGJMr/UxpE/xnevX9tN3jvNPlW8WkHl3g=="
},
"@egjs/imready": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@egjs/imready/-/imready-1.4.1.tgz",
"integrity": "sha512-JIOBs4lB7FYdsKi5uvz2j3SObX8eShtZjtqlOH41tm185aJOQZwiKBK8+V4MxzG4X6DqVhpdN8UcuVwBbElfsg==",
"requires": {
"@cfcs/core": "^0.0.24",
"@egjs/component": "^3.0.1"
}
},
"@egjs/svelte-view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/svelte-view360/-/svelte-view360-4.0.0-beta.7.tgz",
"integrity": "sha512-qFNbLNME8H7QU2lg8SCKUTPoBXVdBcM5m8zmlDRE72esCTguDzUq2szXD7L1JWcb2lYPTFl3HVp/sZlcQ/1HpQ==",
"requires": {
"@egjs/view360": "4.0.0-beta.7"
}
},
"@egjs/view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/view360/-/view360-4.0.0-beta.7.tgz",
"integrity": "sha512-prVTTxuQ1/k59NM7G0tm58k2vPHGoaExoFr5E7MoJaSGF56Otj4okQHAxxosXH87aQLN0feZMtBlsKz0b/7zEw==",
"requires": {
"@egjs/component": "^3.0.2",
"@egjs/imready": "^1.3.0",
"@types/webxr": "^0.5.1",
"gl-matrix": "^3.4.3"
}
},
"@esbuild/android-arm": {
"version": "0.17.19",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
@@ -14748,6 +14841,11 @@
"integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==",
"dev": true
},
"@types/webxr": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.2.tgz",
"integrity": "sha512-szL74BnIcok9m7QwYtVmQ+EdIKwbjPANudfuvDrAF8Cljg9MKUlIoc1w5tjj9PMpeSH3U1Xnx//czQybJ0EfSw=="
},
"@types/yargs": {
"version": "17.0.22",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz",
@@ -16510,6 +16608,11 @@
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true
},
"gl-matrix": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
"integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
},
"glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",

View File

@@ -60,6 +60,7 @@
},
"type": "module",
"dependencies": {
"@egjs/svelte-view360": "^4.0.0-beta.7",
"@zoom-image/svelte": "^0.1.8",
"axios": "^0.27.2",
"buffer": "^6.0.3",

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1304,6 +1304,12 @@ export interface ExifResponseDto {
* @memberof ExifResponseDto
*/
'description'?: string | null;
/**
*
* @type {string}
* @memberof ExifResponseDto
*/
'projectionType'?: string | null;
}
/**
*

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.69.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -12,6 +12,8 @@
import DetailPanel from './detail-panel.svelte';
import PhotoViewer from './photo-viewer.svelte';
import VideoViewer from './video-viewer.svelte';
import PanoramaViewer from './panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
@@ -293,6 +295,8 @@
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer {publicSharedKey} {asset} />
{:else}
<PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />
{/if}

View File

@@ -0,0 +1,20 @@
.view360-container {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
touch-action: pan-y;
overflow: hidden;
}
.view360-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
-ms-user-select: none;
user-select: none;
-webkit-user-drag: none;
}

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api, AssetResponseDto } from '@api';
import View360, { EquirectProjection } from '@egjs/svelte-view360';
import './panorama-viewer.css';
export let asset: AssetResponseDto;
export let publicSharedKey = '';
let dataUrl = '';
let errorMessage = '';
const loadAssetData = async () => {
try {
const { data } = await api.assetApi.serveFile(
{ id: asset.id, isThumb: false, isWeb: false, key: publicSharedKey },
{ responseType: 'blob' },
);
if (data instanceof Blob) {
dataUrl = URL.createObjectURL(data);
return dataUrl;
} else {
throw new Error('Invalid data format');
}
} catch (error) {
errorMessage = 'Failed to load asset';
return '';
}
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await loadAssetData()}
<LoadingSpinner />
{:then assetData}
{#if assetData}
<View360 autoResize={true} initialZoom={0.5} projection={new EquirectProjection({ src: assetData })} />
{:else}
<p>{errorMessage}</p>
{/if}
{/await}
</div>

View File

@@ -14,6 +14,7 @@
export let shadow = false;
export let circle = false;
export let hidden = false;
export let border = false;
let complete = false;
export let eyeColor = 'white';
@@ -26,7 +27,9 @@
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={altText}
class="object-cover transition duration-300"
class="object-cover transition duration-300 {border
? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
: ''}"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { ProjectionType } from '$lib/constants';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { timeToSeconds } from '$lib/utils/time-to-seconds';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
@@ -12,6 +13,7 @@
import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import Rotate360Icon from 'svelte-material-icons/Rotate360.svelte';
const dispatch = createEventDispatcher();
@@ -124,6 +126,14 @@
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pr-2 pt-2">
<Rotate360Icon size="24" />
</span>
</div>
{/if}
{#if asset.resized}
<ImageThumbnail
url={api.getAssetThumbnailUrl(asset.id, format, publicSharedKey)}

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { createEventDispatcher } from 'svelte';
import Close from 'svelte-material-icons/Close.svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import type { PersonResponseDto } from '../../../api/open-api';
import { api } from '@api';
import Merge from 'svelte-material-icons/Merge.svelte';
import Button from '../elements/buttons/button.svelte';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
const dispatch = createEventDispatcher<{
reject: void;
confirm: [PersonResponseDto, PersonResponseDto];
close: void;
}>();
export let personMerge1: PersonResponseDto;
export let personMerge2: PersonResponseDto;
export let people: PersonResponseDto[];
let potentialMergePeople: PersonResponseDto[] = people
.filter(
(person: PersonResponseDto) =>
personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
person.id !== personMerge2.id &&
person.id !== personMerge1.id &&
!person.isHidden,
)
.slice(0, 3);
let choosePersonToMerge = false;
const title = personMerge2.name;
const changePersonToMerge = (newperson: PersonResponseDto) => {
const index = potentialMergePeople.indexOf(newperson);
[potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]];
choosePersonToMerge = false;
};
</script>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
<div
class="w-[250px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[375px]"
>
<div class="relative flex items-center justify-between">
<h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
Merge faces - {title}
</h1>
<CircleIconButton logo={Close} on:click={() => dispatch('close')} />
</div>
<div class="flex items-center justify-center px-2 py-4 md:h-36 md:px-4 md:py-4">
{#if !choosePersonToMerge}
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(personMerge1.id)}
altText={personMerge1.name}
widthStyle="100%"
/>
</div>
<div class="mx-0.5 flex md:mx-2">
<CircleIconButton
logo={Merge}
on:click={() => ([personMerge1, personMerge2] = [personMerge2, personMerge1])}
/>
</div>
<button
disabled={potentialMergePeople.length === 0}
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
on:click={() => {
if (potentialMergePeople.length > 0) {
choosePersonToMerge = !choosePersonToMerge;
}
}}
>
<ImageThumbnail
border={potentialMergePeople.length !== 0}
circle
shadow
url={api.getPeopleThumbnailUrl(personMerge2.id)}
altText={personMerge2.name}
widthStyle="100%"
/>
</button>
{:else}
<div class="grid w-full grid-cols-1 gap-2">
<div class="px-2">
<button on:click={() => (choosePersonToMerge = false)}> <ArrowLeft /></button>
</div>
<div class="flex items-center justify-center">
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28">
<button class="p-2" on:click={() => changePersonToMerge(person)}>
<ImageThumbnail
border={true}
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100%"
on:click={() => changePersonToMerge(person)}
/>
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<div class="flex px-4 md:px-8 md:pt-4">
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same face?</h1>
</div>
<div class="flex px-4 pt-2 md:px-8">
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>
</div>
<div class="mt-8 flex w-full gap-4 px-4 pb-4">
<Button color="gray" fullwidth on:click={() => dispatch('reject')}>No</Button>
<Button fullwidth on:click={() => dispatch('confirm', [personMerge1, personMerge2])}>Yes</Button>
</div>
</div>
</div>

View File

@@ -78,7 +78,7 @@
</script>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
class="max-h-screen w-[500px] max-w-[95vw] overflow-y-scroll rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div class="flex flex-col place-content-center place-items-center gap-4 px-4">
<ImmichLogo class="text-center" height="100" width="100" />
@@ -128,7 +128,8 @@
{#if success}
<p class="ml-4 text-sm text-immich-primary">{success}</p>
{/if}
<div class="flex w-full p-4">
<div class="flex w-full gap-4 p-4">
<Button color="gray" fullwidth on:click={() => dispatch('cancel')}>Cancel</Button>
<Button type="submit" disabled={isCreatingUser} fullwidth>Create</Button>
</div>
</form>

View File

@@ -30,6 +30,7 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { isSearchEnabled } from '$lib/stores/search.store';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
export let user: UserResponseDto | undefined = undefined;
export let isAlbumSelectionMode = false;
@@ -39,6 +40,7 @@
let viewportWidth = 0;
let assetGridElement: HTMLElement;
let bucketInfo: AssetCountByTimeBucketResponseDto;
let showShortcuts = false;
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
@@ -93,6 +95,9 @@
if (!$isViewingAssetStoreState) {
switch (event.key) {
case '?':
if (event.shiftKey) showShortcuts = !showShortcuts;
return;
case '/':
goto(AppRoute.EXPLORE);
return;
@@ -290,6 +295,10 @@
<svelte:window on:keydown={onKeyDown} on:keyup={onKeyUp} on:selectstart={onSelectStart} />
{#if showShortcuts}
<ShowShortcuts on:close={() => (showShortcuts = !showShortcuts)} />
{/if}
{#if bucketInfo && viewportHeight && $assetGridState.timelineHeight > viewportHeight}
<Scrollbar
scrollbarHeight={viewportHeight}

View File

@@ -43,7 +43,7 @@
<div in:fly={{ y: 10, duration: 200 }} class="fixed top-0 z-[100] w-full bg-transparent">
<div
id="asset-selection-app-bar"
class={`grid grid-cols-3 justify-between ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[20%_60%_20%] lg:grid-cols-3 ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
forceDark && 'bg-immich-dark-gray text-white'
}`}
>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { AssetResponseDto, api } from '@api';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { notificationController, NotificationType } from './notification/notification';
import { handleError } from '$lib/utils/handle-error';
import domtoimage from 'dom-to-image';
@@ -13,6 +13,10 @@
const dispatch = createEventDispatcher();
let imgElement: HTMLDivElement;
onMount(() => {
imgElement.style.width = '100%';
});
const hasTransparentPixels = async (blob: Blob) => {
const img = new Image();
img.src = URL.createObjectURL(blob);

View File

@@ -5,13 +5,16 @@
import { goto } from '$app/navigation';
import { isSearchEnabled, savedSearchTerms } from '$lib/stores/search.store';
import { fly } from 'svelte/transition';
import { clickOutside } from '$lib/utils/click-outside';
export let value = '';
export let grayTheme: boolean;
let input: HTMLInputElement;
let showBigSearchBar = false;
$: showClearIcon = value.length > 0;
function onSearch(saveSearch: boolean) {
function onSearch() {
let clipSearch = 'true';
let searchValue = value;
@@ -20,18 +23,23 @@
searchValue = value.slice(2);
}
if (saveSearch) {
saveSearchTerm(value);
}
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchValue);
saveSearchTerm(searchValue);
const params = new URLSearchParams({
q: searchValue,
clip: clipSearch,
});
showBigSearchBar = false;
goto(`${AppRoute.SEARCH}?${params}`);
}
const clearSearchTerm = (searchTerm: string) => {
input.focus();
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== searchTerm);
};
const saveSearchTerm = (saveValue: string) => {
$savedSearchTerms = [saveValue, ...$savedSearchTerms];
@@ -40,7 +48,8 @@
}
};
const clearSearchTerm = () => {
const clearAllSearchTerms = () => {
input.focus();
$savedSearchTerms = [];
};
@@ -55,85 +64,100 @@
};
</script>
<form
draggable="false"
autocomplete="off"
class="relative text-sm"
action={AppRoute.SEARCH}
on:reset={() => (value = '')}
on:submit|preventDefault={() => onSearch(true)}
on:focusin={onFocusIn}
on:focusout={onFocusOut}
>
<label>
<div class="absolute inset-y-0 left-0 flex items-center pl-6">
<div class="pointer-events-none dark:text-immich-dark-fg/75">
<Magnify size="1.5em" />
</div>
</div>
<input
type="text"
name="q"
class="w-full transition-all {grayTheme
? 'dark:bg-immich-dark-gray'
: 'dark:bg-immich-dark-bg'} px-14 py-4 text-immich-fg/75 dark:text-immich-dark-fg {showBigSearchBar
? 'rounded-t-3xl border border-gray-200 bg-white dark:border-gray-800'
: 'rounded-3xl border border-transparent bg-gray-200'}"
placeholder="Search your photos"
required
pattern="^(?!m:$).*$"
bind:value
/>
</label>
{#if showClearIcon}
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
<button
type="reset"
class="rounded-full p-2 hover:bg-immich-primary/5 active:bg-immich-primary/10 dark:text-immich-dark-fg/75 dark:hover:bg-immich-dark-primary/25 dark:active:bg-immich-dark-primary/[.35]"
>
<Close size="1.5em" />
</button>
</div>
{/if}
{#if showBigSearchBar}
<div
transition:fly={{ y: 25, duration: 250 }}
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300"
>
<div class="px-5 pt-5 text-xs">
<p>
Smart search is enabled by default, to search for metadata use the syntax <span
class="rounded-lg bg-gray-100 p-2 font-mono font-semibold leading-7 text-immich-primary dark:bg-gray-900 dark:text-immich-dark-primary"
>m:your-search-term</span
>
</p>
</div>
{#if $savedSearchTerms.length > 0}
<div class="flex justify-between px-5 pt-5 text-xs">
<p>RECENT SEARCHES</p>
<button
type="button"
class="rounded-lg p-2 font-semibold text-immich-primary hover:bg-immich-primary/25 dark:text-immich-dark-primary"
on:click={clearSearchTerm}>Clear all</button
>
<button class="w-full" use:clickOutside on:outclick={onFocusOut}>
<form
draggable="false"
autocomplete="off"
class="relative select-text text-sm"
action={AppRoute.SEARCH}
on:reset={() => (value = '')}
on:submit|preventDefault={() => onSearch()}
>
<label>
<div class="absolute inset-y-0 left-0 flex items-center pl-6">
<div class="dark:text-immich-dark-fg/75">
<button class="flex items-center">
<Magnify size="1.5em" />
</button>
</div>
{/if}
{#each $savedSearchTerms as savedSearchTerm, i (i)}
</div>
<input
type="text"
name="q"
class="w-full transition-all {grayTheme
? 'dark:bg-immich-dark-gray'
: 'dark:bg-immich-dark-bg'} px-14 py-4 text-immich-fg/75 dark:text-immich-dark-fg {showBigSearchBar
? 'rounded-t-3xl border border-gray-200 bg-white dark:border-gray-800'
: 'rounded-3xl border border-transparent bg-gray-200'}"
placeholder="Search your photos"
required
pattern="^(?!m:$).*$"
bind:value
bind:this={input}
on:click={onFocusIn}
/>
</label>
{#if showClearIcon}
<div class="absolute inset-y-0 right-0 flex items-center pr-4">
<button
type="button"
class="flex w-full cursor-pointer gap-3 px-5 py-3 text-black hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-500/10"
on:click={() => {
value = savedSearchTerm;
onSearch(false);
}}
type="reset"
class="rounded-full p-2 hover:bg-immich-primary/5 active:bg-immich-primary/10 dark:text-immich-dark-fg/75 dark:hover:bg-immich-dark-primary/25 dark:active:bg-immich-dark-primary/[.35]"
>
<Magnify size="1.5em" />
{savedSearchTerm}
<Close size="1.5em" />
</button>
{/each}
</div>
{/if}
</form>
</div>
{/if}
{#if showBigSearchBar}
<div
transition:fly={{ y: 25, duration: 250 }}
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300"
>
<div class="flex px-5 pt-5 text-left text-xs">
<p>
Smart search is enabled by default, to search for metadata use the syntax <span
class="rounded-lg bg-gray-100 p-2 font-mono font-semibold leading-7 text-immich-primary dark:bg-gray-900 dark:text-immich-dark-primary"
>m:your-search-term</span
>
</p>
</div>
{#if $savedSearchTerms.length > 0}
<div class="flex items-center justify-between px-5 pt-5 text-xs">
<p>RECENT SEARCHES</p>
<div class="flex w-18 items-center justify-center">
<button
type="button"
class="rounded-lg p-2 font-semibold text-immich-primary hover:bg-immich-primary/25 dark:text-immich-dark-primary"
on:click={clearAllSearchTerms}>Clear all</button
>
</div>
</div>
{/if}
{#each $savedSearchTerms as savedSearchTerm, i (i)}
<div
class="flex w-full items-center justify-between text-xs text-black hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-500/10"
>
<div class="relative w-full items-center">
<button
type="button"
class="relative flex w-full cursor-pointer gap-3 py-3 pl-5"
on:click={() => {
value = savedSearchTerm;
onSearch();
}}
>
<Magnify size="1.5em" />
{savedSearchTerm}
</button>
<div class="absolute right-5 top-0 items-center justify-center py-3">
<button type="button" on:click={() => clearSearchTerm(savedSearchTerm)}><Close size="18" /></button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</form>
</button>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import Close from 'svelte-material-icons/Close.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { createEventDispatcher } from 'svelte';
import FullScreenModal from './full-screen-modal.svelte';
const shortcuts = {
general: [
{ key: ['←', '→'], action: 'Previous or next photo' },
{ key: ['Esc'], action: 'Back, close, or deselect' },
{ key: ['/'], action: 'Search your photos' },
],
actions: [
{ key: ['f'], action: 'Favorite or unfavorite photo' },
{ key: ['i'], action: 'Show or hide info' },
{ key: ['⇧', 'a'], action: 'Archive or unarchive photo' },
{ key: ['⇧', 'd'], action: 'Download' },
{ key: ['Space'], action: 'Play or pause video' },
{ key: ['Del'], action: 'Delete Asset' },
],
};
const dispatch = createEventDispatcher();
</script>
<FullScreenModal on:clickOutside={() => dispatch('close')}>
<div class="flex h-full w-full place-content-center place-items-center overflow-hidden">
<div
class="w-[400px] max-w-[125vw] rounded-3xl border bg-immich-bg shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg md:w-[650px]"
>
<div class="relative px-4 pt-4">
<h1 class="px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">Keyboard Shortcuts</h1>
<div class="absolute inset-y-0 right-0 px-4 py-4">
<CircleIconButton logo={Close} on:click={() => dispatch('close')} />
</div>
</div>
<div class="grid grid-cols-1 gap-4 px-4 pb-4 md:grid-cols-2">
<div class="px-4 py-4">
<h2>General</h2>
<div class="text-sm">
{#each shortcuts.general as shortcut}
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p
class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{/each}
</div>
</div>
<div class="px-4 py-4">
<h2>Actions</h2>
<div class="text-sm">
{#each shortcuts.actions as shortcut}
<div class="grid grid-cols-[20%_80%] items-center gap-4 pt-4 text-sm">
<div class="flex justify-self-end">
{#each shortcut.key as key}
<p
class="mr-1 flex items-center justify-center justify-self-end rounded-lg bg-immich-primary/25 p-2"
>
{key}
</p>
{/each}
</div>
<p class="mb-1 mt-1 flex">{shortcut.action}</p>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
</FullScreenModal>

View File

@@ -25,3 +25,14 @@ export enum AppRoute {
AUTH_REGISTER = '/auth/register',
AUTH_CHANGE_PASSWORD = '/auth/change-password',
}
export enum ProjectionType {
EQUIRECTANGULAR = 'EQUIRECTANGULAR',
CUBEMAP = 'CUBEMAP',
CUBESTRIP = 'CUBESTRIP',
EQUIRECTANGULAR_STEREO = 'EQUIRECTANGULAR_STEREO',
CUBEMAP_STEREO = 'CUBEMAP_STEREO',
CUBESTRIP_STEREO = 'CUBESTRIP_STEREO',
CYLINDER = 'CYLINDER',
NONE = 'NONE',
}

View File

@@ -19,6 +19,7 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { onDestroy, onMount } from 'svelte';
import { browser } from '$app/environment';
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
export let data: PageData;
let selectHidden = false;
@@ -33,6 +34,13 @@
let showLoadingSpinner = false;
let toggleVisibility = false;
let showChangeNameModal = false;
let showMergeModal = false;
let personName = '';
let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto;
let edittingPerson: PersonResponseDto | null = null;
people.forEach((person: PersonResponseDto) => {
initialHiddenValues[person.id] = person.isHidden;
});
@@ -136,13 +144,60 @@
toggleVisibility = false;
};
let showChangeNameModal = false;
let personName = '';
let edittingPerson: PersonResponseDto | null = null;
const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
const [personToMerge, personToBeMergedIn] = response;
showMergeModal = false;
if (!edittingPerson) {
return;
}
try {
await api.personApi.mergePerson({
id: personMerge2.id,
mergePersonDto: { ids: [personToMerge.id] },
});
countVisiblePeople--;
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
notificationController.show({
message: 'Merge faces succesfully',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to save name');
}
if (personToBeMergedIn.name !== personName && edittingPerson.id === personToBeMergedIn.id) {
/*
*
* If the user merges one of the suggested people into the person he's editing it, it's merging the suggested person AND renames
* the person he's editing
*
*/
try {
await api.personApi.updatePerson({ id: personToBeMergedIn.id, personUpdateDto: { name: personName } });
for (const person of people) {
if (person.id === personToBeMergedIn.id) {
person.name = personName;
break;
}
}
notificationController.show({
message: 'Change name succesfully',
type: NotificationType.Info,
});
// trigger reactivity
people = people;
} catch (error) {
handleError(error, 'Unable to save name');
}
}
};
const handleChangeName = ({ detail }: CustomEvent<PersonResponseDto>) => {
showChangeNameModal = true;
personName = detail.name;
personMerge1 = detail;
edittingPerson = detail;
};
@@ -182,33 +237,73 @@
};
const submitNameChange = async () => {
showChangeNameModal = false;
if (!edittingPerson) {
return;
}
if (personName === edittingPerson.name) {
return;
}
// We check if another person has the same name as the name entered by the user
const existingPerson = people.find(
(person: PersonResponseDto) =>
person.name.toLowerCase() === personName.toLowerCase() &&
edittingPerson &&
person.id !== edittingPerson.id &&
person.name,
);
if (existingPerson) {
personMerge2 = existingPerson;
showMergeModal = true;
return;
}
changeName();
};
const changeName = async () => {
showMergeModal = false;
showChangeNameModal = false;
if (!edittingPerson) {
return;
}
try {
if (edittingPerson) {
const { data: updatedPerson } = await api.personApi.updatePerson({
id: edittingPerson.id,
personUpdateDto: { name: personName },
});
const { data: updatedPerson } = await api.personApi.updatePerson({
id: edittingPerson.id,
personUpdateDto: { name: personName },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
showChangeNameModal = false;
notificationController.show({
message: 'Change name succesfully',
type: NotificationType.Info,
});
}
notificationController.show({
message: 'Change name succesfully',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to save name');
}
};
</script>
{#if showMergeModal}
<FullScreenModal on:clickOutside={() => (showMergeModal = false)}>
<MergeSuggestionModal
{personMerge1}
{personMerge2}
{people}
on:close={() => (showMergeModal = false)}
on:reject={() => changeName()}
on:confirm={(event) => handleMergeSameFace(event.detail)}
/>
</FullScreenModal>
{/if}
<UserPageLayout user={data.user} title="People">
<svelte:fragment slot="buttons">
{#if countTotalPeople > 0}

View File

@@ -10,11 +10,13 @@ export const load = (async ({ locals, parent, params }) => {
const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
const { data: assets } = await locals.api.personApi.getPersonAssets({ id: params.personId });
const { data: people } = await locals.api.personApi.getAllPeople({ withHidden: false });
return {
user,
assets,
person,
people,
meta: {
title: person.name || 'Person',
},

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
@@ -15,7 +15,7 @@
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { AssetResponseDto, api } from '@api';
import { AssetResponseDto, PersonResponseDto, api } from '@api';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
import DotsVertical from 'svelte-material-icons/DotsVertical.svelte';
import Plus from 'svelte-material-icons/Plus.svelte';
@@ -30,6 +30,8 @@
} from '$lib/components/shared-components/notification/notification';
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
import { onMount } from 'svelte';
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
export let data: PageData;
let isEditingName = false;
@@ -37,6 +39,13 @@
let showMergeFacePanel = false;
let previousRoute: string = AppRoute.EXPLORE;
let selectedAssets: Set<AssetResponseDto> = new Set();
let showMergeModal = false;
let people = data.people.people;
let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto;
let personName = '';
$: isMultiSelectionMode = selectedAssets.size > 0;
$: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
@@ -56,16 +65,6 @@
}
});
const handleNameChange = async (name: string) => {
try {
isEditingName = false;
data.person.name = name;
await api.personApi.updatePerson({ id: data.person.id, personUpdateDto: { name } });
} catch (error) {
handleError(error, 'Unable to save name');
}
};
const onAssetDelete = (assetId: string) => {
data.assets = data.assets.filter((asset: AssetResponseDto) => asset.id !== assetId);
};
@@ -91,8 +90,92 @@
});
}
};
const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
const [personToMerge, personToBeMergedIn] = response;
showMergeModal = false;
try {
await api.personApi.mergePerson({
id: personToBeMergedIn.id,
mergePersonDto: { ids: [personToMerge.id] },
});
notificationController.show({
message: 'Merge faces succesfully',
type: NotificationType.Info,
});
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
if (personToBeMergedIn.name != personName && data.person.id === personToBeMergedIn.id) {
changeName();
invalidateAll();
return;
}
goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
} catch (error) {
handleError(error, 'Unable to save name');
}
};
const changeName = async () => {
showMergeModal = false;
data.person.name = personName;
try {
isEditingName = false;
const { data: updatedPerson } = await api.personApi.updatePerson({
id: data.person.id,
personUpdateDto: { name: personName },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
notificationController.show({
message: 'Change name succesfully',
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to save name');
}
};
const handleNameChange = async (name: string) => {
personName = name;
if (data.person.name === personName) {
return;
}
const existingPerson = people.find(
(person: PersonResponseDto) =>
person.name.toLowerCase() === personName.toLowerCase() && person.id !== data.person.id && person.name,
);
if (existingPerson) {
personMerge2 = existingPerson;
personMerge1 = data.person;
showMergeModal = true;
return;
}
changeName();
};
</script>
{#if showMergeModal}
<FullScreenModal on:clickOutside={() => (showMergeModal = false)}>
<MergeSuggestionModal
{personMerge1}
{personMerge2}
{people}
on:close={() => (showMergeModal = false)}
on:reject={() => changeName()}
on:confirm={(event) => handleMergeSameFace(event.detail)}
/>
</FullScreenModal>
{/if}
{#if isMultiSelectionMode}
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CreateSharedLink />

View File

@@ -85,7 +85,7 @@
</AssetSelectControlBar>
{:else}
<ControlAppBar on:close-button-click={() => goto(previousRoute)} backIcon={ArrowLeft}>
<div class="w-full max-w-2xl flex-1 pl-4">
<div class="w-full flex-1 pl-4">
<SearchBar grayTheme={false} value={term} />
</div>
</ControlAppBar>

View File

@@ -110,7 +110,7 @@
<section>
{#if shouldShowCreateUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowCreateUserForm = false)}>
<CreateUserForm on:user-created={onUserCreated} />
<CreateUserForm on:user-created={onUserCreated} on:cancel={() => (shouldShowCreateUserForm = false)} />
</FullScreenModal>
{/if}