mirror of
https://github.com/immich-app/immich.git
synced 2025-01-27 22:22:45 +01:00
Merge branch 'main' into feat/theme-customizer
This commit is contained in:
commit
fc43b15821
81 changed files with 1234 additions and 642 deletions
docker
docs/docs/developer
e2e/src/api/specs
i18n
mobile
.fvmrcanalysis_options.yaml
lib
interfaces
models/auth
pages/common
providers
app_life_cycle.provider.dartauth.provider.dartauthentication.provider.dart
backup
websocket.provider.dartrepositories
services
widgets
common/app_bar_dialog
forms
openapi/lib/model
pubspec.lockpubspec.yamltest
open-api
server
web
src
app.d.tsconstants.ts
tsconfig.jsonlib
__mocks__
actions
components
admin-page/settings
asset-viewer
layouts
shared-components
user-settings-page
stores
utils
routes/(user)
albums
folders/[[photos=photos]]/[[assetId=id]]
people
|
@ -125,26 +125,23 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
test: >-
|
||||||
|
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
||||||
|
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
||||||
|
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
||||||
|
echo "checksum failure count is $$Chksum";
|
||||||
|
[ "$$Chksum" = '0' ] || exit 1
|
||||||
interval: 5m
|
interval: 5m
|
||||||
start_interval: 30s
|
start_interval: 30s
|
||||||
start_period: 5m
|
start_period: 5m
|
||||||
command:
|
command: >-
|
||||||
[
|
postgres
|
||||||
'postgres',
|
-c shared_preload_libraries=vectors.so
|
||||||
'-c',
|
-c 'search_path="$$user", public, vectors'
|
||||||
'shared_preload_libraries=vectors.so',
|
-c logging_collector=on
|
||||||
'-c',
|
-c max_wal_size=2GB
|
||||||
'search_path="$$user", public, vectors',
|
-c shared_buffers=512MB
|
||||||
'-c',
|
-c wal_compression=on
|
||||||
'logging_collector=on',
|
|
||||||
'-c',
|
|
||||||
'max_wal_size=2GB',
|
|
||||||
'-c',
|
|
||||||
'shared_buffers=512MB',
|
|
||||||
'-c',
|
|
||||||
'wal_compression=on',
|
|
||||||
]
|
|
||||||
|
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
# immich-prometheus:
|
# immich-prometheus:
|
||||||
|
|
|
@ -67,26 +67,23 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
test: >-
|
||||||
|
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
||||||
|
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
||||||
|
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
||||||
|
echo "checksum failure count is $$Chksum";
|
||||||
|
[ "$$Chksum" = '0' ] || exit 1
|
||||||
interval: 5m
|
interval: 5m
|
||||||
start_interval: 30s
|
start_interval: 30s
|
||||||
start_period: 5m
|
start_period: 5m
|
||||||
command:
|
command: >-
|
||||||
[
|
postgres
|
||||||
'postgres',
|
-c shared_preload_libraries=vectors.so
|
||||||
'-c',
|
-c 'search_path="$$user", public, vectors'
|
||||||
'shared_preload_libraries=vectors.so',
|
-c logging_collector=on
|
||||||
'-c',
|
-c max_wal_size=2GB
|
||||||
'search_path="$$user", public, vectors',
|
-c shared_buffers=512MB
|
||||||
'-c',
|
-c wal_compression=on
|
||||||
'logging_collector=on',
|
|
||||||
'-c',
|
|
||||||
'max_wal_size=2GB',
|
|
||||||
'-c',
|
|
||||||
'shared_buffers=512MB',
|
|
||||||
'-c',
|
|
||||||
'wal_compression=on',
|
|
||||||
]
|
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
|
|
|
@ -65,26 +65,23 @@ services:
|
||||||
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
||||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1; Chksum="$$(psql --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' --tuples-only --no-align --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')"; echo "checksum failure count is $$Chksum"; [ "$$Chksum" = '0' ] || exit 1
|
test: >-
|
||||||
|
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
||||||
|
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
||||||
|
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
||||||
|
echo "checksum failure count is $$Chksum";
|
||||||
|
[ "$$Chksum" = '0' ] || exit 1
|
||||||
interval: 5m
|
interval: 5m
|
||||||
start_interval: 30s
|
start_interval: 30s
|
||||||
start_period: 5m
|
start_period: 5m
|
||||||
command:
|
command: >-
|
||||||
[
|
postgres
|
||||||
'postgres',
|
-c shared_preload_libraries=vectors.so
|
||||||
'-c',
|
-c 'search_path="$$user", public, vectors'
|
||||||
'shared_preload_libraries=vectors.so',
|
-c logging_collector=on
|
||||||
'-c',
|
-c max_wal_size=2GB
|
||||||
'search_path="$$user", public, vectors',
|
-c shared_buffers=512MB
|
||||||
'-c',
|
-c wal_compression=on
|
||||||
'logging_collector=on',
|
|
||||||
'-c',
|
|
||||||
'max_wal_size=2GB',
|
|
||||||
'-c',
|
|
||||||
'shared_buffers=512MB',
|
|
||||||
'-c',
|
|
||||||
'wal_compression=on',
|
|
||||||
]
|
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
@ -11,6 +11,7 @@ When contributing code through a pull request, please check the following:
|
||||||
- [ ] `npm run lint` (linting via ESLint)
|
- [ ] `npm run lint` (linting via ESLint)
|
||||||
- [ ] `npm run format` (formatting via Prettier)
|
- [ ] `npm run format` (formatting via Prettier)
|
||||||
- [ ] `npm run check:svelte` (Type checking via SvelteKit)
|
- [ ] `npm run check:svelte` (Type checking via SvelteKit)
|
||||||
|
- [ ] `npm run check:typescript` (check typescript)
|
||||||
- [ ] `npm test` (unit tests)
|
- [ ] `npm test` (unit tests)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
|
@ -133,6 +133,7 @@ describe('/server', () => {
|
||||||
userDeleteDelay: 7,
|
userDeleteDelay: 7,
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
|
publicUsers: true,
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
{
|
{
|
||||||
|
"user_usage_stats": "Account usage statistics",
|
||||||
|
"user_usage_stats_description": "View account usage statistics",
|
||||||
"about": "Refresh",
|
"about": "Refresh",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Account Settings",
|
||||||
|
@ -222,6 +224,8 @@
|
||||||
"send_welcome_email": "Send welcome email",
|
"send_welcome_email": "Send welcome email",
|
||||||
"server_external_domain_settings": "External domain",
|
"server_external_domain_settings": "External domain",
|
||||||
"server_external_domain_settings_description": "Domain for public shared links, including http(s)://",
|
"server_external_domain_settings_description": "Domain for public shared links, including http(s)://",
|
||||||
|
"server_public_users": "Public Users",
|
||||||
|
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
|
||||||
"server_settings": "Server Settings",
|
"server_settings": "Server Settings",
|
||||||
"server_settings_description": "Manage server settings",
|
"server_settings_description": "Manage server settings",
|
||||||
"server_welcome_message": "Welcome message",
|
"server_welcome_message": "Welcome message",
|
||||||
|
@ -1311,6 +1315,7 @@
|
||||||
"view_all_users": "View all users",
|
"view_all_users": "View all users",
|
||||||
"view_in_timeline": "View in timeline",
|
"view_in_timeline": "View in timeline",
|
||||||
"view_links": "View links",
|
"view_links": "View links",
|
||||||
|
"view_name": "View",
|
||||||
"view_next_asset": "View next asset",
|
"view_next_asset": "View next asset",
|
||||||
"view_previous_asset": "View previous asset",
|
"view_previous_asset": "View previous asset",
|
||||||
"view_stack": "View Stack",
|
"view_stack": "View Stack",
|
||||||
|
@ -1324,5 +1329,7 @@
|
||||||
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
"years_ago": "{years, plural, one {# year} other {# years}} ago",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"you_dont_have_any_shared_links": "You don't have any shared links",
|
"you_dont_have_any_shared_links": "You don't have any shared links",
|
||||||
"zoom_image": "Zoom Image"
|
"zoom_image": "Zoom Image",
|
||||||
|
"timeline": "Timeline",
|
||||||
|
"total": "Total"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"flutter": "3.24.4"
|
"flutter": "3.24.5"
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,7 +93,7 @@ custom_lint:
|
||||||
- lib/models/server_info/server_{config,disk_info,features,version}.model.dart
|
- lib/models/server_info/server_{config,disk_info,features,version}.model.dart
|
||||||
- lib/models/shared_link/shared_link.model.dart
|
- lib/models/shared_link/shared_link.model.dart
|
||||||
- lib/providers/asset_viewer/asset_people.provider.dart
|
- lib/providers/asset_viewer/asset_people.provider.dart
|
||||||
- lib/providers/authentication.provider.dart
|
- lib/providers/auth.provider.dart
|
||||||
- lib/providers/image/immich_remote_{image,thumbnail}_provider.dart
|
- lib/providers/image/immich_remote_{image,thumbnail}_provider.dart
|
||||||
- lib/providers/map/map_state.provider.dart
|
- lib/providers/map/map_state.provider.dart
|
||||||
- lib/providers/search/{search,search_filter}.provider.dart
|
- lib/providers/search/{search,search_filter}.provider.dart
|
||||||
|
|
5
mobile/lib/interfaces/auth.interface.dart
Normal file
5
mobile/lib/interfaces/auth.interface.dart
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||||
|
|
||||||
|
abstract interface class IAuthRepository implements IDatabaseRepository {
|
||||||
|
Future<void> clearLocalData();
|
||||||
|
}
|
9
mobile/lib/interfaces/auth_api.interface.dart
Normal file
9
mobile/lib/interfaces/auth_api.interface.dart
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||||
|
|
||||||
|
abstract interface class IAuthApiRepository {
|
||||||
|
Future<LoginResponse> login(String email, String password);
|
||||||
|
|
||||||
|
Future<void> logout();
|
||||||
|
|
||||||
|
Future<void> changePassword(String newPassword);
|
||||||
|
}
|
|
@ -1,62 +1,58 @@
|
||||||
class AuthenticationState {
|
class AuthState {
|
||||||
final String deviceId;
|
final String deviceId;
|
||||||
final String userId;
|
final String userId;
|
||||||
final String userEmail;
|
final String userEmail;
|
||||||
final bool isAuthenticated;
|
final bool isAuthenticated;
|
||||||
final String name;
|
final String name;
|
||||||
final bool isAdmin;
|
final bool isAdmin;
|
||||||
final bool shouldChangePassword;
|
|
||||||
final String profileImagePath;
|
final String profileImagePath;
|
||||||
AuthenticationState({
|
|
||||||
|
AuthState({
|
||||||
required this.deviceId,
|
required this.deviceId,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.userEmail,
|
required this.userEmail,
|
||||||
required this.isAuthenticated,
|
required this.isAuthenticated,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.isAdmin,
|
required this.isAdmin,
|
||||||
required this.shouldChangePassword,
|
|
||||||
required this.profileImagePath,
|
required this.profileImagePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
AuthenticationState copyWith({
|
AuthState copyWith({
|
||||||
String? deviceId,
|
String? deviceId,
|
||||||
String? userId,
|
String? userId,
|
||||||
String? userEmail,
|
String? userEmail,
|
||||||
bool? isAuthenticated,
|
bool? isAuthenticated,
|
||||||
String? name,
|
String? name,
|
||||||
bool? isAdmin,
|
bool? isAdmin,
|
||||||
bool? shouldChangePassword,
|
|
||||||
String? profileImagePath,
|
String? profileImagePath,
|
||||||
}) {
|
}) {
|
||||||
return AuthenticationState(
|
return AuthState(
|
||||||
deviceId: deviceId ?? this.deviceId,
|
deviceId: deviceId ?? this.deviceId,
|
||||||
userId: userId ?? this.userId,
|
userId: userId ?? this.userId,
|
||||||
userEmail: userEmail ?? this.userEmail,
|
userEmail: userEmail ?? this.userEmail,
|
||||||
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
isAdmin: isAdmin ?? this.isAdmin,
|
isAdmin: isAdmin ?? this.isAdmin,
|
||||||
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
|
|
||||||
profileImagePath: profileImagePath ?? this.profileImagePath,
|
profileImagePath: profileImagePath ?? this.profileImagePath,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath)';
|
return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
return other is AuthenticationState &&
|
return other is AuthState &&
|
||||||
other.deviceId == deviceId &&
|
other.deviceId == deviceId &&
|
||||||
other.userId == userId &&
|
other.userId == userId &&
|
||||||
other.userEmail == userEmail &&
|
other.userEmail == userEmail &&
|
||||||
other.isAuthenticated == isAuthenticated &&
|
other.isAuthenticated == isAuthenticated &&
|
||||||
other.name == name &&
|
other.name == name &&
|
||||||
other.isAdmin == isAdmin &&
|
other.isAdmin == isAdmin &&
|
||||||
other.shouldChangePassword == shouldChangePassword &&
|
|
||||||
other.profileImagePath == profileImagePath;
|
other.profileImagePath == profileImagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,7 +64,6 @@ class AuthenticationState {
|
||||||
isAuthenticated.hashCode ^
|
isAuthenticated.hashCode ^
|
||||||
name.hashCode ^
|
name.hashCode ^
|
||||||
isAdmin.hashCode ^
|
isAdmin.hashCode ^
|
||||||
shouldChangePassword.hashCode ^
|
|
||||||
profileImagePath.hashCode;
|
profileImagePath.hashCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
30
mobile/lib/models/auth/login_response.model.dart
Normal file
30
mobile/lib/models/auth/login_response.model.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
class LoginResponse {
|
||||||
|
final String accessToken;
|
||||||
|
|
||||||
|
final bool isAdmin;
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
final String profileImagePath;
|
||||||
|
|
||||||
|
final bool shouldChangePassword;
|
||||||
|
|
||||||
|
final String userEmail;
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
LoginResponse({
|
||||||
|
required this.accessToken,
|
||||||
|
required this.isAdmin,
|
||||||
|
required this.name,
|
||||||
|
required this.profileImagePath,
|
||||||
|
required this.shouldChangePassword,
|
||||||
|
required this.userEmail,
|
||||||
|
required this.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'LoginResponse[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]';
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
|
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
@ -25,7 +25,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final sharedUsers = useState(album.sharedUsers.toList());
|
final sharedUsers = useState(album.sharedUsers.toList());
|
||||||
final owner = album.owner.value;
|
final owner = album.owner.value;
|
||||||
final userId = ref.watch(authenticationProvider).userId;
|
final userId = ref.watch(authProvider).userId;
|
||||||
final activityEnabled = useState(album.activityEnabled);
|
final activityEnabled = useState(album.activityEnabled);
|
||||||
final isProcessing = useProcessingOverlay();
|
final isProcessing = useProcessingOverlay();
|
||||||
final isOwner = owner?.id == userId;
|
final isOwner = owner?.id == userId;
|
||||||
|
|
|
@ -15,7 +15,7 @@ import 'package:immich_mobile/utils/immich_loading_overlay.dart';
|
||||||
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
|
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
|
||||||
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
|
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
|
||||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart';
|
import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
@ -42,7 +42,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
||||||
() => ref.read(currentAlbumProvider.notifier).set(value),
|
() => ref.read(currentAlbumProvider.notifier).set(value),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final userId = ref.watch(authenticationProvider).userId;
|
final userId = ref.watch(authProvider).userId;
|
||||||
final isProcessing = useProcessingOverlay();
|
final isProcessing = useProcessingOverlay();
|
||||||
|
|
||||||
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
|
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
|
||||||
|
|
|
@ -3,11 +3,10 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
|
@ -16,7 +15,6 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final apiService = ref.watch(apiServiceProvider);
|
|
||||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
||||||
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
|
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
|
||||||
final accessToken = Store.tryGet(StoreKey.accessToken);
|
final accessToken = Store.tryGet(StoreKey.accessToken);
|
||||||
|
@ -26,14 +24,9 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||||
bool isAuthSuccess = false;
|
bool isAuthSuccess = false;
|
||||||
|
|
||||||
if (accessToken != null && serverUrl != null && endpoint != null) {
|
if (accessToken != null && serverUrl != null && endpoint != null) {
|
||||||
apiService.setEndpoint(endpoint);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isAuthSuccess = await ref
|
isAuthSuccess = await ref.read(authProvider.notifier).saveAuthInfo(
|
||||||
.read(authenticationProvider.notifier)
|
|
||||||
.setSuccessLoginInfo(
|
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
serverUrl: serverUrl,
|
|
||||||
);
|
);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log.severe(
|
log.severe(
|
||||||
|
@ -53,7 +46,7 @@ class SplashScreenPage extends HookConsumerWidget {
|
||||||
log.severe(
|
log.severe(
|
||||||
'Unable to login using offline or online methods - Logging out completely',
|
'Unable to login using offline or online methods - Logging out completely',
|
||||||
);
|
);
|
||||||
ref.read(authenticationProvider.notifier).logout();
|
ref.read(authProvider.notifier).logout();
|
||||||
context.replaceRoute(const LoginRoute());
|
context.replaceRoute(const LoginRoute());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
|
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||||
|
@ -42,7 +42,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||||
if (!_wasPaused) return;
|
if (!_wasPaused) return;
|
||||||
_wasPaused = false;
|
_wasPaused = false;
|
||||||
|
|
||||||
final isAuthenticated = _ref.read(authenticationProvider).isAuthenticated;
|
final isAuthenticated = _ref.read(authProvider).isAuthenticated;
|
||||||
|
|
||||||
// Needs to be logged in
|
// Needs to be logged in
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
@ -85,7 +85,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
|
||||||
state = AppLifeCycleEnum.paused;
|
state = AppLifeCycleEnum.paused;
|
||||||
_wasPaused = true;
|
_wasPaused = true;
|
||||||
|
|
||||||
if (_ref.read(authenticationProvider).isAuthenticated) {
|
if (_ref.read(authProvider).isAuthenticated) {
|
||||||
// Do not cancel backup if manual upload is in progress
|
// Do not cancel backup if manual upload is in progress
|
||||||
if (_ref.read(backupProvider.notifier).backupProgress !=
|
if (_ref.read(backupProvider.notifier).backupProgress !=
|
||||||
BackUpProgressEnum.manualInProgress) {
|
BackUpProgressEnum.manualInProgress) {
|
||||||
|
|
164
mobile/lib/providers/auth.provider.dart
Normal file
164
mobile/lib/providers/auth.provider.dart
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_udid/flutter_udid.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||||
|
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||||
|
return AuthNotifier(
|
||||||
|
ref.watch(authServiceProvider),
|
||||||
|
ref.watch(apiServiceProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
|
final AuthService _authService;
|
||||||
|
final ApiService _apiService;
|
||||||
|
final _log = Logger("AuthenticationNotifier");
|
||||||
|
|
||||||
|
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||||
|
|
||||||
|
AuthNotifier(
|
||||||
|
this._authService,
|
||||||
|
this._apiService,
|
||||||
|
) : super(
|
||||||
|
AuthState(
|
||||||
|
deviceId: "",
|
||||||
|
userId: "",
|
||||||
|
userEmail: "",
|
||||||
|
name: '',
|
||||||
|
profileImagePath: '',
|
||||||
|
isAdmin: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<String> validateServerUrl(String url) {
|
||||||
|
return _authService.validateServerUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoginResponse> login(String email, String password) async {
|
||||||
|
final response = await _authService.login(email, password);
|
||||||
|
await saveAuthInfo(accessToken: response.accessToken);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _authService.logout();
|
||||||
|
} finally {
|
||||||
|
await _cleanUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanUp() async {
|
||||||
|
state = AuthState(
|
||||||
|
deviceId: "",
|
||||||
|
userId: "",
|
||||||
|
userEmail: "",
|
||||||
|
name: '',
|
||||||
|
profileImagePath: '',
|
||||||
|
isAdmin: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateUserProfileImagePath(String path) {
|
||||||
|
state = state.copyWith(profileImagePath: path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> changePassword(String newPassword) async {
|
||||||
|
try {
|
||||||
|
await _authService.changePassword(newPassword);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> saveAuthInfo({
|
||||||
|
required String accessToken,
|
||||||
|
}) async {
|
||||||
|
_apiService.setAccessToken(accessToken);
|
||||||
|
|
||||||
|
// Get the deviceid from the store if it exists, otherwise generate a new one
|
||||||
|
String deviceId =
|
||||||
|
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
|
||||||
|
|
||||||
|
User? user = Store.tryGet(StoreKey.currentUser);
|
||||||
|
|
||||||
|
UserAdminResponseDto? userResponse;
|
||||||
|
UserPreferencesResponseDto? userPreferences;
|
||||||
|
try {
|
||||||
|
final responses = await Future.wait([
|
||||||
|
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
|
||||||
|
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
|
||||||
|
]);
|
||||||
|
userResponse = responses[0] as UserAdminResponseDto;
|
||||||
|
userPreferences = responses[1] as UserPreferencesResponseDto;
|
||||||
|
} on ApiException catch (error, stackTrace) {
|
||||||
|
if (error.code == 401) {
|
||||||
|
_log.severe("Unauthorized access, token likely expired. Logging out.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
_log.severe(
|
||||||
|
"Error getting user information from the server [API EXCEPTION]",
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_log.severe(
|
||||||
|
"Error getting user information from the server [CATCH ALL]",
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
"Error getting user information from the server [CATCH ALL] $error $stackTrace",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user information is successfully retrieved, update the store
|
||||||
|
// Due to the flow of the code, this will always happen on first login
|
||||||
|
if (userResponse != null) {
|
||||||
|
Store.put(StoreKey.deviceId, deviceId);
|
||||||
|
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
||||||
|
Store.put(
|
||||||
|
StoreKey.currentUser,
|
||||||
|
User.fromUserDto(userResponse, userPreferences),
|
||||||
|
);
|
||||||
|
Store.put(StoreKey.accessToken, accessToken);
|
||||||
|
|
||||||
|
user = User.fromUserDto(userResponse, userPreferences);
|
||||||
|
} else {
|
||||||
|
_log.severe("Unable to get user information from the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user is null, the login was not successful
|
||||||
|
// and we don't have a local copy of the user from a prior successful login
|
||||||
|
if (user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
isAuthenticated: true,
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: user.email,
|
||||||
|
name: user.name,
|
||||||
|
profileImagePath: user.profileImagePath,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
deviceId: deviceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,245 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_udid/flutter_udid.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
|
||||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
|
||||||
import 'package:immich_mobile/entities/user.entity.dart';
|
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/db.provider.dart';
|
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
|
||||||
import 'package:immich_mobile/utils/db.dart';
|
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:openapi/api.dart';
|
|
||||||
|
|
||||||
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|
||||||
AuthenticationNotifier(
|
|
||||||
this._apiService,
|
|
||||||
this._db,
|
|
||||||
this._ref,
|
|
||||||
) : super(
|
|
||||||
AuthenticationState(
|
|
||||||
deviceId: "",
|
|
||||||
userId: "",
|
|
||||||
userEmail: "",
|
|
||||||
name: '',
|
|
||||||
profileImagePath: '',
|
|
||||||
isAdmin: false,
|
|
||||||
shouldChangePassword: false,
|
|
||||||
isAuthenticated: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final ApiService _apiService;
|
|
||||||
final Isar _db;
|
|
||||||
final StateNotifierProviderRef<AuthenticationNotifier, AuthenticationState>
|
|
||||||
_ref;
|
|
||||||
final _log = Logger("AuthenticationNotifier");
|
|
||||||
|
|
||||||
static const Duration _timeoutDuration = Duration(seconds: 7);
|
|
||||||
|
|
||||||
Future<bool> login(
|
|
||||||
String email,
|
|
||||||
String password,
|
|
||||||
String serverUrl,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
// Resolve API server endpoint from user provided serverUrl
|
|
||||||
await _apiService.resolveAndSetEndpoint(serverUrl);
|
|
||||||
await _apiService.serverInfoApi.pingServer();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Invalid Server Endpoint Url $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sign-in request
|
|
||||||
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
|
||||||
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
var iosInfo = await deviceInfoPlugin.iosInfo;
|
|
||||||
_apiService.authenticationApi.apiClient
|
|
||||||
.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
|
|
||||||
_apiService.authenticationApi.apiClient
|
|
||||||
.addDefaultHeader('deviceType', 'iOS');
|
|
||||||
} else {
|
|
||||||
var androidInfo = await deviceInfoPlugin.androidInfo;
|
|
||||||
_apiService.authenticationApi.apiClient
|
|
||||||
.addDefaultHeader('deviceModel', androidInfo.model);
|
|
||||||
_apiService.authenticationApi.apiClient
|
|
||||||
.addDefaultHeader('deviceType', 'Android');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var loginResponse = await _apiService.authenticationApi.login(
|
|
||||||
LoginCredentialDto(
|
|
||||||
email: email,
|
|
||||||
password: password,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loginResponse == null) {
|
|
||||||
debugPrint('Login Response is null');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return setSuccessLoginInfo(
|
|
||||||
accessToken: loginResponse.accessToken,
|
|
||||||
serverUrl: serverUrl,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error logging in $e");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> logout() async {
|
|
||||||
var log = Logger('AuthenticationNotifier');
|
|
||||||
try {
|
|
||||||
String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
|
|
||||||
|
|
||||||
await _apiService.authenticationApi
|
|
||||||
.logout()
|
|
||||||
.timeout(_timeoutDuration)
|
|
||||||
.then((_) => log.info("Logout was successful for $userEmail"))
|
|
||||||
.onError(
|
|
||||||
(error, stackTrace) =>
|
|
||||||
log.severe("Logout failed for $userEmail", error, stackTrace),
|
|
||||||
);
|
|
||||||
} catch (e, stack) {
|
|
||||||
log.severe('Logout failed', e, stack);
|
|
||||||
} finally {
|
|
||||||
await Future.wait([
|
|
||||||
clearAssetsAndAlbums(_db),
|
|
||||||
Store.delete(StoreKey.currentUser),
|
|
||||||
Store.delete(StoreKey.accessToken),
|
|
||||||
]);
|
|
||||||
_ref.invalidate(albumProvider);
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
deviceId: "",
|
|
||||||
userId: "",
|
|
||||||
userEmail: "",
|
|
||||||
name: '',
|
|
||||||
profileImagePath: '',
|
|
||||||
isAdmin: false,
|
|
||||||
shouldChangePassword: false,
|
|
||||||
isAuthenticated: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUserProfileImagePath(String path) {
|
|
||||||
state = state.copyWith(profileImagePath: path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> changePassword(String newPassword) async {
|
|
||||||
try {
|
|
||||||
await _apiService.usersApi.updateMyUser(
|
|
||||||
UserUpdateMeDto(
|
|
||||||
password: newPassword,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
state = state.copyWith(shouldChangePassword: false);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error changing password $e");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> setSuccessLoginInfo({
|
|
||||||
required String accessToken,
|
|
||||||
required String serverUrl,
|
|
||||||
}) async {
|
|
||||||
_apiService.setAccessToken(accessToken);
|
|
||||||
|
|
||||||
// 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 = Store.tryGet(StoreKey.currentUser);
|
|
||||||
|
|
||||||
UserAdminResponseDto? userResponse;
|
|
||||||
UserPreferencesResponseDto? userPreferences;
|
|
||||||
try {
|
|
||||||
final responses = await Future.wait([
|
|
||||||
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
|
|
||||||
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
|
|
||||||
]);
|
|
||||||
userResponse = responses[0] as UserAdminResponseDto;
|
|
||||||
userPreferences = responses[1] as UserPreferencesResponseDto;
|
|
||||||
} on ApiException catch (error, stackTrace) {
|
|
||||||
if (error.code == 401) {
|
|
||||||
_log.severe("Unauthorized access, token likely expired. Logging out.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
_log.severe(
|
|
||||||
"Error getting user information from the server [API EXCEPTION]",
|
|
||||||
stackTrace,
|
|
||||||
);
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
_log.severe(
|
|
||||||
"Error getting user information from the server [CATCH ALL]",
|
|
||||||
error,
|
|
||||||
stackTrace,
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
"Error getting user information from the server [CATCH ALL] $error $stackTrace",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user information is successfully retrieved, update the store
|
|
||||||
// Due to the flow of the code, this will always happen on first login
|
|
||||||
if (userResponse != null) {
|
|
||||||
Store.put(StoreKey.deviceId, deviceId);
|
|
||||||
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
|
|
||||||
Store.put(
|
|
||||||
StoreKey.currentUser,
|
|
||||||
User.fromUserDto(userResponse, userPreferences),
|
|
||||||
);
|
|
||||||
Store.put(StoreKey.serverUrl, serverUrl);
|
|
||||||
Store.put(StoreKey.accessToken, accessToken);
|
|
||||||
|
|
||||||
shouldChangePassword = userResponse.shouldChangePassword;
|
|
||||||
user = User.fromUserDto(userResponse, userPreferences);
|
|
||||||
} else {
|
|
||||||
_log.severe("Unable to get user information from the server.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user is null, the login was not successful
|
|
||||||
// and we don't have a local copy of the user from a prior successful login
|
|
||||||
if (user == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
isAuthenticated: true,
|
|
||||||
userId: user.id,
|
|
||||||
userEmail: user.email,
|
|
||||||
name: user.name,
|
|
||||||
profileImagePath: user.profileImagePath,
|
|
||||||
isAdmin: user.isAdmin,
|
|
||||||
shouldChangePassword: shouldChangePassword,
|
|
||||||
deviceId: deviceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final authenticationProvider =
|
|
||||||
StateNotifierProvider<AuthenticationNotifier, AuthenticationState>((ref) {
|
|
||||||
return AuthenticationNotifier(
|
|
||||||
ref.watch(apiServiceProvider),
|
|
||||||
ref.watch(dbProvider),
|
|
||||||
ref,
|
|
||||||
);
|
|
||||||
});
|
|
|
@ -22,8 +22,8 @@ import 'package:immich_mobile/repositories/backup.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
import 'package:immich_mobile/repositories/file_media.repository.dart';
|
||||||
import 'package:immich_mobile/services/background.service.dart';
|
import 'package:immich_mobile/services/background.service.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
import 'package:immich_mobile/models/auth/auth_state.model.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
@ -92,7 +92,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
final log = Logger('BackupNotifier');
|
final log = Logger('BackupNotifier');
|
||||||
final BackupService _backupService;
|
final BackupService _backupService;
|
||||||
final ServerInfoService _serverInfoService;
|
final ServerInfoService _serverInfoService;
|
||||||
final AuthenticationState _authState;
|
final AuthState _authState;
|
||||||
final BackgroundService _backgroundService;
|
final BackgroundService _backgroundService;
|
||||||
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
final GalleryPermissionNotifier _galleryPermissionNotifier;
|
||||||
final Isar _db;
|
final Isar _db;
|
||||||
|
@ -765,7 +765,7 @@ final backupProvider =
|
||||||
return BackupNotifier(
|
return BackupNotifier(
|
||||||
ref.watch(backupServiceProvider),
|
ref.watch(backupServiceProvider),
|
||||||
ref.watch(serverInfoServiceProvider),
|
ref.watch(serverInfoServiceProvider),
|
||||||
ref.watch(authenticationProvider),
|
ref.watch(authProvider),
|
||||||
ref.watch(backgroundServiceProvider),
|
ref.watch(backgroundServiceProvider),
|
||||||
ref.watch(galleryPermissionNotifier.notifier),
|
ref.watch(galleryPermissionNotifier.notifier),
|
||||||
ref.watch(dbProvider),
|
ref.watch(dbProvider),
|
||||||
|
|
|
@ -35,7 +35,7 @@ class BackupVerification extends _$BackupVerification {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final connection = await Connectivity().checkConnectivity();
|
final connection = await Connectivity().checkConnectivity();
|
||||||
if (connection.contains(ConnectivityResult.wifi)) {
|
if (!connection.contains(ConnectivityResult.wifi)) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
@ -103,7 +103,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||||
/// Connects websocket to server unless already connected
|
/// Connects websocket to server unless already connected
|
||||||
void connect() {
|
void connect() {
|
||||||
if (state.isConnected) return;
|
if (state.isConnected) return;
|
||||||
final authenticationState = _ref.read(authenticationProvider);
|
final authenticationState = _ref.read(authProvider);
|
||||||
|
|
||||||
if (authenticationState.isAuthenticated) {
|
if (authenticationState.isAuthenticated) {
|
||||||
try {
|
try {
|
||||||
|
|
30
mobile/lib/repositories/auth.repository.dart
Normal file
30
mobile/lib/repositories/auth.repository.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/etag.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
|
import 'package:immich_mobile/entities/user.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/auth.interface.dart';
|
||||||
|
import 'package:immich_mobile/providers/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/database.repository.dart';
|
||||||
|
|
||||||
|
final authRepositoryProvider = Provider<IAuthRepository>(
|
||||||
|
(ref) => AuthRepository(ref.watch(dbProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
class AuthRepository extends DatabaseRepository implements IAuthRepository {
|
||||||
|
AuthRepository(super.db);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> clearLocalData() {
|
||||||
|
return db.writeTxn(() {
|
||||||
|
return Future.wait([
|
||||||
|
db.assets.clear(),
|
||||||
|
db.exifInfos.clear(),
|
||||||
|
db.albums.clear(),
|
||||||
|
db.eTags.clear(),
|
||||||
|
db.users.clear(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
56
mobile/lib/repositories/auth_api.repository.dart
Normal file
56
mobile/lib/repositories/auth_api.repository.dart
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
||||||
|
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
final authApiRepositoryProvider =
|
||||||
|
Provider((ref) => AuthApiRepository(ref.watch(apiServiceProvider)));
|
||||||
|
|
||||||
|
class AuthApiRepository extends ApiRepository implements IAuthApiRepository {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
AuthApiRepository(this._apiService);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> changePassword(String newPassword) async {
|
||||||
|
await _apiService.usersApi.updateMyUser(
|
||||||
|
UserUpdateMeDto(
|
||||||
|
password: newPassword,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LoginResponse> login(String email, String password) async {
|
||||||
|
final loginResponseDto = await checkNull(
|
||||||
|
_apiService.authenticationApi.login(
|
||||||
|
LoginCredentialDto(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return _mapLoginReponse(loginResponseDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> logout() async {
|
||||||
|
await _apiService.authenticationApi.logout().timeout(Duration(seconds: 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
_mapLoginReponse(LoginResponseDto dto) {
|
||||||
|
return LoginResponse(
|
||||||
|
accessToken: dto.accessToken,
|
||||||
|
isAdmin: dto.isAdmin,
|
||||||
|
name: dto.name,
|
||||||
|
profileImagePath: dto.profileImagePath,
|
||||||
|
shouldChangePassword: dto.shouldChangePassword,
|
||||||
|
userEmail: dto.userEmail,
|
||||||
|
userId: dto.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:immich_mobile/interfaces/database.interface.dart';
|
import 'package:immich_mobile/interfaces/database.interface.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
|
@ -69,7 +70,7 @@ class ApiService implements Authentication {
|
||||||
final endpoint = await _resolveEndpoint(serverUrl);
|
final endpoint = await _resolveEndpoint(serverUrl);
|
||||||
setEndpoint(endpoint);
|
setEndpoint(endpoint);
|
||||||
|
|
||||||
// Save in hivebox for next startup
|
// Save in local database for next startup
|
||||||
Store.put(StoreKey.serverEndpoint, endpoint);
|
Store.put(StoreKey.serverEndpoint, endpoint);
|
||||||
return endpoint;
|
return endpoint;
|
||||||
}
|
}
|
||||||
|
@ -148,11 +149,27 @@ class ApiService implements Authentication {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
setAccessToken(String accessToken) {
|
void setAccessToken(String accessToken) {
|
||||||
_accessToken = accessToken;
|
_accessToken = accessToken;
|
||||||
Store.put(StoreKey.accessToken, accessToken);
|
Store.put(StoreKey.accessToken, accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setDeviceInfoHeader() async {
|
||||||
|
DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||||
|
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final iosInfo = await deviceInfoPlugin.iosInfo;
|
||||||
|
authenticationApi.apiClient
|
||||||
|
.addDefaultHeader('deviceModel', iosInfo.utsname.machine);
|
||||||
|
authenticationApi.apiClient.addDefaultHeader('deviceType', 'iOS');
|
||||||
|
} else {
|
||||||
|
final androidInfo = await deviceInfoPlugin.androidInfo;
|
||||||
|
authenticationApi.apiClient
|
||||||
|
.addDefaultHeader('deviceModel', androidInfo.model);
|
||||||
|
authenticationApi.apiClient.addDefaultHeader('deviceType', 'Android');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, String> getRequestHeaders() {
|
static Map<String, String> getRequestHeaders() {
|
||||||
var accessToken = Store.get(StoreKey.accessToken, "");
|
var accessToken = Store.get(StoreKey.accessToken, "");
|
||||||
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
var customHeadersStr = Store.get(StoreKey.customHeaders, "");
|
||||||
|
|
98
mobile/lib/services/auth.service.dart
Normal file
98
mobile/lib/services/auth.service.dart
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/auth.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
||||||
|
import 'package:immich_mobile/models/auth/login_response.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final authServiceProvider = Provider(
|
||||||
|
(ref) => AuthService(
|
||||||
|
ref.watch(authApiRepositoryProvider),
|
||||||
|
ref.watch(authRepositoryProvider),
|
||||||
|
ref.watch(apiServiceProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
final IAuthApiRepository _authApiRepository;
|
||||||
|
final IAuthRepository _authRepository;
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
final _log = Logger("AuthService");
|
||||||
|
|
||||||
|
AuthService(
|
||||||
|
this._authApiRepository,
|
||||||
|
this._authRepository,
|
||||||
|
this._apiService,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Validates the provided server URL by resolving and setting the endpoint.
|
||||||
|
/// Also sets the device info header and stores the valid URL.
|
||||||
|
///
|
||||||
|
/// [url] - The server URL to be validated.
|
||||||
|
///
|
||||||
|
/// Returns the validated and resolved server URL as a [String].
|
||||||
|
///
|
||||||
|
/// Throws an exception if the URL cannot be resolved or set.
|
||||||
|
Future<String> validateServerUrl(String url) async {
|
||||||
|
final validUrl = await _apiService.resolveAndSetEndpoint(url);
|
||||||
|
await _apiService.setDeviceInfoHeader();
|
||||||
|
Store.put(StoreKey.serverUrl, validUrl);
|
||||||
|
|
||||||
|
return validUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<LoginResponse> login(String email, String password) {
|
||||||
|
return _authApiRepository.login(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs user logout operation by making a server request and clearing local data.
|
||||||
|
///
|
||||||
|
/// This method attempts to log out the user through the authentication API repository.
|
||||||
|
/// If the server request fails, the error is logged but local data is still cleared.
|
||||||
|
/// The local data cleanup is guaranteed to execute regardless of the server request outcome.
|
||||||
|
///
|
||||||
|
/// Throws any unhandled exceptions from the API request or local data clearing operations.
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _authApiRepository.logout();
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_log.severe("Error logging out", error, stackTrace);
|
||||||
|
} finally {
|
||||||
|
await clearLocalData().catchError((error, stackTrace) {
|
||||||
|
_log.severe("Error clearing local data", error, stackTrace);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all local authentication-related data.
|
||||||
|
///
|
||||||
|
/// This method performs a concurrent deletion of:
|
||||||
|
/// - Authentication repository data
|
||||||
|
/// - Current user information
|
||||||
|
/// - Access token
|
||||||
|
/// - Asset ETag
|
||||||
|
///
|
||||||
|
/// All deletions are executed in parallel using [Future.wait].
|
||||||
|
Future<void> clearLocalData() {
|
||||||
|
return Future.wait([
|
||||||
|
_authRepository.clearLocalData(),
|
||||||
|
Store.delete(StoreKey.currentUser),
|
||||||
|
Store.delete(StoreKey.accessToken),
|
||||||
|
Store.delete(StoreKey.assetETag),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changePassword(String newPassword) {
|
||||||
|
try {
|
||||||
|
return _authApiRepository.changePassword(newPassword);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
_log.severe("Error changing password", error, stackTrace);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
mobile/lib/services/device.service.dart
Normal file
24
mobile/lib/services/device.service.dart
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:flutter_udid/flutter_udid.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
|
||||||
|
final deviceServiceProvider = Provider((ref) => DeviceService());
|
||||||
|
|
||||||
|
class DeviceService {
|
||||||
|
DeviceService();
|
||||||
|
|
||||||
|
createDeviceId() {
|
||||||
|
return FlutterUdid.consistentUdid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the device ID from local storage or creates a new one if not found.
|
||||||
|
///
|
||||||
|
/// This method first attempts to retrieve the device ID from the local store using
|
||||||
|
/// [StoreKey.deviceId]. If no device ID is found (returns null), it generates a
|
||||||
|
/// new device ID by calling [createDeviceId].
|
||||||
|
///
|
||||||
|
/// Returns a [String] representing the device's unique identifier.
|
||||||
|
String getDeviceId() {
|
||||||
|
return Store.tryGet(StoreKey.deviceId) ?? createDeviceId();
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,8 +35,9 @@ class UserService {
|
||||||
this._syncService,
|
this._syncService,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<List<User>> getUsers({bool self = false}) =>
|
Future<List<User>> getUsers({bool self = false}) {
|
||||||
_userRepository.getAll(self: self);
|
return _userRepository.getAll(self: self);
|
||||||
|
}
|
||||||
|
|
||||||
Future<({String profileImagePath})?> uploadProfileImage(XFile image) async {
|
Future<({String profileImagePath})?> uploadProfileImage(XFile image) async {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
@ -128,7 +128,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
||||||
onOk: () async {
|
onOk: () async {
|
||||||
isLoggingOut.value = true;
|
isLoggingOut.value = true;
|
||||||
await ref
|
await ref
|
||||||
.read(authenticationProvider.notifier)
|
.read(authProvider.notifier)
|
||||||
.logout()
|
.logout()
|
||||||
.whenComplete(() => isLoggingOut.value = false);
|
.whenComplete(() => isLoggingOut.value = false);
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,7 @@ import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
|
|
||||||
class AppBarProfileInfoBox extends HookConsumerWidget {
|
class AppBarProfileInfoBox extends HookConsumerWidget {
|
||||||
|
@ -18,7 +17,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final uploadProfileImageStatus =
|
final uploadProfileImageStatus =
|
||||||
ref.watch(uploadProfileImageProvider).status;
|
ref.watch(uploadProfileImageProvider).status;
|
||||||
final user = Store.tryGet(StoreKey.currentUser);
|
final user = Store.tryGet(StoreKey.currentUser);
|
||||||
|
@ -63,7 +62,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
|
||||||
if (success) {
|
if (success) {
|
||||||
final profileImagePath =
|
final profileImagePath =
|
||||||
ref.read(uploadProfileImageProvider).profileImagePath;
|
ref.read(uploadProfileImageProvider).profileImagePath;
|
||||||
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
|
ref.watch(authProvider.notifier).updateUserProfileImagePath(
|
||||||
profileImagePath,
|
profileImagePath,
|
||||||
);
|
);
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
@ -21,7 +21,7 @@ class ChangePasswordForm extends HookConsumerWidget {
|
||||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final confirmPasswordController =
|
final confirmPasswordController =
|
||||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final authState = ref.watch(authenticationProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
|
@ -73,13 +73,11 @@ class ChangePasswordForm extends HookConsumerWidget {
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (formKey.currentState!.validate()) {
|
if (formKey.currentState!.validate()) {
|
||||||
var isSuccess = await ref
|
var isSuccess = await ref
|
||||||
.read(authenticationProvider.notifier)
|
.read(authProvider.notifier)
|
||||||
.changePassword(passwordController.value.text);
|
.changePassword(passwordController.value.text);
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
await ref
|
await ref.read(authProvider.notifier).logout();
|
||||||
.read(authenticationProvider.notifier)
|
|
||||||
.logout();
|
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(manualUploadProvider.notifier)
|
.read(manualUploadProvider.notifier)
|
||||||
|
|
|
@ -11,9 +11,7 @@ import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
|
@ -40,13 +38,12 @@ class LoginForm extends HookConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final usernameController =
|
final emailController =
|
||||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final passwordController =
|
final passwordController =
|
||||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final serverEndpointController =
|
final serverEndpointController =
|
||||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final apiService = ref.watch(apiServiceProvider);
|
|
||||||
final emailFocusNode = useFocusNode();
|
final emailFocusNode = useFocusNode();
|
||||||
final passwordFocusNode = useFocusNode();
|
final passwordFocusNode = useFocusNode();
|
||||||
final serverEndpointFocusNode = useFocusNode();
|
final serverEndpointFocusNode = useFocusNode();
|
||||||
|
@ -85,7 +82,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
|
|
||||||
/// Fetch the server login credential and enables oAuth login if necessary
|
/// Fetch the server login credential and enables oAuth login if necessary
|
||||||
/// Returns true if successful, false otherwise
|
/// Returns true if successful, false otherwise
|
||||||
Future<bool> getServerLoginCredential() async {
|
Future<void> getServerAuthSettings() async {
|
||||||
final serverUrl = sanitizeUrl(serverEndpointController.text);
|
final serverUrl = sanitizeUrl(serverEndpointController.text);
|
||||||
|
|
||||||
// Guard empty URL
|
// Guard empty URL
|
||||||
|
@ -95,13 +92,12 @@ class LoginForm extends HookConsumerWidget {
|
||||||
msg: "login_form_server_empty".tr(),
|
msg: "login_form_server_empty".tr(),
|
||||||
toastType: ToastType.error,
|
toastType: ToastType.error,
|
||||||
);
|
);
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isLoadingServer.value = true;
|
isLoadingServer.value = true;
|
||||||
final endpoint = await apiService.resolveAndSetEndpoint(serverUrl);
|
final endpoint =
|
||||||
|
await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
|
||||||
|
|
||||||
// Fetch and load server config and features
|
// Fetch and load server config and features
|
||||||
await ref.read(serverInfoProvider.notifier).getServerInfo();
|
await ref.read(serverInfoProvider.notifier).getServerInfo();
|
||||||
|
@ -127,7 +123,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
isOauthEnable.value = false;
|
isOauthEnable.value = false;
|
||||||
isPasswordLoginEnable.value = true;
|
isPasswordLoginEnable.value = true;
|
||||||
isLoadingServer.value = false;
|
isLoadingServer.value = false;
|
||||||
return false;
|
|
||||||
} on HandshakeException {
|
} on HandshakeException {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
|
@ -138,7 +133,6 @@ class LoginForm extends HookConsumerWidget {
|
||||||
isOauthEnable.value = false;
|
isOauthEnable.value = false;
|
||||||
isPasswordLoginEnable.value = true;
|
isPasswordLoginEnable.value = true;
|
||||||
isLoadingServer.value = false;
|
isLoadingServer.value = false;
|
||||||
return false;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ImmichToast.show(
|
ImmichToast.show(
|
||||||
context: context,
|
context: context,
|
||||||
|
@ -149,11 +143,9 @@ class LoginForm extends HookConsumerWidget {
|
||||||
isOauthEnable.value = false;
|
isOauthEnable.value = false;
|
||||||
isPasswordLoginEnable.value = true;
|
isPasswordLoginEnable.value = true;
|
||||||
isLoadingServer.value = false;
|
isLoadingServer.value = false;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingServer.value = false;
|
isLoadingServer.value = false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
@ -168,67 +160,50 @@ class LoginForm extends HookConsumerWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
populateTestLoginInfo() {
|
populateTestLoginInfo() {
|
||||||
usernameController.text = 'demo@immich.app';
|
emailController.text = 'demo@immich.app';
|
||||||
passwordController.text = 'demo';
|
passwordController.text = 'demo';
|
||||||
serverEndpointController.text = 'https://demo.immich.app';
|
serverEndpointController.text = 'https://demo.immich.app';
|
||||||
}
|
}
|
||||||
|
|
||||||
populateTestLoginInfo1() {
|
populateTestLoginInfo1() {
|
||||||
usernameController.text = 'testuser@email.com';
|
emailController.text = 'testuser@email.com';
|
||||||
passwordController.text = 'password';
|
passwordController.text = 'password';
|
||||||
serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
serverEndpointController.text = 'http://10.1.15.216:3000/api';
|
||||||
}
|
}
|
||||||
|
|
||||||
login() async {
|
login() async {
|
||||||
TextInput.finishAutofillContext();
|
TextInput.finishAutofillContext();
|
||||||
// Start loading
|
|
||||||
isLoading.value = true;
|
|
||||||
|
|
||||||
// This will remove current cache asset state of previous user login.
|
isLoading.value = true;
|
||||||
ref.read(assetProvider.notifier).clearAllAsset();
|
|
||||||
|
|
||||||
// Invalidate all api repository provider instance to take into account new access token
|
// Invalidate all api repository provider instance to take into account new access token
|
||||||
invalidateAllApiRepositoryProviders(ref);
|
invalidateAllApiRepositoryProviders(ref);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final isAuthenticated =
|
final result = await ref.read(authProvider.notifier).login(
|
||||||
await ref.read(authenticationProvider.notifier).login(
|
emailController.text,
|
||||||
usernameController.text,
|
passwordController.text,
|
||||||
passwordController.text,
|
);
|
||||||
sanitizeUrl(serverEndpointController.text),
|
|
||||||
);
|
if (result.shouldChangePassword && !result.isAdmin) {
|
||||||
if (isAuthenticated) {
|
context.pushRoute(const ChangePasswordRoute());
|
||||||
// Resume backup (if enable) then navigate
|
|
||||||
if (ref.read(authenticationProvider).shouldChangePassword &&
|
|
||||||
!ref.read(authenticationProvider).isAdmin) {
|
|
||||||
context.pushRoute(const ChangePasswordRoute());
|
|
||||||
} else {
|
|
||||||
final hasPermission = await ref
|
|
||||||
.read(galleryPermissionNotifier.notifier)
|
|
||||||
.hasPermission;
|
|
||||||
if (hasPermission) {
|
|
||||||
// Don't resume the backup until we have gallery permission
|
|
||||||
ref.read(backupProvider.notifier).resumeBackup();
|
|
||||||
}
|
|
||||||
context.replaceRoute(const TabControllerRoute());
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ImmichToast.show(
|
context.replaceRoute(const TabControllerRoute());
|
||||||
context: context,
|
|
||||||
msg: "login_form_failed_login".tr(),
|
|
||||||
toastType: ToastType.error,
|
|
||||||
gravity: ToastGravity.TOP,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ImmichToast.show(
|
||||||
|
context: context,
|
||||||
|
msg: "login_form_failed_login".tr(),
|
||||||
|
toastType: ToastType.error,
|
||||||
|
gravity: ToastGravity.TOP,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// Make sure we stop loading
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oAuthLogin() async {
|
oAuthLogin() async {
|
||||||
var oAuthService = ref.watch(oAuthServiceProvider);
|
var oAuthService = ref.watch(oAuthServiceProvider);
|
||||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
|
||||||
String? oAuthServerUrl;
|
String? oAuthServerUrl;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -262,11 +237,8 @@ class LoginForm extends HookConsumerWidget {
|
||||||
"Finished OAuth login with response: ${loginResponseDto.userEmail}",
|
"Finished OAuth login with response: ${loginResponseDto.userEmail}",
|
||||||
);
|
);
|
||||||
|
|
||||||
final isSuccess = await ref
|
final isSuccess = await ref.watch(authProvider.notifier).saveAuthInfo(
|
||||||
.watch(authenticationProvider.notifier)
|
|
||||||
.setSuccessLoginInfo(
|
|
||||||
accessToken: loginResponseDto.accessToken,
|
accessToken: loginResponseDto.accessToken,
|
||||||
serverUrl: sanitizeUrl(serverEndpointController.text),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
|
@ -309,7 +281,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
ServerEndpointInput(
|
ServerEndpointInput(
|
||||||
controller: serverEndpointController,
|
controller: serverEndpointController,
|
||||||
focusNode: serverEndpointFocusNode,
|
focusNode: serverEndpointFocusNode,
|
||||||
onSubmit: getServerLoginCredential,
|
onSubmit: getServerAuthSettings,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Row(
|
Row(
|
||||||
|
@ -344,7 +316,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed:
|
||||||
isLoadingServer.value ? null : getServerLoginCredential,
|
isLoadingServer.value ? null : getServerAuthSettings,
|
||||||
icon: const Icon(Icons.arrow_forward_rounded),
|
icon: const Icon(Icons.arrow_forward_rounded),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'login_form_next_button',
|
'login_form_next_button',
|
||||||
|
@ -402,7 +374,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
if (isPasswordLoginEnable.value) ...[
|
if (isPasswordLoginEnable.value) ...[
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
EmailInput(
|
EmailInput(
|
||||||
controller: usernameController,
|
controller: emailController,
|
||||||
focusNode: emailFocusNode,
|
focusNode: emailFocusNode,
|
||||||
onSubmit: passwordFocusNode.requestFocus,
|
onSubmit: passwordFocusNode.requestFocus,
|
||||||
),
|
),
|
||||||
|
|
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_server_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_server_dto.dart
generated
Binary file not shown.
|
@ -1862,4 +1862,4 @@ packages:
|
||||||
version: "3.1.2"
|
version: "3.1.2"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.5.3 <4.0.0"
|
dart: ">=3.5.3 <4.0.0"
|
||||||
flutter: ">=3.24.4"
|
flutter: ">=3.24.5"
|
||||||
|
|
|
@ -6,7 +6,7 @@ version: 1.121.0+168
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
flutter: 3.24.4
|
flutter: 3.24.5
|
||||||
|
|
||||||
isar_version: &isar_version 3.1.8 # define the version to be used
|
isar_version: &isar_version 3.1.8 # define the version to be used
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
|
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/auth.interface.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
import 'package:immich_mobile/interfaces/backup.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
|
import 'package:immich_mobile/interfaces/exif_info.interface.dart';
|
||||||
|
@ -29,3 +31,7 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
|
||||||
class MockFileMediaRepository extends Mock implements IFileMediaRepository {}
|
class MockFileMediaRepository extends Mock implements IFileMediaRepository {}
|
||||||
|
|
||||||
class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {}
|
class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {}
|
||||||
|
|
||||||
|
class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
|
||||||
|
|
||||||
|
class MockAuthRepository extends Mock implements IAuthRepository {}
|
||||||
|
|
118
mobile/test/services/auth.service_test.dart
Normal file
118
mobile/test/services/auth.service_test.dart
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/services/auth.service.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import '../repository.mocks.dart';
|
||||||
|
import '../service.mocks.dart';
|
||||||
|
import '../test_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AuthService sut;
|
||||||
|
late MockAuthApiRepository authApiRepository;
|
||||||
|
late MockAuthRepository authRepository;
|
||||||
|
late MockApiService apiService;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
authApiRepository = MockAuthApiRepository();
|
||||||
|
authRepository = MockAuthRepository();
|
||||||
|
apiService = MockApiService();
|
||||||
|
sut = AuthService(authApiRepository, authRepository, apiService);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('validateServerUrl', () {
|
||||||
|
setUpAll(() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final db = await TestUtils.initIsar();
|
||||||
|
db.writeTxnSync(() => db.clearSync());
|
||||||
|
Store.init(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should resolve HTTP endpoint', () async {
|
||||||
|
const testUrl = 'http://ip:2283';
|
||||||
|
const resolvedUrl = 'http://ip:2283/api';
|
||||||
|
|
||||||
|
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||||
|
.thenAnswer((_) async => resolvedUrl);
|
||||||
|
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
final result = await sut.validateServerUrl(testUrl);
|
||||||
|
|
||||||
|
expect(result, resolvedUrl);
|
||||||
|
|
||||||
|
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||||
|
verify(() => apiService.setDeviceInfoHeader()).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should resolve HTTPS endpoint', () async {
|
||||||
|
const testUrl = 'https://immich.domain.com';
|
||||||
|
const resolvedUrl = 'https://immich.domain.com/api';
|
||||||
|
|
||||||
|
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||||
|
.thenAnswer((_) async => resolvedUrl);
|
||||||
|
when(() => apiService.setDeviceInfoHeader()).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
final result = await sut.validateServerUrl(testUrl);
|
||||||
|
|
||||||
|
expect(result, resolvedUrl);
|
||||||
|
|
||||||
|
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||||
|
verify(() => apiService.setDeviceInfoHeader()).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should throw error on invalid URL', () async {
|
||||||
|
const testUrl = 'invalid-url';
|
||||||
|
|
||||||
|
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||||
|
.thenThrow(Exception('Invalid URL'));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() async => await sut.validateServerUrl(testUrl),
|
||||||
|
throwsA(isA<Exception>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||||
|
verifyNever(() => apiService.setDeviceInfoHeader());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should throw error on unreachable server', () async {
|
||||||
|
const testUrl = 'https://unreachable.server';
|
||||||
|
|
||||||
|
when(() => apiService.resolveAndSetEndpoint(testUrl))
|
||||||
|
.thenThrow(Exception('Server is not reachable'));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() async => await sut.validateServerUrl(testUrl),
|
||||||
|
throwsA(isA<Exception>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(() => apiService.resolveAndSetEndpoint(testUrl)).called(1);
|
||||||
|
verifyNever(() => apiService.setDeviceInfoHeader());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('logout', () {
|
||||||
|
test('Should logout user', () async {
|
||||||
|
when(() => authApiRepository.logout()).thenAnswer((_) async => {});
|
||||||
|
when(() => authRepository.clearLocalData())
|
||||||
|
.thenAnswer((_) => Future.value(null));
|
||||||
|
|
||||||
|
await sut.logout();
|
||||||
|
|
||||||
|
verify(() => authApiRepository.logout()).called(1);
|
||||||
|
verify(() => authRepository.clearLocalData()).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should clear local data even on server error', () async {
|
||||||
|
when(() => authApiRepository.logout())
|
||||||
|
.thenThrow(Exception('Server error'));
|
||||||
|
when(() => authRepository.clearLocalData())
|
||||||
|
.thenAnswer((_) => Future.value(null));
|
||||||
|
|
||||||
|
await sut.logout();
|
||||||
|
|
||||||
|
verify(() => authApiRepository.logout()).called(1);
|
||||||
|
verify(() => authRepository.clearLocalData()).called(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -10825,6 +10825,9 @@
|
||||||
"oauthButtonText": {
|
"oauthButtonText": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"publicUsers": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"trashDays": {
|
"trashDays": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
@ -10840,6 +10843,7 @@
|
||||||
"mapDarkStyleUrl",
|
"mapDarkStyleUrl",
|
||||||
"mapLightStyleUrl",
|
"mapLightStyleUrl",
|
||||||
"oauthButtonText",
|
"oauthButtonText",
|
||||||
|
"publicUsers",
|
||||||
"trashDays",
|
"trashDays",
|
||||||
"userDeleteDelay"
|
"userDeleteDelay"
|
||||||
],
|
],
|
||||||
|
@ -12018,11 +12022,15 @@
|
||||||
},
|
},
|
||||||
"loginPageMessage": {
|
"loginPageMessage": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"publicUsers": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"externalDomain",
|
"externalDomain",
|
||||||
"loginPageMessage"
|
"loginPageMessage",
|
||||||
|
"publicUsers"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
|
|
@ -928,6 +928,7 @@ export type ServerConfigDto = {
|
||||||
mapDarkStyleUrl: string;
|
mapDarkStyleUrl: string;
|
||||||
mapLightStyleUrl: string;
|
mapLightStyleUrl: string;
|
||||||
oauthButtonText: string;
|
oauthButtonText: string;
|
||||||
|
publicUsers: boolean;
|
||||||
trashDays: number;
|
trashDays: number;
|
||||||
userDeleteDelay: number;
|
userDeleteDelay: number;
|
||||||
};
|
};
|
||||||
|
@ -1236,6 +1237,7 @@ export type SystemConfigReverseGeocodingDto = {
|
||||||
export type SystemConfigServerDto = {
|
export type SystemConfigServerDto = {
|
||||||
externalDomain: string;
|
externalDomain: string;
|
||||||
loginPageMessage: string;
|
loginPageMessage: string;
|
||||||
|
publicUsers: boolean;
|
||||||
};
|
};
|
||||||
export type SystemConfigStorageTemplateDto = {
|
export type SystemConfigStorageTemplateDto = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
|
@ -151,6 +151,7 @@ export interface SystemConfig {
|
||||||
server: {
|
server: {
|
||||||
externalDomain: string;
|
externalDomain: string;
|
||||||
loginPageMessage: string;
|
loginPageMessage: string;
|
||||||
|
publicUsers: boolean;
|
||||||
};
|
};
|
||||||
user: {
|
user: {
|
||||||
deleteDelay: number;
|
deleteDelay: number;
|
||||||
|
@ -310,6 +311,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||||
server: {
|
server: {
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
loginPageMessage: '',
|
loginPageMessage: '',
|
||||||
|
publicUsers: true,
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
smtp: {
|
smtp: {
|
||||||
|
|
|
@ -39,8 +39,8 @@ export class UserController {
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
searchUsers(): Promise<UserResponseDto[]> {
|
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
|
||||||
return this.service.search();
|
return this.service.search(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
|
|
|
@ -144,6 +144,7 @@ export class ServerConfigDto {
|
||||||
isInitialized!: boolean;
|
isInitialized!: boolean;
|
||||||
isOnboarded!: boolean;
|
isOnboarded!: boolean;
|
||||||
externalDomain!: string;
|
externalDomain!: string;
|
||||||
|
publicUsers!: boolean;
|
||||||
mapDarkStyleUrl!: string;
|
mapDarkStyleUrl!: string;
|
||||||
mapLightStyleUrl!: string;
|
mapLightStyleUrl!: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -404,6 +404,9 @@ class SystemConfigServerDto {
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
loginPageMessage!: string;
|
loginPageMessage!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
publicUsers!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemConfigSmtpTransportDto {
|
class SystemConfigSmtpTransportDto {
|
||||||
|
|
|
@ -169,6 +169,7 @@ describe(ServerService.name, () => {
|
||||||
isInitialized: undefined,
|
isInitialized: undefined,
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
|
publicUsers: true,
|
||||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||||
});
|
});
|
||||||
|
|
|
@ -110,6 +110,7 @@ export class ServerService extends BaseService {
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isOnboarded: onboarding?.isOnboarded || false,
|
isOnboarded: onboarding?.isOnboarded || false,
|
||||||
externalDomain: config.server.externalDomain,
|
externalDomain: config.server.externalDomain,
|
||||||
|
publicUsers: config.server.publicUsers,
|
||||||
mapDarkStyleUrl: config.map.darkStyle,
|
mapDarkStyleUrl: config.map.darkStyle,
|
||||||
mapLightStyleUrl: config.map.lightStyle,
|
mapLightStyleUrl: config.map.lightStyle,
|
||||||
};
|
};
|
||||||
|
|
|
@ -133,6 +133,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
server: {
|
server: {
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
loginPageMessage: '',
|
loginPageMessage: '',
|
||||||
|
publicUsers: true,
|
||||||
},
|
},
|
||||||
storageTemplate: {
|
storageTemplate: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|
|
@ -38,9 +38,9 @@ describe(UserService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('should get all users', async () => {
|
it('admin should get all users', async () => {
|
||||||
userMock.getList.mockResolvedValue([userStub.admin]);
|
userMock.getList.mockResolvedValue([userStub.admin]);
|
||||||
await expect(sut.search()).resolves.toEqual([
|
await expect(sut.search(authStub.admin)).resolves.toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: authStub.admin.user.id,
|
id: authStub.admin.user.id,
|
||||||
email: authStub.admin.user.email,
|
email: authStub.admin.user.email,
|
||||||
|
@ -48,6 +48,29 @@ describe(UserService.name, () => {
|
||||||
]);
|
]);
|
||||||
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
|
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('non-admin should get all users when publicUsers enabled', async () => {
|
||||||
|
userMock.getList.mockResolvedValue([userStub.user1]);
|
||||||
|
await expect(sut.search(authStub.user1)).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: authStub.user1.user.id,
|
||||||
|
email: authStub.user1.user.email,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-admin user should only receive itself when publicUsers is disabled', async () => {
|
||||||
|
userMock.getList.mockResolvedValue([userStub.user1]);
|
||||||
|
systemMock.get.mockResolvedValue(systemConfigStub.publicUsersDisabled);
|
||||||
|
await expect(sut.search(authStub.user1)).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: authStub.user1.user.id,
|
||||||
|
email: authStub.user1.user.email,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(userMock.getList).not.toHaveBeenCalledWith({ withDeleted: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get', () => {
|
describe('get', () => {
|
||||||
|
|
|
@ -19,8 +19,14 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService extends BaseService {
|
export class UserService extends BaseService {
|
||||||
async search(): Promise<UserResponseDto[]> {
|
async search(auth: AuthDto): Promise<UserResponseDto[]> {
|
||||||
const users = await this.userRepository.getList({ withDeleted: false });
|
const config = await this.getConfig({ withCache: false });
|
||||||
|
|
||||||
|
let users: UserEntity[] = [auth.user];
|
||||||
|
if (auth.user.isAdmin || config.server.publicUsers) {
|
||||||
|
users = await this.userRepository.getList({ withDeleted: false });
|
||||||
|
}
|
||||||
|
|
||||||
return users.map((user) => mapUser(user));
|
return users.map((user) => mapUser(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
5
server/test/fixtures/system-config.stub.ts
vendored
5
server/test/fixtures/system-config.stub.ts
vendored
|
@ -117,4 +117,9 @@ export const systemConfigStub = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
publicUsersDisabled: {
|
||||||
|
server: {
|
||||||
|
publicUsers: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
} satisfies Record<string, DeepPartial<SystemConfig>>;
|
} satisfies Record<string, DeepPartial<SystemConfig>>;
|
||||||
|
|
2
web/src/app.d.ts
vendored
2
web/src/app.d.ts
vendored
|
@ -28,7 +28,7 @@ interface Element {
|
||||||
requestFullscreen?(options?: FullscreenOptions): Promise<void>;
|
requestFullscreen?(options?: FullscreenOptions): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
import type en from '$lib/en.json';
|
import type en from '$i18n/en.json';
|
||||||
import 'svelte-i18n';
|
import 'svelte-i18n';
|
||||||
|
|
||||||
type NestedKeys<T, K = keyof T> = K extends keyof T & string
|
type NestedKeys<T, K = keyof T> = K extends keyof T & string
|
||||||
|
|
9
web/src/lib/__mocks__/visual-viewport.mock.ts
Normal file
9
web/src/lib/__mocks__/visual-viewport.mock.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export const getVisualViewportMock = () => ({
|
||||||
|
height: window.innerHeight,
|
||||||
|
width: window.innerWidth,
|
||||||
|
scale: 1,
|
||||||
|
offsetLeft: 0,
|
||||||
|
offsetTop: 0,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
87
web/src/lib/actions/scroll-memory.ts
Normal file
87
web/src/lib/actions/scroll-memory.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { navigating } from '$app/stores';
|
||||||
|
import { AppRoute, SessionStorageKey } from '$lib/constants';
|
||||||
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/**
|
||||||
|
* {@link AppRoute} for subpages that scroll state should be kept while visiting.
|
||||||
|
*
|
||||||
|
* This must be kept the same in all subpages of this route for the scroll memory clearer to work.
|
||||||
|
*/
|
||||||
|
routeStartsWith: AppRoute;
|
||||||
|
/**
|
||||||
|
* Function to clear additional data/state before scrolling (ex infinite scroll).
|
||||||
|
*/
|
||||||
|
beforeClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageOptions extends Options {
|
||||||
|
/**
|
||||||
|
* Function to save additional data/state before scrolling (ex infinite scroll).
|
||||||
|
*/
|
||||||
|
beforeSave?: () => void;
|
||||||
|
/**
|
||||||
|
* Function to load additional data/state before scrolling (ex infinite scroll).
|
||||||
|
*/
|
||||||
|
beforeScroll?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param node The scroll slot element, typically from {@link UserPageLayout}
|
||||||
|
*/
|
||||||
|
export function scrollMemory(
|
||||||
|
node: HTMLElement,
|
||||||
|
{ routeStartsWith, beforeSave, beforeClear, beforeScroll }: PageOptions,
|
||||||
|
) {
|
||||||
|
const unsubscribeNavigating = navigating.subscribe((navigation) => {
|
||||||
|
const existingScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION);
|
||||||
|
if (navigation?.to && !existingScroll) {
|
||||||
|
// Save current scroll information when going into a subpage.
|
||||||
|
if (navigation.to.url.pathname.startsWith(routeStartsWith)) {
|
||||||
|
beforeSave?.();
|
||||||
|
sessionStorage.setItem(SessionStorageKey.SCROLL_POSITION, node.scrollTop.toString());
|
||||||
|
} else {
|
||||||
|
beforeClear?.();
|
||||||
|
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePromiseError(
|
||||||
|
(async () => {
|
||||||
|
await beforeScroll?.();
|
||||||
|
|
||||||
|
const newScroll = sessionStorage.getItem(SessionStorageKey.SCROLL_POSITION);
|
||||||
|
if (newScroll) {
|
||||||
|
node.scroll({
|
||||||
|
top: Number.parseFloat(newScroll),
|
||||||
|
behavior: 'instant',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
beforeClear?.();
|
||||||
|
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
unsubscribeNavigating();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollMemoryClearer(_node: HTMLElement, { routeStartsWith, beforeClear }: Options) {
|
||||||
|
const unsubscribeNavigating = navigating.subscribe((navigation) => {
|
||||||
|
// Forget scroll position from main page if going somewhere else.
|
||||||
|
if (navigation?.to && !navigation?.to.url.pathname.startsWith(routeStartsWith)) {
|
||||||
|
beforeClear?.();
|
||||||
|
sessionStorage.removeItem(SessionStorageKey.SCROLL_POSITION);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
unsubscribeNavigating();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
67
web/src/lib/actions/use-actions.ts
Normal file
67
web/src/lib/actions/use-actions.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* @license Apache-2.0
|
||||||
|
* https://github.com/hperrin/svelte-material-ui/blob/master/packages/common/src/internal/useActions.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SvelteActionReturnType<P> = {
|
||||||
|
update?: (newParams?: P) => void;
|
||||||
|
destroy?: () => void;
|
||||||
|
} | void;
|
||||||
|
|
||||||
|
export type SvelteHTMLActionType<P> = (node: HTMLElement, params?: P) => SvelteActionReturnType<P>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type HTMLActionEntry<P = any> = SvelteHTMLActionType<P> | [SvelteHTMLActionType<P>, P];
|
||||||
|
|
||||||
|
export type HTMLActionArray = HTMLActionEntry[];
|
||||||
|
|
||||||
|
export type SvelteSVGActionType<P> = (node: SVGElement, params?: P) => SvelteActionReturnType<P>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type SVGActionEntry<P = any> = SvelteSVGActionType<P> | [SvelteSVGActionType<P>, P];
|
||||||
|
|
||||||
|
export type SVGActionArray = SVGActionEntry[];
|
||||||
|
|
||||||
|
export type ActionArray = HTMLActionArray | SVGActionArray;
|
||||||
|
|
||||||
|
export function useActions(node: HTMLElement | SVGElement, actions: ActionArray) {
|
||||||
|
const actionReturns: SvelteActionReturnType<unknown>[] = [];
|
||||||
|
|
||||||
|
if (actions) {
|
||||||
|
for (const actionEntry of actions) {
|
||||||
|
const action = Array.isArray(actionEntry) ? actionEntry[0] : actionEntry;
|
||||||
|
if (Array.isArray(actionEntry) && actionEntry.length > 1) {
|
||||||
|
actionReturns.push(action(node as HTMLElement & SVGElement, actionEntry[1]));
|
||||||
|
} else {
|
||||||
|
actionReturns.push(action(node as HTMLElement & SVGElement));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(actions: ActionArray) {
|
||||||
|
if ((actions?.length || 0) != actionReturns.length) {
|
||||||
|
throw new Error('You must not change the length of an actions array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions) {
|
||||||
|
for (const [i, returnEntry] of actionReturns.entries()) {
|
||||||
|
if (returnEntry && returnEntry.update) {
|
||||||
|
const actionEntry = actions[i];
|
||||||
|
if (Array.isArray(actionEntry) && actionEntry.length > 1) {
|
||||||
|
returnEntry.update(actionEntry[1]);
|
||||||
|
} else {
|
||||||
|
returnEntry.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
for (const returnEntry of actionReturns) {
|
||||||
|
returnEntry?.destroy?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { SettingInputFieldType } from '$lib/constants';
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
|
@ -44,6 +45,13 @@
|
||||||
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
|
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingSwitch
|
||||||
|
title={$t('admin.server_public_users')}
|
||||||
|
subtitle={$t('admin.server_public_users_description')}
|
||||||
|
{disabled}
|
||||||
|
bind:checked={config.server.publicUsers}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<SettingButtonsRow
|
<SettingButtonsRow
|
||||||
onReset={(options) => onReset({ ...options, configKeys: ['server'] })}
|
onReset={(options) => onReset({ ...options, configKeys: ['server'] })}
|
||||||
|
|
|
@ -61,7 +61,6 @@
|
||||||
label={$t('admin.theme_custom_css_settings')}
|
label={$t('admin.theme_custom_css_settings')}
|
||||||
description={$t('admin.theme_custom_css_settings_description')}
|
description={$t('admin.theme_custom_css_settings_description')}
|
||||||
bind:value={config.theme.customCss}
|
bind:value={config.theme.customCss}
|
||||||
required={true}
|
|
||||||
isEdited={config.theme.customCss !== savedConfig.theme.customCss}
|
isEdited={config.theme.customCss !== savedConfig.theme.customCss}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -394,6 +394,7 @@
|
||||||
let isFullScreen = $derived(fullscreenElement !== null);
|
let isFullScreen = $derived(fullscreenElement !== null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (asset) {
|
if (asset) {
|
||||||
|
previewStackedAsset = undefined;
|
||||||
handlePromiseError(refreshStack());
|
handlePromiseError(refreshStack());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
|
import NavigationBar from '../shared-components/navigation-bar/navigation-bar.svelte';
|
||||||
import SideBar from '../shared-components/side-bar/side-bar.svelte';
|
import SideBar from '../shared-components/side-bar/side-bar.svelte';
|
||||||
import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte';
|
import AdminSideBar from '../shared-components/side-bar/admin-side-bar.svelte';
|
||||||
|
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -16,6 +17,7 @@
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
scrollbar?: boolean;
|
scrollbar?: boolean;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
|
use?: ActionArray;
|
||||||
header?: Snippet;
|
header?: Snippet;
|
||||||
sidebar?: Snippet;
|
sidebar?: Snippet;
|
||||||
buttons?: Snippet;
|
buttons?: Snippet;
|
||||||
|
@ -29,6 +31,7 @@
|
||||||
description = undefined,
|
description = undefined,
|
||||||
scrollbar = true,
|
scrollbar = true,
|
||||||
admin = false,
|
admin = false,
|
||||||
|
use = [],
|
||||||
header,
|
header,
|
||||||
sidebar,
|
sidebar,
|
||||||
buttons,
|
buttons,
|
||||||
|
@ -73,7 +76,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto">
|
<div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||||
|
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||||
|
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
|
||||||
|
describe('Combobox component', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||||
|
vi.stubGlobal('visualViewport', getVisualViewportMock());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows selected option', () => {
|
||||||
|
render(Combobox, {
|
||||||
|
label: 'test',
|
||||||
|
selectedOption: { label: 'option-1', value: 'option-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toHaveValue('option-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the selected option when set to undefined', async () => {
|
||||||
|
const { rerender } = render(Combobox, {
|
||||||
|
label: 'test',
|
||||||
|
selectedOption: { label: 'option-1', value: 'option-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await rerender({ selectedOption: undefined });
|
||||||
|
expect(screen.getByRole('combobox')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,4 +1,5 @@
|
||||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||||
|
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import ChangeDate from './change-date.svelte';
|
import ChangeDate from './change-date.svelte';
|
||||||
|
@ -16,16 +17,7 @@ describe('ChangeDate component', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||||
|
vi.stubGlobal('visualViewport', getVisualViewportMock());
|
||||||
vi.stubGlobal('visualViewport', {
|
|
||||||
height: window.innerHeight,
|
|
||||||
width: window.innerWidth,
|
|
||||||
scale: 1,
|
|
||||||
offsetLeft: 0,
|
|
||||||
offsetTop: 0,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
@ -218,7 +218,7 @@
|
||||||
const getInputPosition = () => input?.getBoundingClientRect();
|
const getInputPosition = () => input?.getBoundingClientRect();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// searchQuery = selectedOption ? selectedOption.label : '';
|
searchQuery = selectedOption ? selectedOption.label : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
let filteredOptions = $derived(
|
let filteredOptions = $derived(
|
||||||
|
|
|
@ -96,13 +96,25 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readEntriesAsync = (reader: FileSystemDirectoryReader) => {
|
||||||
|
return new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||||
|
reader.readEntries(resolve, reject);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getContentsFromFileSystemDirectoryEntry = async (
|
const getContentsFromFileSystemDirectoryEntry = async (
|
||||||
fileSystemDirectoryEntry: FileSystemDirectoryEntry,
|
fileSystemDirectoryEntry: FileSystemDirectoryEntry,
|
||||||
): Promise<FileSystemEntry[]> => {
|
): Promise<FileSystemEntry[]> => {
|
||||||
return new Promise((resolve, reject) => {
|
const reader = fileSystemDirectoryEntry.createReader();
|
||||||
const reader = fileSystemDirectoryEntry.createReader();
|
const files: FileSystemEntry[] = [];
|
||||||
reader.readEntries(resolve, reject);
|
let entries: FileSystemEntry[];
|
||||||
});
|
|
||||||
|
do {
|
||||||
|
entries = await readEntriesAsync(reader);
|
||||||
|
files.push(...entries);
|
||||||
|
} while (entries.length > 0);
|
||||||
|
|
||||||
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFiles = async (files?: FileList | File[]) => {
|
const handleFiles = async (files?: FileList | File[]) => {
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { type AlbumStatisticsResponseDto, getAlbumStatistics } from '@immich/sdk';
|
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
albumType: keyof AlbumStatisticsResponseDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { albumType }: Props = $props();
|
|
||||||
|
|
||||||
const handleAlbumCount = async () => {
|
|
||||||
try {
|
|
||||||
return await getAlbumStatistics();
|
|
||||||
} catch {
|
|
||||||
return { owned: 0, shared: 0, notShared: 0 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#await handleAlbumCount()}
|
|
||||||
<LoadingSpinner />
|
|
||||||
{:then data}
|
|
||||||
<div>
|
|
||||||
<p>{$t('albums_count', { values: { count: data[albumType] } })}</p>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
|
@ -1,20 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { getAssetStatistics } from '@immich/sdk';
|
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
assetStats: NonNullable<Parameters<typeof getAssetStatistics>[0]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { assetStats }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#await getAssetStatistics(assetStats)}
|
|
||||||
<LoadingSpinner />
|
|
||||||
{:then data}
|
|
||||||
<div>
|
|
||||||
<p>{$t('videos_count', { values: { count: data.videos } })}</p>
|
|
||||||
<p>{$t('photos_count', { values: { count: data.images } })}</p>
|
|
||||||
</div>
|
|
||||||
{/await}
|
|
|
@ -1,10 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { mdiInformationOutline } from '@mdi/js';
|
|
||||||
import { resolveRoute } from '$app/paths';
|
import { resolveRoute } from '$app/paths';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -13,7 +10,6 @@
|
||||||
flippedLogo?: boolean;
|
flippedLogo?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
preloadData?: boolean;
|
preloadData?: boolean;
|
||||||
moreInformation?: Snippet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
@ -23,10 +19,8 @@
|
||||||
flippedLogo = false,
|
flippedLogo = false,
|
||||||
isSelected = $bindable(false),
|
isSelected = $bindable(false),
|
||||||
preloadData = true,
|
preloadData = true,
|
||||||
moreInformation,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let showMoreInformation = $state(false);
|
|
||||||
let routePath = $derived(resolveRoute(routeId, {}));
|
let routePath = $derived(resolveRoute(routeId, {}));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
@ -39,7 +33,7 @@
|
||||||
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
|
data-sveltekit-preload-data={preloadData ? 'hover' : 'off'}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
aria-current={isSelected ? 'page' : undefined}
|
aria-current={isSelected ? 'page' : undefined}
|
||||||
class="flex w-full place-items-center justify-between gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
|
class="flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
|
||||||
{isSelected
|
{isSelected
|
||||||
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
|
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
|
||||||
: ''}
|
: ''}
|
||||||
|
@ -50,33 +44,5 @@
|
||||||
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
|
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
|
||||||
<span class="text-sm font-medium">{title}</span>
|
<span class="text-sm font-medium">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div></div>
|
||||||
<div
|
|
||||||
class="h-0 overflow-hidden transition-[height] delay-1000 duration-100 sm:group-hover:h-auto group-hover:sm:overflow-visible md:h-auto md:overflow-visible"
|
|
||||||
>
|
|
||||||
{#if moreInformation}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="relative flex cursor-default select-none justify-center"
|
|
||||||
onmouseenter={() => (showMoreInformation = true)}
|
|
||||||
onmouseleave={() => (showMoreInformation = false)}
|
|
||||||
>
|
|
||||||
<div class="p-1 text-gray-600 hover:cursor-help dark:text-gray-400">
|
|
||||||
<Icon path={mdiInformationOutline} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showMoreInformation}
|
|
||||||
<div class="absolute right-6 top-0">
|
|
||||||
<div
|
|
||||||
class="flex place-content-center place-items-center whitespace-nowrap rounded-3xl border bg-immich-bg px-6 py-3 text-xs text-immich-fg shadow-lg dark:border-immich-dark-gray dark:bg-gray-600 dark:text-immich-dark-fg"
|
|
||||||
class:hidden={!showMoreInformation}
|
|
||||||
transition:fade={{ duration: 200 }}
|
|
||||||
>
|
|
||||||
{@render moreInformation?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -24,8 +24,6 @@
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import SideBarSection from './side-bar-section.svelte';
|
import SideBarSection from './side-bar-section.svelte';
|
||||||
import SideBarLink from './side-bar-link.svelte';
|
import SideBarLink from './side-bar-link.svelte';
|
||||||
import MoreInformationAssets from '$lib/components/shared-components/side-bar/more-information-assets.svelte';
|
|
||||||
import MoreInformationAlbums from '$lib/components/shared-components/side-bar/more-information-albums.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
|
@ -47,11 +45,7 @@
|
||||||
routeId="/(user)/photos"
|
routeId="/(user)/photos"
|
||||||
bind:isSelected={isPhotosSelected}
|
bind:isSelected={isPhotosSelected}
|
||||||
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
|
icon={isPhotosSelected ? mdiImageMultiple : mdiImageMultipleOutline}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isArchived: false }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
{#if $featureFlags.search}
|
{#if $featureFlags.search}
|
||||||
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
|
<SideBarLink title={$t('explore')} routeId="/(user)/explore" icon={mdiMagnify} />
|
||||||
|
@ -80,11 +74,7 @@
|
||||||
routeId="/(user)/sharing"
|
routeId="/(user)/sharing"
|
||||||
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
|
icon={isSharingSelected ? mdiAccountMultiple : mdiAccountMultipleOutline}
|
||||||
bind:isSelected={isSharingSelected}
|
bind:isSelected={isSharingSelected}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAlbums albumType="shared" />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
|
||||||
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
|
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
|
||||||
|
@ -96,17 +86,9 @@
|
||||||
routeId="/(user)/favorites"
|
routeId="/(user)/favorites"
|
||||||
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
|
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
|
||||||
bind:isSelected={isFavoritesSelected}
|
bind:isSelected={isFavoritesSelected}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isFavorite: true }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
|
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAlbums albumType="owned" />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
|
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
|
||||||
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
|
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
|
||||||
|
@ -128,11 +110,7 @@
|
||||||
routeId="/(user)/archive"
|
routeId="/(user)/archive"
|
||||||
bind:isSelected={isArchiveSelected}
|
bind:isSelected={isArchiveSelected}
|
||||||
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isArchived: true }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
|
|
||||||
{#if $featureFlags.trash}
|
{#if $featureFlags.trash}
|
||||||
<SideBarLink
|
<SideBarLink
|
||||||
|
@ -140,11 +118,7 @@
|
||||||
routeId="/(user)/trash"
|
routeId="/(user)/trash"
|
||||||
bind:isSelected={isTrashSelected}
|
bind:isSelected={isTrashSelected}
|
||||||
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
|
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
|
||||||
>
|
></SideBarLink>
|
||||||
{#snippet moreInformation()}
|
|
||||||
<MoreInformationAssets assetStats={{ isTrashed: true }} />
|
|
||||||
{/snippet}
|
|
||||||
</SideBarLink>
|
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
|
@ -30,8 +30,10 @@
|
||||||
mdiFeatureSearchOutline,
|
mdiFeatureSearchOutline,
|
||||||
mdiKeyOutline,
|
mdiKeyOutline,
|
||||||
mdiOnepassword,
|
mdiOnepassword,
|
||||||
|
mdiServerOutline,
|
||||||
mdiTwoFactorAuthentication,
|
mdiTwoFactorAuthentication,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
keys?: ApiKeyResponseDto[];
|
keys?: ApiKeyResponseDto[];
|
||||||
|
@ -59,6 +61,15 @@
|
||||||
<UserProfileSettings />
|
<UserProfileSettings />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
<SettingAccordion
|
||||||
|
icon={mdiServerOutline}
|
||||||
|
key="user-usage-info"
|
||||||
|
title={$t('user_usage_stats')}
|
||||||
|
subtitle={$t('user_usage_stats_description')}
|
||||||
|
>
|
||||||
|
<UserUsageStatistic />
|
||||||
|
</SettingAccordion>
|
||||||
|
|
||||||
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
|
<SettingAccordion icon={mdiApi} key="api-keys" title={$t('api_keys')} subtitle={$t('manage_your_api_keys')}>
|
||||||
<UserAPIKeyList bind:keys />
|
<UserAPIKeyList bind:keys />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import {
|
||||||
|
getAlbumStatistics,
|
||||||
|
getAssetStatistics,
|
||||||
|
type AlbumStatisticsResponseDto,
|
||||||
|
type AssetStatsResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
let timelineStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let favoriteStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let archiveStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let trashStats: AssetStatsResponseDto = $state({
|
||||||
|
videos: 0,
|
||||||
|
images: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let albumStats: AlbumStatisticsResponseDto = $state({
|
||||||
|
owned: 0,
|
||||||
|
shared: 0,
|
||||||
|
notShared: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getUsage = async () => {
|
||||||
|
[timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([
|
||||||
|
getAssetStatistics({ isArchived: false }),
|
||||||
|
getAssetStatistics({ isFavorite: true }),
|
||||||
|
getAssetStatistics({ isArchived: true }),
|
||||||
|
getAssetStatistics({ isTrashed: true }),
|
||||||
|
getAlbumStatistics(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await getUsage();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet row(viewName: string, stats: AssetStatsResponseDto)}
|
||||||
|
<tr
|
||||||
|
class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg odd:bg-immich-bg even:bg-immich-gray odd:dark:bg-immich-dark-gray/50 even:dark:bg-immich-dark-gray/75"
|
||||||
|
>
|
||||||
|
<td class="w-1/4 px-4 text-sm">{viewName}</td>
|
||||||
|
<td class="w-1/4 px-4 text-sm">{stats.images.toLocaleString($locale)}</td>
|
||||||
|
<td class="w-1/4 px-4 text-sm">{stats.videos.toLocaleString($locale)}</td>
|
||||||
|
<td class="w-1/4 px-4">{stats.total.toLocaleString($locale)}</td>
|
||||||
|
</tr>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<section class="my-6">
|
||||||
|
<p class="text-xs dark:text-white uppercase">{$t('photos_and_videos')}</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left mt-4">
|
||||||
|
<thead
|
||||||
|
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<tr class="flex w-full place-items-center text-sm font-medium text-center">
|
||||||
|
<th class="w-1/4">{$t('view_name')}</th>
|
||||||
|
<th class="w-1/4">{$t('photos')}</th>
|
||||||
|
<th class="w-1/4">{$t('videos')}</th>
|
||||||
|
<th class="w-1/4">{$t('total')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||||
|
{@render row($t('timeline'), timelineStats)}
|
||||||
|
{@render row($t('favorites'), favoriteStats)}
|
||||||
|
{@render row($t('archive'), archiveStats)}
|
||||||
|
{@render row($t('trash'), trashStats)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<p class="text-xs dark:text-white uppercase">{$t('albums')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-left mt-4">
|
||||||
|
<thead
|
||||||
|
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<tr class="flex w-full place-items-center text-sm font-medium text-center">
|
||||||
|
<th class="w-1/2">{$t('owned')}</th>
|
||||||
|
<th class="w-1/2">{$t('shared')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||||
|
<tr
|
||||||
|
class="flex h-14 w-full place-items-center text-center dark:text-immich-dark-fg bg-immich-bg dark:bg-immich-dark-gray/50"
|
||||||
|
>
|
||||||
|
<td class="w-1/2 px-4 text-sm">{albumStats.owned.toLocaleString($locale)}</td>
|
||||||
|
<td class="w-1/2 px-4 text-sm">{albumStats.shared.toLocaleString($locale)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
|
@ -84,6 +84,11 @@ export enum QueryParameter {
|
||||||
PATH = 'path',
|
PATH = 'path',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SessionStorageKey {
|
||||||
|
INFINITE_SCROLL_PAGE = 'infiniteScrollPage',
|
||||||
|
SCROLL_POSITION = 'scrollPosition',
|
||||||
|
}
|
||||||
|
|
||||||
export enum OpenSettingQueryParameterValue {
|
export enum OpenSettingQueryParameterValue {
|
||||||
OAUTH = 'oauth',
|
OAUTH = 'oauth',
|
||||||
JOB = 'job',
|
JOB = 'job',
|
||||||
|
|
|
@ -1,69 +0,0 @@
|
||||||
import {
|
|
||||||
getAssetsByOriginalPath,
|
|
||||||
getUniqueOriginalPaths,
|
|
||||||
/**
|
|
||||||
* TODO: Incorrect type
|
|
||||||
*/
|
|
||||||
type AssetResponseDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { get, writable } from 'svelte/store';
|
|
||||||
|
|
||||||
type AssetCache = {
|
|
||||||
[path: string]: AssetResponseDto[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type FoldersStore = {
|
|
||||||
uniquePaths: string[] | null;
|
|
||||||
assets: AssetCache;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createFoldersStore() {
|
|
||||||
const initialState: FoldersStore = {
|
|
||||||
uniquePaths: null,
|
|
||||||
assets: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const { subscribe, set, update } = writable(initialState);
|
|
||||||
|
|
||||||
async function fetchUniquePaths() {
|
|
||||||
const state = get(foldersStore);
|
|
||||||
|
|
||||||
if (state.uniquePaths !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniquePaths = await getUniqueOriginalPaths();
|
|
||||||
if (uniquePaths) {
|
|
||||||
update((state) => ({ ...state, uniquePaths }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAssetsByPath(path: string) {
|
|
||||||
const state = get(foldersStore);
|
|
||||||
|
|
||||||
if (state.assets[path]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assets = await getAssetsByOriginalPath({ path });
|
|
||||||
if (assets) {
|
|
||||||
update((state) => ({
|
|
||||||
...state,
|
|
||||||
assets: { ...state.assets, [path]: assets },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCache() {
|
|
||||||
set(initialState);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe,
|
|
||||||
fetchUniquePaths,
|
|
||||||
fetchAssetsByPath,
|
|
||||||
clearCache,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const foldersStore = createFoldersStore();
|
|
45
web/src/lib/stores/folders.svelte.ts
Normal file
45
web/src/lib/stores/folders.svelte.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import {
|
||||||
|
getAssetsByOriginalPath,
|
||||||
|
getUniqueOriginalPaths,
|
||||||
|
/**
|
||||||
|
* TODO: Incorrect type
|
||||||
|
*/
|
||||||
|
type AssetResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
|
||||||
|
type AssetCache = {
|
||||||
|
[path: string]: AssetResponseDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
class FoldersStore {
|
||||||
|
private initialized = false;
|
||||||
|
uniquePaths = $state<string[]>([]);
|
||||||
|
assets = $state<AssetCache>({});
|
||||||
|
|
||||||
|
async fetchUniquePaths() {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
const uniquePaths = await getUniqueOriginalPaths();
|
||||||
|
this.uniquePaths.push(...uniquePaths);
|
||||||
|
this.uniquePaths.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAssetsByPath(path: string) {
|
||||||
|
if (this.assets[path]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assets[path] = await getAssetsByOriginalPath({ path });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
this.initialized = false;
|
||||||
|
this.uniquePaths = [];
|
||||||
|
this.assets = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const foldersStore = new FoldersStore();
|
|
@ -34,6 +34,7 @@ export const serverConfig = writable<ServerConfig>({
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
mapDarkStyleUrl: '',
|
mapDarkStyleUrl: '',
|
||||||
mapLightStyleUrl: '',
|
mapLightStyleUrl: '',
|
||||||
|
publicUsers: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const retrieveServerConfig = async () => {
|
export const retrieveServerConfig = async () => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { foldersStore } from '$lib/stores/folders.store';
|
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||||
import { serverInfo } from '$lib/stores/server-info.store';
|
import { serverInfo } from '$lib/stores/server-info.store';
|
||||||
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';
|
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';
|
||||||
|
|
|
@ -7,8 +7,6 @@ export const normalizeTreePath = (path: string) => path.replace(/^\//, '').repla
|
||||||
export function buildTree(paths: string[]) {
|
export function buildTree(paths: string[]) {
|
||||||
const root: RecursiveObject = {};
|
const root: RecursiveObject = {};
|
||||||
|
|
||||||
paths.sort();
|
|
||||||
|
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
const parts = path.split('/');
|
const parts = path.split('/');
|
||||||
let current = root;
|
let current = root;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { scrollMemory } from '$lib/actions/scroll-memory';
|
||||||
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
|
import { AlbumFilter, albumViewSettings } from '$lib/stores/preferences.store';
|
||||||
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
|
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
|
@ -8,6 +9,7 @@
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||||
import GroupTab from '$lib/components/elements/group-tab.svelte';
|
import GroupTab from '$lib/components/elements/group-tab.svelte';
|
||||||
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -20,7 +22,7 @@
|
||||||
let albumGroups: string[] = $state([]);
|
let albumGroups: string[] = $state([]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title}>
|
<UserPageLayout title={data.meta.title} use={[[scrollMemory, { routeStartsWith: AppRoute.ALBUMS }]]}>
|
||||||
{#snippet buttons()}
|
{#snippet buttons()}
|
||||||
<div class="flex place-items-center gap-2">
|
<div class="flex place-items-center gap-2">
|
||||||
<AlbumsControls {albumGroups} bind:searchQuery />
|
<AlbumsControls {albumGroups} bind:searchQuery />
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, goto, onNavigate } from '$app/navigation';
|
import { afterNavigate, goto, onNavigate } from '$app/navigation';
|
||||||
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
|
import AlbumDescription from '$lib/components/album-page/album-description.svelte';
|
||||||
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
|
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
|
||||||
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
|
import AlbumSummary from '$lib/components/album-page/album-summary.svelte';
|
||||||
|
@ -430,7 +431,7 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex overflow-hidden">
|
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
|
||||||
<div class="relative w-full shrink">
|
<div class="relative w-full shrink">
|
||||||
{#if $isMultiSelectState}
|
{#if $isMultiSelectState}
|
||||||
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
|
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import type { Viewport } from '$lib/stores/assets.store';
|
import type { Viewport } from '$lib/stores/assets.store';
|
||||||
import { foldersStore } from '$lib/stores/folders.store';
|
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||||
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
||||||
import { mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
|
import { mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
|
|
||||||
let pathSegments = $derived(data.path ? data.path.split('/') : []);
|
let pathSegments = $derived(data.path ? data.path.split('/') : []);
|
||||||
let tree = $derived(buildTree($foldersStore?.uniquePaths || []));
|
let tree = $derived(buildTree(foldersStore.uniquePaths));
|
||||||
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
|
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
|
||||||
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
|
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { QueryParameter } from '$lib/constants';
|
import { QueryParameter } from '$lib/constants';
|
||||||
import { foldersStore } from '$lib/stores/folders.store';
|
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||||
import { authenticate } from '$lib/utils/auth';
|
import { authenticate } from '$lib/utils/auth';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
import { getAssetInfoFromParam } from '$lib/utils/navigation';
|
||||||
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async ({ params, url }) => {
|
export const load = (async ({ params, url }) => {
|
||||||
|
@ -13,18 +12,16 @@ export const load = (async ({ params, url }) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
await foldersStore.fetchUniquePaths();
|
await foldersStore.fetchUniquePaths();
|
||||||
const { uniquePaths } = get(foldersStore);
|
|
||||||
|
|
||||||
let pathAssets = null;
|
let pathAssets = null;
|
||||||
|
|
||||||
const path = url.searchParams.get(QueryParameter.PATH);
|
const path = url.searchParams.get(QueryParameter.PATH);
|
||||||
if (path) {
|
if (path) {
|
||||||
await foldersStore.fetchAssetsByPath(path);
|
await foldersStore.fetchAssetsByPath(path);
|
||||||
const { assets } = get(foldersStore);
|
pathAssets = foldersStore.assets[path] || null;
|
||||||
pathAssets = assets[path] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let tree = buildTree(uniquePaths || []);
|
let tree = buildTree(foldersStore.uniquePaths);
|
||||||
const parts = normalizeTreePath(path || '').split('/');
|
const parts = normalizeTreePath(path || '').split('/');
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
tree = tree?.[part];
|
tree = tree?.[part];
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
|
import { scrollMemory } from '$lib/actions/scroll-memory';
|
||||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
@ -17,7 +18,7 @@
|
||||||
notificationController,
|
notificationController,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants';
|
import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { websocketEvents } from '$lib/stores/websocket';
|
import { websocketEvents } from '$lib/stores/websocket';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
@ -50,6 +51,7 @@
|
||||||
let showSetBirthDateModal = $state(false);
|
let showSetBirthDateModal = $state(false);
|
||||||
let showMergeModal = $state(false);
|
let showMergeModal = $state(false);
|
||||||
let personName = $state('');
|
let personName = $state('');
|
||||||
|
let currentPage = $state(1);
|
||||||
let nextPage = $state(data.people.hasNextPage ? 2 : null);
|
let nextPage = $state(data.people.hasNextPage ? 2 : null);
|
||||||
let personMerge1 = $state<PersonResponseDto>();
|
let personMerge1 = $state<PersonResponseDto>();
|
||||||
let personMerge2 = $state<PersonResponseDto>();
|
let personMerge2 = $state<PersonResponseDto>();
|
||||||
|
@ -68,6 +70,7 @@
|
||||||
handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
|
handlePromiseError(searchPeopleElement.searchPeople(true, searchName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
|
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
|
||||||
for (const person of people) {
|
for (const person of people) {
|
||||||
if (person.id === personId) {
|
if (person.id === personId) {
|
||||||
|
@ -77,6 +80,36 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loadInitialScroll = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
// Load up to previously loaded page when returning.
|
||||||
|
let newNextPage = sessionStorage.getItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
|
||||||
|
if (newNextPage && nextPage) {
|
||||||
|
let startingPage = nextPage,
|
||||||
|
pagesToLoad = Number.parseInt(newNextPage) - nextPage;
|
||||||
|
|
||||||
|
if (pagesToLoad) {
|
||||||
|
handlePromiseError(
|
||||||
|
Promise.all(
|
||||||
|
Array.from({ length: pagesToLoad }).map((_, i) => {
|
||||||
|
return getAllPeople({ withHidden: true, page: startingPage + i });
|
||||||
|
}),
|
||||||
|
).then((pages) => {
|
||||||
|
for (const page of pages) {
|
||||||
|
people = people.concat(page.people);
|
||||||
|
}
|
||||||
|
currentPage = startingPage + pagesToLoad - 1;
|
||||||
|
nextPage = pages.at(-1)?.hasNextPage ? startingPage + pagesToLoad : null;
|
||||||
|
resolve(); // wait until extra pages are loaded
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const loadNextPage = async () => {
|
const loadNextPage = async () => {
|
||||||
if (!nextPage) {
|
if (!nextPage) {
|
||||||
return;
|
return;
|
||||||
|
@ -85,6 +118,9 @@
|
||||||
try {
|
try {
|
||||||
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
|
const { people: newPeople, hasNextPage } = await getAllPeople({ withHidden: true, page: nextPage });
|
||||||
people = people.concat(newPeople);
|
people = people.concat(newPeople);
|
||||||
|
if (nextPage !== null) {
|
||||||
|
currentPage = nextPage;
|
||||||
|
}
|
||||||
nextPage = hasNextPage ? nextPage + 1 : null;
|
nextPage = hasNextPage ? nextPage + 1 : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.failed_to_load_people'));
|
handleError(error, $t('errors.failed_to_load_people'));
|
||||||
|
@ -323,6 +359,23 @@
|
||||||
<UserPageLayout
|
<UserPageLayout
|
||||||
title={$t('people')}
|
title={$t('people')}
|
||||||
description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
|
description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
|
||||||
|
use={[
|
||||||
|
[
|
||||||
|
scrollMemory,
|
||||||
|
{
|
||||||
|
routeStartsWith: AppRoute.PEOPLE,
|
||||||
|
beforeSave: () => {
|
||||||
|
if (currentPage) {
|
||||||
|
sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeClear: () => {
|
||||||
|
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
|
||||||
|
},
|
||||||
|
beforeLoad: loadInitialScroll,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{#snippet buttons()}
|
{#snippet buttons()}
|
||||||
{#if people.length > 0}
|
{#if people.length > 0}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
|
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
|
||||||
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
|
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
|
||||||
|
@ -25,7 +26,7 @@
|
||||||
NotificationType,
|
NotificationType,
|
||||||
notificationController,
|
notificationController,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { AppRoute, PersonPageViewMode, QueryParameter } from '$lib/constants';
|
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||||
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets.store';
|
import { AssetStore } from '$lib/stores/assets.store';
|
||||||
|
@ -62,14 +63,17 @@
|
||||||
data: PageData;
|
data: PageData;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data = $bindable() }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
let numberOfAssets = $state(data.statistics.assets);
|
let numberOfAssets = $state(data.statistics.assets);
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
|
|
||||||
let assetStore = new AssetStore({
|
const assetStoreOptions = { isArchived: false, personId: data.person.id };
|
||||||
isArchived: false,
|
const assetStore = new AssetStore(assetStoreOptions);
|
||||||
personId: data.person.id,
|
|
||||||
|
$effect(() => {
|
||||||
|
assetStoreOptions.personId = data.person.id;
|
||||||
|
handlePromiseError(assetStore.updateOptions(assetStoreOptions));
|
||||||
});
|
});
|
||||||
|
|
||||||
const assetInteractionStore = createAssetInteractionStore();
|
const assetInteractionStore = createAssetInteractionStore();
|
||||||
|
@ -164,7 +168,7 @@
|
||||||
type: NotificationType.Info,
|
type: NotificationType.Info,
|
||||||
});
|
});
|
||||||
|
|
||||||
await goto(previousRoute, { replaceState: true });
|
await goto(previousRoute);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_hide_person'));
|
handleError(error, $t('errors.unable_to_hide_person'));
|
||||||
}
|
}
|
||||||
|
@ -328,7 +332,6 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (person) {
|
if (person) {
|
||||||
handlePromiseError(updateAssetCount());
|
handlePromiseError(updateAssetCount());
|
||||||
handlePromiseError(assetStore.updateOptions({ personId: person.id }));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -431,7 +434,15 @@
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg">
|
<main
|
||||||
|
class="relative h-screen overflow-hidden bg-immich-bg tall:ml-4 pt-[var(--navbar-height)] dark:bg-immich-dark-bg"
|
||||||
|
use:scrollMemoryClearer={{
|
||||||
|
routeStartsWith: AppRoute.PEOPLE,
|
||||||
|
beforeClear: () => {
|
||||||
|
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#key person.id}
|
{#key person.id}
|
||||||
<AssetGrid
|
<AssetGrid
|
||||||
enableRouting={true}
|
enableRouting={true}
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "es2020",
|
"target": "es2022",
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"]
|
||||||
},
|
},
|
||||||
"extends": "./.svelte-kit/tsconfig.json"
|
"extends": "./.svelte-kit/tsconfig.json"
|
||||||
|
|
Loading…
Reference in a new issue