diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64c084fa2e..d52ca4e6f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -593,8 +593,8 @@ jobs: echo "Changed files: ${CHANGED_FILES}" exit 1 - generated-typeorm-migrations-up-to-date: - name: TypeORM Checks + sql-schema-up-to-date: + name: SQL Schema Checks runs-on: ubuntu-latest permissions: contents: read @@ -641,7 +641,7 @@ jobs: - name: Generate new migrations continue-on-error: true - run: npm run migrations:generate TestMigration + run: npm run migrations:generate src/TestMigration - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20 diff --git a/i18n/en.json b/i18n/en.json index c17d55872c..d2e109dec0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -8,6 +8,8 @@ "unable_to_create_pincode": "Unable to create PIN code", "pincode_changed_successfully": "PIN code changed successfully", "pincode_created_successfully": "PIN code created successfully", + "pincode_reset_successfully": "PIN code reset successfully", + "reset_pincode": "Reset PIN code", "create_pincode": "Create PIN code", "about": "About", "account": "Account", @@ -63,6 +65,7 @@ "confirm_email_below": "To confirm, type \"{email}\" below", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", + "confirm_user_pincode_reset": "Are you sure you want to reset {user}'s PIN code?", "create_job": "Create job", "cron_expression": "Cron expression", "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>", @@ -932,6 +935,7 @@ "unable_to_remove_reaction": "Unable to remove reaction", "unable_to_repair_items": "Unable to repair items", "unable_to_reset_password": "Unable to reset password", + "unable_to_reset_pincode": "Unable to reset PIN code", "unable_to_resolve_duplicate": "Unable to resolve duplicate", "unable_to_restore_assets": "Unable to restore assets", "unable_to_restore_trash": "Unable to restore trash", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c83971b0de..2244dc4c5e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -114,6 +114,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | +*AuthenticationApi* | [**resetPincode**](doc//AuthenticationApi.md#resetpincode) | **POST** /auth/reset-pincode | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | @@ -398,6 +399,7 @@ Class | Method | HTTP request | Description - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) + - [ResetPincodeDto](doc//ResetPincodeDto.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 03f4b4ae27..fc4ab98c80 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -199,6 +199,7 @@ part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; +part 'model/reset_pincode_dto.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index a8809aa63b..3e3d2f5b62 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -286,6 +286,53 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'POST /auth/reset-pincode' operation and returns the [Response]. + /// Parameters: + /// + /// * [ResetPincodeDto] resetPincodeDto (required): + Future<Response> resetPincodeWithHttpInfo(ResetPincodeDto resetPincodeDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/reset-pincode'; + + // ignore: prefer_final_locals + Object? postBody = resetPincodeDto; + + final queryParams = <QueryParam>[]; + final headerParams = <String, String>{}; + final formParams = <String, String>{}; + + const contentTypes = <String>['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [ResetPincodeDto] resetPincodeDto (required): + Future<UserAdminResponseDto?> resetPincode(ResetPincodeDto resetPincodeDto,) async { + final response = await resetPincodeWithHttpInfo(resetPincodeDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 621a298253..4caee90715 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -454,6 +454,8 @@ class ApiClient { return ReactionLevelTypeTransformer().decode(value); case 'ReactionType': return ReactionTypeTypeTransformer().decode(value); + case 'ResetPincodeDto': + return ResetPincodeDto.fromJson(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); case 'SearchAlbumResponseDto': diff --git a/mobile/openapi/lib/model/reset_pincode_dto.dart b/mobile/openapi/lib/model/reset_pincode_dto.dart new file mode 100644 index 0000000000..29a3a75b30 --- /dev/null +++ b/mobile/openapi/lib/model/reset_pincode_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ResetPincodeDto { + /// Returns a new [ResetPincodeDto] instance. + ResetPincodeDto({ + required this.userId, + }); + + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is ResetPincodeDto && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (userId.hashCode); + + @override + String toString() => 'ResetPincodeDto[userId=$userId]'; + + Map<String, dynamic> toJson() { + final json = <String, dynamic>{}; + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [ResetPincodeDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ResetPincodeDto? fromJson(dynamic value) { + upgradeDto(value, "ResetPincodeDto"); + if (value is Map) { + final json = value.cast<String, dynamic>(); + + return ResetPincodeDto( + userId: mapValueOfType<String>(json, r'userId')!, + ); + } + return null; + } + + static List<ResetPincodeDto> listFromJson(dynamic json, {bool growable = false,}) { + final result = <ResetPincodeDto>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ResetPincodeDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map<String, ResetPincodeDto> mapFromJson(dynamic json) { + final map = <String, ResetPincodeDto>{}; + if (json is Map && json.isNotEmpty) { + json = json.cast<String, dynamic>(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ResetPincodeDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ResetPincodeDto-objects as value to a dart map + static Map<String, List<ResetPincodeDto>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<ResetPincodeDto>>{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast<String, dynamic>(); + for (final entry in json.entries) { + map[entry.key] = ResetPincodeDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = <String>{ + 'userId', + }; +} + diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 25393ccab6..cfe3bec365 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -517,6 +517,9 @@ export type LogoutResponseDto = { redirectUri: string; successful: boolean; }; +export type ResetPincodeDto = { + userId: string; +}; export type AuthStatusResponseDto = { hasPincode: boolean; }; @@ -2051,6 +2054,18 @@ export function logout(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +export function resetPincode({ resetPincodeDto }: { + resetPincodeDto: ResetPincodeDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserAdminResponseDto; + }>("/auth/reset-pincode", oazapfts.json({ + ...opts, + method: "POST", + body: resetPincodeDto + }))); +} export function getAuthStatus(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 9c080039ac..ed45637890 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -90,13 +90,13 @@ export class UserRepository { } @GenerateSql({ params: [DummyValue.EMAIL] }) - getByEmail(email: string, withPassword?: boolean, withPincode?: boolean) { + getByEmail(email: string, options?: { withPassword?: boolean; withPincode?: boolean }) { return this.db .selectFrom('users') .select(columns.userAdmin) .select(withMetadata) - .$if(!!withPassword, (eb) => eb.select('password')) - .$if(!!withPincode, (eb) => eb.select('pincode')) + .$if(!!options?.withPassword, (eb) => eb.select('password')) + .$if(!!options?.withPincode, (eb) => eb.select('pincode')) .where('email', '=', email) .where('users.deletedAt', 'is', null) .executeTakeFirst(); diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts index 100b92aa63..a1134df6bc 100644 --- a/server/src/schema/enums.ts +++ b/server/src/schema/enums.ts @@ -1,4 +1,4 @@ -import { AssetStatus, SourceType } from 'src/enum'; +import { AssetStatus, AssetVisibility, SourceType } from 'src/enum'; import { registerEnum } from 'src/sql-tools'; export const assets_status_enum = registerEnum({ @@ -10,3 +10,8 @@ export const asset_face_source_type = registerEnum({ name: 'sourcetype', values: Object.values(SourceType), }); + +export const asset_visibility_enum = registerEnum({ + name: 'asset_visibility_enum', + values: Object.values(AssetVisibility), +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 1800f08c13..735dfd3ae9 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -1,5 +1,4 @@ -import { AssetVisibility } from 'src/enum'; -import { asset_face_source_type, assets_status_enum } from 'src/schema/enums'; +import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; import { assets_delete_audit, f_concat_ws, @@ -46,12 +45,7 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table'; import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; -import { ConfigurationParameter, Database, Extensions, registerEnum } from 'src/sql-tools'; - -export const asset_visibility_enum = registerEnum({ - name: 'asset_visibility_enum', - values: Object.values(AssetVisibility), -}); +import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) @@ -107,5 +101,5 @@ export class ImmichDatabase { assets_delete_audit, ]; - enum = [assets_status_enum, asset_face_source_type]; + enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; } diff --git a/server/src/schema/migrations/1746636476623-DropExtraIndexes.ts b/server/src/schema/migrations/1746636476623-DropExtraIndexes.ts new file mode 100644 index 0000000000..aae52829a5 --- /dev/null +++ b/server/src/schema/migrations/1746636476623-DropExtraIndexes.ts @@ -0,0 +1,21 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely<any>): Promise<void> { + const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db); + const databaseName = rows[0].db; + await sql.raw(`ALTER DATABASE "${databaseName}" SET search_path TO "$user", public, vectors`).execute(db); + await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "PK_21a6d86d1ab5d841648212e5353";`.execute(db); + await sql`ALTER TABLE "naturalearth_countries" DROP CONSTRAINT IF EXISTS "naturalearth_countries_pkey";`.execute(db); + await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "naturalearth_countries_pkey" PRIMARY KEY ("id") WITH (FILLFACTOR = 100);`.execute(db); + await sql`DROP INDEX IF EXISTS "IDX_02a43fd0b3c50fb6d7f0cb7282";`.execute(db); + await sql`DROP INDEX IF EXISTS "IDX_95ad7106dd7b484275443f580f";`.execute(db); + await sql`DROP INDEX IF EXISTS "IDX_7e077a8b70b3530138610ff5e0";`.execute(db); + await sql`DROP INDEX IF EXISTS "IDX_92e67dc508c705dd66c9461557";`.execute(db); + await sql`DROP INDEX IF EXISTS "IDX_6afb43681a21cf7815932bc38a";`.execute(db); +} + +export async function down(db: Kysely<any>): Promise<void> { + const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(db); + const databaseName = rows[0].db; + await sql.raw(`ALTER DATABASE "${databaseName}" RESET "search_path"`).execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 4552ac158d..d337984a46 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -1,7 +1,6 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; -import { asset_visibility_enum } from 'src/schema'; -import { assets_status_enum } from 'src/schema/enums'; +import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums'; import { assets_delete_audit } from 'src/schema/functions'; import { LibraryTable } from 'src/schema/tables/library.table'; import { StackTable } from 'src/schema/tables/stack.table'; diff --git a/server/src/schema/tables/natural-earth-countries.table.ts b/server/src/schema/tables/natural-earth-countries.table.ts index df1132d17d..e5e6ead772 100644 --- a/server/src/schema/tables/natural-earth-countries.table.ts +++ b/server/src/schema/tables/natural-earth-countries.table.ts @@ -1,6 +1,6 @@ import { Column, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; -@Table({ name: 'naturalearth_countries' }) +@Table({ name: 'naturalearth_countries', primaryConstraintName: 'naturalearth_countries_pkey' }) export class NaturalEarthCountriesTable { @PrimaryGeneratedColumn({ strategy: 'identity' }) id!: number; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index fb4499e0fc..113691d477 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -119,7 +119,7 @@ describe(AuthService.name, () => { await sut.changePassword(auth, dto); - expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true }); expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); }); @@ -872,7 +872,7 @@ describe(AuthService.name, () => { await sut.createPincode(auth, dto); - expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, false, true); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPincode: true }); expect(mocks.crypto.hashBcrypt).toHaveBeenCalledWith('new-pincode', SALT_ROUNDS); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pincode: expect.any(String) }); }); @@ -900,7 +900,7 @@ describe(AuthService.name, () => { await sut.changePincode(auth, dto); - expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, false, true); + expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPincode: true }); expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-pincode', 'hash-pincode'); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 848a813e29..705a29a334 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -60,9 +60,9 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Password login has been disabled'); } - let user = await this.userRepository.getByEmail(dto.email, true); + let user = await this.userRepository.getByEmail(dto.email, { withPassword: true }); if (user) { - const isAuthenticated = this.validatePassword(dto.password, user); + const isAuthenticated = this.validateSecrect(dto.password, user.password); if (!isAuthenticated) { user = undefined; } @@ -90,12 +90,12 @@ export class AuthService extends BaseService { async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> { const { password, newPassword } = dto; - const user = await this.userRepository.getByEmail(auth.user.email, true); + const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true }); if (!user) { throw new UnauthorizedException(); } - const valid = this.validatePassword(password, user); + const valid = this.validateSecrect(password, user.password); if (!valid) { throw new BadRequestException('Wrong password'); } @@ -108,7 +108,7 @@ export class AuthService extends BaseService { } async createPincode(auth: AuthDto, { pincode }: createPincodeDto): Promise<UserAdminResponseDto> { - const user = await this.userRepository.getByEmail(auth.user.email, false, true); + const user = await this.userRepository.getByEmail(auth.user.email, { withPincode: true }); if (!user) { throw new UnauthorizedException(); } @@ -127,18 +127,18 @@ export class AuthService extends BaseService { async changePincode(auth: AuthDto, dto: ChangePincodeDto): Promise<UserAdminResponseDto> { const { pincode, newPincode } = dto; - const user = await this.userRepository.getByEmail(auth.user.email, false, true); + const user = await this.userRepository.getByEmail(auth.user.email, { withPincode: true }); if (!user) { throw new UnauthorizedException(); } - const valid = this.validatePincode(pincode, user); + const valid = this.validateSecrect(pincode, user.pincode); if (!valid) { throw new BadRequestException('Wrong pincode'); } - const hasedPincode = await this.cryptoRepository.hashBcrypt(newPincode.toString(), SALT_ROUNDS); - const updatedUser = await this.userRepository.update(user.id, { pincode: hasedPincode }); + const hashedPincode = await this.cryptoRepository.hashBcrypt(newPincode.toString(), SALT_ROUNDS); + const updatedUser = await this.userRepository.update(user.id, { pincode: hashedPincode }); return mapUserAdmin(updatedUser); } @@ -411,18 +411,12 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid API key'); } - private validatePassword(inputPassword: string, user: { password?: string }): boolean { - if (!user || !user.password) { + private validateSecrect(inputSecret: string, existingHash?: string | null): boolean { + if (!existingHash) { return false; } - return this.cryptoRepository.compareBcrypt(inputPassword, user.password); - } - private validatePincode(inputPincode: string, user: { pincode?: string | null }): boolean { - if (!user || !user.pincode) { - return false; - } - return this.cryptoRepository.compareBcrypt(inputPincode, user.pincode); + return this.cryptoRepository.compareBcrypt(inputSecret, existingHash); } private async validateSession(tokenValue: string): Promise<AuthDto> { @@ -477,20 +471,14 @@ export class AuthService extends BaseService { } async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> { - const hasPincode = await this.hasPincode(auth); - - return { - hasPincode, - }; - } - - private async hasPincode(auth: AuthDto): Promise<boolean> { - const user = await this.userRepository.getByEmail(auth.user.email, false, true); + const user = await this.userRepository.getByEmail(auth.user.email, { withPincode: true }); if (!user) { throw new UnauthorizedException(); } - return !!user.pincode; + return { + hasPincode: !!user.pincode, + }; } async resetPincode(auth: AuthDto, dto: ResetPincodeDto): Promise<UserAdminResponseDto> { diff --git a/web/src/lib/components/album-page/album-map.svelte b/web/src/lib/components/album-page/album-map.svelte index 8230dea92e..871e26b4f9 100644 --- a/web/src/lib/components/album-page/album-map.svelte +++ b/web/src/lib/components/album-page/album-map.svelte @@ -130,6 +130,7 @@ clickable={false} bind:mapMarkers onSelect={onViewAssets} + showSettings={false} /> {/await} </div> diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 15bc42d001..d672b1a8b0 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -494,6 +494,7 @@ }, ]} center={latlng} + showSettings={false} zoom={12.5} simplified useLocationPin diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte deleted file mode 100644 index bba7398655..0000000000 --- a/web/src/lib/components/forms/api-key-form.svelte +++ /dev/null @@ -1,55 +0,0 @@ -<script lang="ts"> - import { mdiKeyVariant } from '@mdi/js'; - import { t } from 'svelte-i18n'; - import FullScreenModal from '../shared-components/full-screen-modal.svelte'; - import { NotificationType, notificationController } from '../shared-components/notification/notification'; - import { Button } from '@immich/ui'; - - interface Props { - apiKey: { name: string }; - title: string; - cancelText?: string; - submitText?: string; - onSubmit: (apiKey: { name: string }) => void; - onCancel: () => void; - } - - let { - apiKey = $bindable(), - title, - cancelText = $t('cancel'), - submitText = $t('save'), - onSubmit, - onCancel, - }: Props = $props(); - - const handleSubmit = () => { - if (apiKey.name) { - onSubmit({ name: apiKey.name }); - } else { - notificationController.show({ - message: $t('api_key_empty'), - type: NotificationType.Warning, - }); - } - }; - - const onsubmit = (event: Event) => { - event.preventDefault(); - handleSubmit(); - }; -</script> - -<FullScreenModal {title} icon={mdiKeyVariant} onClose={() => onCancel()}> - <form {onsubmit} autocomplete="off" id="api-key-form"> - <div class="mb-4 flex flex-col gap-2"> - <label class="immich-form-label" for="name">{$t('name')}</label> - <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} /> - </div> - </form> - - {#snippet stickyBottom()} - <Button shape="round" color="secondary" fullWidth onclick={() => onCancel()}>{cancelText}</Button> - <Button shape="round" type="submit" fullWidth form="api-key-form">{submitText}</Button> - {/snippet} -</FullScreenModal> diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte deleted file mode 100644 index 0d3b88d85a..0000000000 --- a/web/src/lib/components/forms/api-key-secret.svelte +++ /dev/null @@ -1,32 +0,0 @@ -<script lang="ts"> - import { copyToClipboard } from '$lib/utils'; - import { Button } from '@immich/ui'; - import { mdiKeyVariant } from '@mdi/js'; - import { t } from 'svelte-i18n'; - import FullScreenModal from '../shared-components/full-screen-modal.svelte'; - - interface Props { - secret?: string; - onDone: () => void; - } - - let { secret = '', onDone }: Props = $props(); -</script> - -<FullScreenModal title={$t('api_key')} icon={mdiKeyVariant} onClose={onDone}> - <div class="text-immich-primary dark:text-immich-dark-primary"> - <p class="text-sm dark:text-immich-dark-fg"> - {$t('api_key_description')} - </p> - </div> - - <div class="my-4 flex flex-col gap-2"> - <!-- <label class="immich-form-label" for="secret">{ $t("api_key") }</label> --> - <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret}></textarea> - </div> - - {#snippet stickyBottom()} - <Button shape="round" onclick={() => copyToClipboard(secret)} fullWidth>{$t('copy_to_clipboard')}</Button> - <Button shape="round" onclick={onDone} fullWidth>{$t('done')}</Button> - {/snippet} -</FullScreenModal> diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte deleted file mode 100644 index c1ee1e0b80..0000000000 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ /dev/null @@ -1,133 +0,0 @@ -<script lang="ts"> - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; - import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; - import type { MapSettings } from '$lib/stores/preferences.store'; - import { Button, Field, Stack, Switch } from '@immich/ui'; - import { Duration } from 'luxon'; - import { t } from 'svelte-i18n'; - import { fly } from 'svelte/transition'; - import DateInput from '../elements/date-input.svelte'; - - interface Props { - settings: MapSettings; - onClose: () => void; - onSave: (settings: MapSettings) => void; - } - - let { settings: initialValues, onClose, onSave }: Props = $props(); - let settings = $state(initialValues); - - let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore); - - const onsubmit = (event: Event) => { - event.preventDefault(); - onSave(settings); - }; -</script> - -<form {onsubmit}> - <FullScreenModal title={$t('map_settings')} {onClose}> - <Stack gap={4}> - <Field label={$t('allow_dark_mode')}> - <Switch bind:checked={settings.allowDarkMode} /> - </Field> - <Field label={$t('only_favorites')}> - <Switch bind:checked={settings.onlyFavorites} /> - </Field> - <Field label={$t('include_archived')}> - <Switch bind:checked={settings.includeArchived} /> - </Field> - <Field label={$t('include_shared_partner_assets')}> - <Switch bind:checked={settings.withPartners} /> - </Field> - <Field label={$t('include_shared_albums')}> - <Switch bind:checked={settings.withSharedAlbums} /> - </Field> - - {#if customDateRange} - <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4"> - <div class="flex items-center justify-between gap-8"> - <label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label> - <DateInput - class="immich-form-input w-40" - type="date" - id="date-after" - max={settings.dateBefore} - bind:value={settings.dateAfter} - /> - </div> - <div class="flex items-center justify-between gap-8"> - <label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label> - <DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} /> - </div> - <div class="flex justify-center text-xs"> - <Button - color="primary" - size="small" - variant="ghost" - onclick={() => { - customDateRange = false; - settings.dateAfter = ''; - settings.dateBefore = ''; - }} - > - {$t('remove_custom_date_range')} - </Button> - </div> - </div> - {:else} - <div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1"> - <SettingSelect - label={$t('date_range')} - name="date-range" - bind:value={settings.relativeDate} - options={[ - { - value: '', - text: $t('all'), - }, - { - value: Duration.fromObject({ hours: 24 }).toISO() || '', - text: $t('past_durations.hours', { values: { hours: 24 } }), - }, - { - value: Duration.fromObject({ days: 7 }).toISO() || '', - text: $t('past_durations.days', { values: { days: 7 } }), - }, - { - value: Duration.fromObject({ days: 30 }).toISO() || '', - text: $t('past_durations.days', { values: { days: 30 } }), - }, - { - value: Duration.fromObject({ years: 1 }).toISO() || '', - text: $t('past_durations.years', { values: { years: 1 } }), - }, - { - value: Duration.fromObject({ years: 3 }).toISO() || '', - text: $t('past_durations.years', { values: { years: 3 } }), - }, - ]} - /> - <div class="text-xs"> - <Button - color="primary" - size="small" - variant="ghost" - onclick={() => { - customDateRange = true; - settings.relativeDate = ''; - }} - > - {$t('use_custom_date_range')} - </Button> - </div> - </div> - {/if} - </Stack> - - {#snippet stickyBottom()} - <Button color="secondary" shape="round" fullWidth onclick={onClose}>{$t('cancel')}</Button> - <Button type="submit" shape="round" fullWidth>{$t('save')}</Button> - {/snippet} - </FullScreenModal> -</form> diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 3539945911..e502d9aeda 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -190,6 +190,7 @@ simplified={true} clickable={true} onClickPoint={(selected) => (point = selected)} + showSettings={false} /> {/await} </div> diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 5adeb2f00f..bf66018eb6 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -9,15 +9,20 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; import { Theme } from '$lib/constants'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; import { themeManager } from '$lib/managers/theme-manager.svelte'; + import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte'; import { mapSettings } from '$lib/stores/preferences.store'; import { serverConfig } from '$lib/stores/server-config.store'; import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { type MapMarkerResponseDto } from '@immich/sdk'; + import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk'; import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; + import { isEqual, omit } from 'lodash-es'; + import { DateTime, Duration } from 'luxon'; import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl'; + import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { AttributionControl, @@ -36,8 +41,8 @@ } from 'svelte-maplibre'; interface Props { - mapMarkers: MapMarkerResponseDto[]; - showSettingsModal?: boolean | undefined; + mapMarkers?: MapMarkerResponseDto[]; + showSettings?: boolean; zoom?: number | undefined; center?: LngLatLike | undefined; hash?: boolean; @@ -51,8 +56,8 @@ } let { - mapMarkers = $bindable(), - showSettingsModal = $bindable(undefined), + mapMarkers = $bindable([]), + showSettings = true, zoom = undefined, center = $bindable(undefined), hash = false, @@ -67,6 +72,7 @@ let map: maplibregl.Map | undefined = $state(); let marker: maplibregl.Marker | null = null; + let abortController: AbortController; const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT); const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl); @@ -143,6 +149,72 @@ }; }; + function getFileCreatedDates() { + const { relativeDate, dateAfter, dateBefore } = $mapSettings; + + if (relativeDate) { + const duration = Duration.fromISO(relativeDate); + return { + fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined, + }; + } + + try { + return { + fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined, + fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined, + }; + } catch { + $mapSettings.dateAfter = ''; + $mapSettings.dateBefore = ''; + return {}; + } + } + + async function loadMapMarkers() { + if (abortController) { + abortController.abort(); + } + abortController = new AbortController(); + + const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings; + const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates(); + + return await getMapMarkers( + { + isArchived: includeArchived && undefined, + isFavorite: onlyFavorites || undefined, + fileCreatedAfter: fileCreatedAfter || undefined, + fileCreatedBefore, + withPartners: withPartners || undefined, + withSharedAlbums: withSharedAlbums || undefined, + }, + { + signal: abortController.signal, + }, + ); + } + + const handleSettingsClick = async () => { + const settings = await modalManager.show(MapSettingsModal, { settings: { ...$mapSettings } }); + if (settings) { + const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); + $mapSettings = settings; + + if (shouldUpdate) { + mapMarkers = await loadMapMarkers(); + } + } + }; + + onMount(async () => { + mapMarkers = await loadMapMarkers(); + }); + + onDestroy(() => { + abortController.abort(); + }); + $effect(() => { map?.setStyle(styleUrl, { transformStyle: (previousStyle, nextStyle) => { @@ -199,10 +271,10 @@ <AttributionControl compact={false} /> {/if} - {#if showSettingsModal !== undefined} + {#if showSettings} <Control> <ControlGroup> - <ControlButton onclick={() => (showSettingsModal = true)}><Icon path={mdiCog} size="100%" /></ControlButton> + <ControlButton onclick={handleSettingsClick}><Icon path={mdiCog} size="100%" /></ControlButton> </ControlGroup> </Control> {/if} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 53b90798d9..8cf2fb9dfc 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -6,12 +6,13 @@ import { page } from '$app/state'; import { clickOutside } from '$lib/actions/click-outside'; import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; - import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte'; import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import { AppRoute } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import HelpAndFeedbackModal from '$lib/modals/HelpAndFeedbackModal.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; @@ -35,7 +36,6 @@ let { showUploadButton = true, onUploadClick }: Props = $props(); let shouldShowAccountInfoPanel = $state(false); - let shouldShowHelpPanel = $state(false); let shouldShowNotificationPanel = $state(false); let innerWidth: number = $state(0); const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0); @@ -49,10 +49,6 @@ <svelte:window bind:innerWidth /> -{#if shouldShowHelpPanel && info} - <HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} {info} /> -{/if} - <nav id="dashboard-navbar" class="z-auto max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm"> <SkipLink text={$t('skip_to_content')} /> <div @@ -129,18 +125,14 @@ <ThemeButton padding="2" /> - <div - use:clickOutside={{ - onEscape: () => (shouldShowHelpPanel = false), - }} - > + <div> <IconButton shape="round" color="secondary" variant="ghost" size="medium" icon={mdiHelpCircleOutline} - onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)} + onclick={() => info && modalManager.show(HelpAndFeedbackModal, { info })} aria-label={$t('support_and_feedback')} /> </div> diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index fe48a68009..aafb430046 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -28,7 +28,7 @@ const { isPurchased } = purchaseStore; const openPurchaseModal = async () => { - await modalManager.open(PurchaseModal); + await modalManager.show(PurchaseModal, {}); showMessage = false; }; diff --git a/web/src/lib/components/shared-components/side-bar/server-status.svelte b/web/src/lib/components/shared-components/side-bar/server-status.svelte index 665decc44f..0a9f3d9d8c 100644 --- a/web/src/lib/components/shared-components/side-bar/server-status.svelte +++ b/web/src/lib/components/shared-components/side-bar/server-status.svelte @@ -56,7 +56,7 @@ {#if $connected && version} <button type="button" - onclick={() => info && modalManager.open(ServerAboutModal, { versions, info })} + onclick={() => info && modalManager.show(ServerAboutModal, { versions, info })} class="dark:text-immich-gray flex gap-1" > {#if isMain} diff --git a/web/src/lib/components/user-settings-page/PincodeInput.svelte b/web/src/lib/components/user-settings-page/PincodeInput.svelte index 70deef0044..a14e28335f 100644 --- a/web/src/lib/components/user-settings-page/PincodeInput.svelte +++ b/web/src/lib/components/user-settings-page/PincodeInput.svelte @@ -11,10 +11,11 @@ let pinValues = $state(Array.from({ length: pinLength }).fill('')); let pincodeInputElements: HTMLInputElement[] = $state([]); - export function reset() { - pinValues = Array.from({ length: pinLength }).fill(''); - value = ''; - } + $effect(() => { + if (value === '') { + pinValues = Array.from({ length: pinLength }).fill(''); + } + }); const focusNext = (index: number) => { if (index < pinLength - 1) { @@ -55,33 +56,37 @@ const target = event.currentTarget as HTMLInputElement; const index = pincodeInputElements.indexOf(target); - if (event.key === 'Tab') { - return; - } - - if (event.key === 'Backspace') { - if (target.value === '' && index > 0) { - focusPrev(index); - pinValues[index - 1] = ''; - } else if (target.value !== '') { - pinValues[index] = ''; + switch (event.key) { + case 'Tab': { + return; + } + case 'Backspace': { + if (target.value === '' && index > 0) { + focusPrev(index); + pinValues[index - 1] = ''; + } else if (target.value !== '') { + pinValues[index] = ''; + } + return; + } + case 'ArrowLeft': { + if (index > 0) { + focusPrev(index); + } + return; + } + case 'ArrowRight': { + if (index < pinLength - 1) { + focusNext(index); + } + return; + } + default: { + if (!/^\d$/.test(event.key)) { + event.preventDefault(); + } + break; } - - return; - } - - if (event.key === 'ArrowLeft' && index > 0) { - focusPrev(index); - return; - } - if (event.key === 'ArrowRight' && index < pinLength - 1) { - focusNext(index); - return; - } - - if (!/^\d$/.test(event.key) && event.key !== 'Backspace') { - event.preventDefault(); - return; } } </script> diff --git a/web/src/lib/components/user-settings-page/PincodeSettings.svelte b/web/src/lib/components/user-settings-page/PincodeSettings.svelte index b3fd9cf950..fc7f8a5955 100644 --- a/web/src/lib/components/user-settings-page/PincodeSettings.svelte +++ b/web/src/lib/components/user-settings-page/PincodeSettings.svelte @@ -11,7 +11,6 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - let pincodeFormElement = $state<HTMLFormElement | null>(null); let hasPincode = $state(false); let currentPincode = $state(''); let newPincode = $state(''); @@ -99,13 +98,12 @@ currentPincode = ''; newPincode = ''; confirmPincode = ''; - pincodeFormElement?.reset(); }; </script> <section class="my-4"> <div in:fade={{ duration: 200 }}> - <form bind:this={pincodeFormElement} autocomplete="off" onsubmit={onSubmit} class="mt-6"> + <form autocomplete="off" onsubmit={onSubmit} class="mt-6"> <div class="flex flex-col gap-6 place-items-center place-content-center"> {#if hasPincode} <p class="text-dark">Change PIN code</p> @@ -127,7 +125,10 @@ {/if} </div> - <div class="flex justify-end"> + <div class="flex justify-end gap-2 mt-4"> + <Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}> + {$t('clear')} + </Button> <Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}> {hasPincode ? $t('save') : $t('create_pincode')} </Button> diff --git a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte b/web/src/lib/components/user-settings-page/partner-selection-modal.svelte deleted file mode 100644 index 37c6580429..0000000000 --- a/web/src/lib/components/user-settings-page/partner-selection-modal.svelte +++ /dev/null @@ -1,79 +0,0 @@ -<script lang="ts"> - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; - import { getPartners, PartnerDirection, searchUsers, type UserResponseDto } from '@immich/sdk'; - import { onMount } from 'svelte'; - import { t } from 'svelte-i18n'; - import UserAvatar from '../shared-components/user-avatar.svelte'; - import { Button } from '@immich/ui'; - - interface Props { - user: UserResponseDto; - onClose: () => void; - onAddUsers: (users: UserResponseDto[]) => void; - } - - let { user, onClose, onAddUsers }: Props = $props(); - - let availableUsers: UserResponseDto[] = $state([]); - let selectedUsers: UserResponseDto[] = $state([]); - - onMount(async () => { - let users = await searchUsers(); - - // remove current user - users = users.filter((_user) => _user.id !== user.id); - - // exclude partners from the list of users available for selection - const partners = await getPartners({ direction: PartnerDirection.SharedBy }); - const partnerIds = new Set(partners.map((partner) => partner.id)); - availableUsers = users.filter((user) => !partnerIds.has(user.id)); - }); - - const selectUser = (user: UserResponseDto) => { - selectedUsers = selectedUsers.includes(user) - ? selectedUsers.filter((selectedUser) => selectedUser.id !== user.id) - : [...selectedUsers, user]; - }; -</script> - -<FullScreenModal title={$t('add_partner')} showLogo {onClose}> - <div class="immich-scrollbar max-h-[300px] overflow-y-auto"> - {#if availableUsers.length > 0} - {#each availableUsers as user (user.id)} - <button - type="button" - onclick={() => selectUser(user)} - class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" - > - {#if selectedUsers.includes(user)} - <span - class="flex h-12 w-12 place-content-center place-items-center rounded-full border bg-immich-primary text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-primary dark:text-immich-dark-bg" - >✓</span - > - {:else} - <UserAvatar {user} size="lg" /> - {/if} - - <div class="text-start"> - <p class="text-immich-fg dark:text-immich-dark-fg"> - {user.name} - </p> - <p class="text-xs"> - {user.email} - </p> - </div> - </button> - {/each} - {:else} - <p class="py-5 text-sm"> - {$t('photo_shared_all_users')} - </p> - {/if} - - {#if selectedUsers.length > 0} - <div class="pt-5"> - <Button shape="round" fullWidth onclick={() => onAddUsers(selectedUsers)}>{$t('add')}</Button> - </div> - {/if} - </div> -</FullScreenModal> diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index b2238b84e2..b18390e28f 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -2,6 +2,8 @@ import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import PartnerSelectionModal from '$lib/modals/PartnerSelectionModal.svelte'; import { createPartner, getPartners, @@ -18,7 +20,6 @@ import { handleError } from '../../utils/handle-error'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import Icon from '../elements/icon.svelte'; - import PartnerSelectionModal from './partner-selection-modal.svelte'; interface PartnerSharing { user: UserResponseDto; @@ -33,8 +34,6 @@ let { user }: Props = $props(); - let createPartnerFlag = $state(false); - // let removePartnerDto: PartnerResponseDto | null = null; let partners: Array<PartnerSharing> = $state([]); onMount(async () => { @@ -99,14 +98,19 @@ } }; - const handleCreatePartners = async (users: UserResponseDto[]) => { + const handleCreatePartners = async () => { + const users = await modalManager.show(PartnerSelectionModal, { user }); + + if (!users) { + return; + } + try { for (const user of users) { await createPartner({ id: user.id }); } await refreshPartners(); - createPartnerFlag = false; } catch (error) { handleError(error, $t('errors.unable_to_add_partners')); } @@ -189,10 +193,6 @@ {/if} <div class="flex justify-end mt-5"> - <Button shape="round" size="small" onclick={() => (createPartnerFlag = true)}>{$t('add_partner')}</Button> + <Button shape="round" size="small" onclick={() => handleCreatePartners()}>{$t('add_partner')}</Button> </div> </section> - -{#if createPartnerFlag} - <PartnerSelectionModal {user} onClose={() => (createPartnerFlag = false)} onAddUsers={handleCreatePartners} /> -{/if} diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 6fbc28a776..ab9ffb294c 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -1,6 +1,9 @@ <script lang="ts"> import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { dialogController } from '$lib/components/shared-components/dialog/dialog'; + import { modalManager } from '$lib/managers/modal-manager.svelte'; + import ApiKeyModal from '$lib/modals/ApiKeyModal.svelte'; + import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte'; import { locale } from '$lib/stores/preferences.store'; import { createApiKey, @@ -15,8 +18,6 @@ import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; - import APIKeyForm from '../forms/api-key-form.svelte'; - import APIKeySecret from '../forms/api-key-secret.svelte'; import { notificationController, NotificationType } from '../shared-components/notification/notification'; interface Props { @@ -25,10 +26,6 @@ let { keys = $bindable() }: Props = $props(); - let newKey: { name: string } | null = $state(null); - let editKey: ApiKeyResponseDto | null = $state(null); - let secret = $state(''); - const format: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', @@ -39,30 +36,46 @@ keys = await getApiKeys(); } - const handleCreate = async ({ name }: { name: string }) => { - try { - const data = await createApiKey({ - apiKeyCreateDto: { - name, - permissions: [Permission.All], - }, - }); - secret = data.secret; - } catch (error) { - handleError(error, $t('errors.unable_to_create_api_key')); - } finally { - await refreshKeys(); - newKey = null; - } - }; + const handleCreate = async () => { + const result = await modalManager.show(ApiKeyModal, { + title: $t('new_api_key'), + apiKey: { name: 'API Key' }, + submitText: $t('create'), + }); - const handleUpdate = async (detail: Partial<ApiKeyResponseDto>) => { - if (!editKey || !detail.name) { + if (!result) { return; } try { - await updateApiKey({ id: editKey.id, apiKeyUpdateDto: { name: detail.name } }); + const { secret } = await createApiKey({ + apiKeyCreateDto: { + name: result.name, + permissions: [Permission.All], + }, + }); + + await modalManager.show(ApiKeySecretModal, { secret }); + } catch (error) { + handleError(error, $t('errors.unable_to_create_api_key')); + } finally { + await refreshKeys(); + } + }; + + const handleUpdate = async (key: ApiKeyResponseDto) => { + const result = await modalManager.show(ApiKeyModal, { + title: $t('api_key'), + submitText: $t('save'), + apiKey: key, + }); + + if (!result) { + return; + } + + try { + await updateApiKey({ id: key.id, apiKeyUpdateDto: { name: result.name } }); notificationController.show({ message: $t('saved_api_key'), type: NotificationType.Info, @@ -71,7 +84,6 @@ handleError(error, $t('errors.unable_to_save_api_key')); } finally { await refreshKeys(); - editKey = null; } }; @@ -95,34 +107,10 @@ }; </script> -{#if newKey} - <APIKeyForm - title={$t('new_api_key')} - submitText={$t('create')} - apiKey={newKey} - onSubmit={(key) => handleCreate(key)} - onCancel={() => (newKey = null)} - /> -{/if} - -{#if secret} - <APIKeySecret {secret} onDone={() => (secret = '')} /> -{/if} - -{#if editKey} - <APIKeyForm - title={$t('api_key')} - submitText={$t('save')} - apiKey={editKey} - onSubmit={(key) => handleUpdate(key)} - onCancel={() => (editKey = null)} - /> -{/if} - <section class="my-4"> <div class="flex flex-col gap-2" in:fade={{ duration: 500 }}> <div class="mb-2 flex justify-end"> - <Button shape="round" size="small" onclick={() => (newKey = { name: $t('api_key') })}>{$t('new_api_key')}</Button> + <Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button> </div> {#if keys.length > 0} @@ -153,7 +141,7 @@ icon={mdiPencilOutline} title={$t('edit_key')} size="16" - onclick={() => (editKey = key)} + onclick={() => handleUpdate(key)} /> <CircleIconButton color="primary" diff --git a/web/src/lib/managers/modal-manager.svelte.ts b/web/src/lib/managers/modal-manager.svelte.ts index 055df14502..12f9224018 100644 --- a/web/src/lib/managers/modal-manager.svelte.ts +++ b/web/src/lib/managers/modal-manager.svelte.ts @@ -1,19 +1,20 @@ import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import { mount, unmount, type Component, type ComponentProps } from 'svelte'; -type OnCloseData<T> = T extends { onClose: (data: infer R) => void | Promise<void> } ? R : never; +type OnCloseData<T> = T extends { onClose: (data?: infer R) => void } ? R : never; +type ExtendsEmptyObject<T> = keyof T extends never ? Record<string, never> : T; class ModalManager { - open<T = { onClose: (data: unknown) => void }, K = OnCloseData<T>>( - Component: Component<{ onClose: T }>, - props?: Record<string, never>, - ): Promise<K>; - open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: Omit<T, 'onClose'>): Promise<K>; - open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props?: Omit<T, 'onClose'>) { - return new Promise<K>((resolve) => { - let modal: object = {}; + show<T extends object>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) { + return this.open(Component, props).onClose; + } - const onClose = async (data: K) => { + open<T extends object, K = OnCloseData<T>>(Component: Component<T>, props: ExtendsEmptyObject<Omit<T, 'onClose'>>) { + let modal: object = {}; + let onClose: () => Promise<void>; + + const deferred = new Promise<K | undefined>((resolve) => { + onClose = async (data?: K) => { await unmount(modal); resolve(data); }; @@ -21,15 +22,20 @@ class ModalManager { modal = mount(Component, { target: document.body, props: { - ...((props ?? {}) as T), + ...(props as T), onClose, }, }); }); + + return { + onClose: deferred, + close: () => onClose(), + }; } openDialog(options: Omit<ComponentProps<typeof ConfirmDialog>, 'onClose'>) { - return this.open(ConfirmDialog, options); + return this.show(ConfirmDialog, options); } } diff --git a/web/src/lib/modals/ApiKeyModal.svelte b/web/src/lib/modals/ApiKeyModal.svelte new file mode 100644 index 0000000000..f5e1fb2a7e --- /dev/null +++ b/web/src/lib/modals/ApiKeyModal.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; + import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; + import { mdiKeyVariant } from '@mdi/js'; + import { t } from 'svelte-i18n'; + + interface Props { + apiKey: { name: string }; + title: string; + cancelText?: string; + submitText?: string; + onClose: (apiKey?: { name: string }) => void; + } + + let { apiKey = $bindable(), title, cancelText = $t('cancel'), submitText = $t('save'), onClose }: Props = $props(); + + const handleSubmit = () => { + if (apiKey.name) { + onClose({ name: apiKey.name }); + } else { + notificationController.show({ + message: $t('api_key_empty'), + type: NotificationType.Warning, + }); + } + }; + + const onsubmit = (event: Event) => { + event.preventDefault(); + handleSubmit(); + }; +</script> + +<Modal {title} icon={mdiKeyVariant} {onClose} size="small"> + <ModalBody> + <form {onsubmit} autocomplete="off" id="api-key-form"> + <div class="mb-4 flex flex-col gap-2"> + <label class="immich-form-label" for="name">{$t('name')}</label> + <input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} /> + </div> + </form> + </ModalBody> + + <ModalFooter> + <div class="flex gap-3 w-full"> + <Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{cancelText}</Button> + <Button shape="round" type="submit" fullWidth form="api-key-form">{submitText}</Button> + </div> + </ModalFooter> +</Modal> diff --git a/web/src/lib/modals/ApiKeySecretModal.svelte b/web/src/lib/modals/ApiKeySecretModal.svelte new file mode 100644 index 0000000000..88d34341d9 --- /dev/null +++ b/web/src/lib/modals/ApiKeySecretModal.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import { copyToClipboard } from '$lib/utils'; + import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; + import { mdiKeyVariant } from '@mdi/js'; + import { t } from 'svelte-i18n'; + + interface Props { + secret?: string; + onClose: () => void; + } + + let { secret = '', onClose }: Props = $props(); +</script> + +<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small"> + <ModalBody> + <div class="text-immich-primary dark:text-immich-dark-primary"> + <p class="text-sm dark:text-immich-dark-fg"> + {$t('api_key_description')} + </p> + </div> + + <div class="my-4 flex flex-col gap-2"> + <!-- <label class="immich-form-label" for="secret">{ $t("api_key") }</label> --> + <textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret}></textarea> + </div> + </ModalBody> + + <ModalFooter> + <div class="flex gap-3 w-full"> + <Button shape="round" onclick={() => copyToClipboard(secret)} fullWidth>{$t('copy_to_clipboard')}</Button> + <Button shape="round" onclick={onClose} fullWidth>{$t('done')}</Button> + </div> + </ModalFooter> +</Modal> diff --git a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte b/web/src/lib/modals/HelpAndFeedbackModal.svelte similarity index 94% rename from web/src/lib/components/shared-components/help-and-feedback-modal.svelte rename to web/src/lib/modals/HelpAndFeedbackModal.svelte index 1dcb021d78..edc78b3bf4 100644 --- a/web/src/lib/components/shared-components/help-and-feedback-modal.svelte +++ b/web/src/lib/modals/HelpAndFeedbackModal.svelte @@ -1,11 +1,10 @@ <script lang="ts"> - import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; - import Portal from '$lib/components/shared-components/portal/portal.svelte'; - import { type ServerAboutResponseDto } from '@immich/sdk'; - import { t } from 'svelte-i18n'; - import Icon from '$lib/components/elements/icon.svelte'; - import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js'; import { discordPath, discordViewBox } from '$lib/assets/svg-paths'; + import Icon from '$lib/components/elements/icon.svelte'; + import { type ServerAboutResponseDto } from '@immich/sdk'; + import { Modal, ModalBody } from '@immich/ui'; + import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js'; + import { t } from 'svelte-i18n'; interface Props { onClose: () => void; @@ -15,8 +14,8 @@ let { onClose, info }: Props = $props(); </script> -<Portal> - <FullScreenModal title={$t('support_and_feedback')} {onClose}> +<Modal title={$t('support_and_feedback')} {onClose} size="small"> + <ModalBody> <p>{$t('official_immich_resources')}</p> <div class="flex flex-col sm:grid sm:grid-cols-2 gap-2 mt-5"> <div> @@ -130,5 +129,5 @@ {/if} </div> {/if} - </FullScreenModal> -</Portal> + </ModalBody> +</Modal> diff --git a/web/src/lib/modals/MapSettingsModal.svelte b/web/src/lib/modals/MapSettingsModal.svelte new file mode 100644 index 0000000000..e7bef2ecaf --- /dev/null +++ b/web/src/lib/modals/MapSettingsModal.svelte @@ -0,0 +1,135 @@ +<script lang="ts"> + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; + import type { MapSettings } from '$lib/stores/preferences.store'; + import { Button, Field, Modal, ModalBody, ModalFooter, Stack, Switch } from '@immich/ui'; + import { Duration } from 'luxon'; + import { t } from 'svelte-i18n'; + import { fly } from 'svelte/transition'; + import DateInput from '../components/elements/date-input.svelte'; + + interface Props { + settings: MapSettings; + onClose: (settings?: MapSettings) => void; + } + + let { settings: initialValues, onClose }: Props = $props(); + let settings = $state(initialValues); + + let customDateRange = $state(!!settings.dateAfter || !!settings.dateBefore); + + const onsubmit = (event: Event) => { + event.preventDefault(); + onClose(settings); + }; +</script> + +<Modal title={$t('map_settings')} {onClose} size="small"> + <ModalBody> + <form {onsubmit} id="map-settings-form"> + <Stack gap={4}> + <Field label={$t('allow_dark_mode')}> + <Switch bind:checked={settings.allowDarkMode} /> + </Field> + <Field label={$t('only_favorites')}> + <Switch bind:checked={settings.onlyFavorites} /> + </Field> + <Field label={$t('include_archived')}> + <Switch bind:checked={settings.includeArchived} /> + </Field> + <Field label={$t('include_shared_partner_assets')}> + <Switch bind:checked={settings.withPartners} /> + </Field> + <Field label={$t('include_shared_albums')}> + <Switch bind:checked={settings.withSharedAlbums} /> + </Field> + + {#if customDateRange} + <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4"> + <div class="flex items-center justify-between gap-8"> + <label class="immich-form-label shrink-0 text-sm" for="date-after">{$t('date_after')}</label> + <DateInput + class="immich-form-input w-40" + type="date" + id="date-after" + max={settings.dateBefore} + bind:value={settings.dateAfter} + /> + </div> + <div class="flex items-center justify-between gap-8"> + <label class="immich-form-label shrink-0 text-sm" for="date-before">{$t('date_before')}</label> + <DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} /> + </div> + <div class="flex justify-center text-xs"> + <Button + color="primary" + size="small" + variant="ghost" + onclick={() => { + customDateRange = false; + settings.dateAfter = ''; + settings.dateBefore = ''; + }} + > + {$t('remove_custom_date_range')} + </Button> + </div> + </div> + {:else} + <div in:fly={{ y: -10, duration: 200 }} class="flex flex-col gap-1"> + <SettingSelect + label={$t('date_range')} + name="date-range" + bind:value={settings.relativeDate} + options={[ + { + value: '', + text: $t('all'), + }, + { + value: Duration.fromObject({ hours: 24 }).toISO() || '', + text: $t('past_durations.hours', { values: { hours: 24 } }), + }, + { + value: Duration.fromObject({ days: 7 }).toISO() || '', + text: $t('past_durations.days', { values: { days: 7 } }), + }, + { + value: Duration.fromObject({ days: 30 }).toISO() || '', + text: $t('past_durations.days', { values: { days: 30 } }), + }, + { + value: Duration.fromObject({ years: 1 }).toISO() || '', + text: $t('past_durations.years', { values: { years: 1 } }), + }, + { + value: Duration.fromObject({ years: 3 }).toISO() || '', + text: $t('past_durations.years', { values: { years: 3 } }), + }, + ]} + /> + <div class="text-xs"> + <Button + color="primary" + size="small" + variant="ghost" + onclick={() => { + customDateRange = true; + settings.relativeDate = ''; + }} + > + {$t('use_custom_date_range')} + </Button> + </div> + </div> + {/if} + </Stack> + </form> + </ModalBody> + + <ModalFooter> + <div class="flex gap-3 w-full"> + <Button color="secondary" shape="round" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button> + <Button type="submit" shape="round" fullWidth form="map-settings-form">{$t('save')}</Button> + </div> + </ModalFooter> +</Modal> diff --git a/web/src/lib/modals/PartnerSelectionModal.svelte b/web/src/lib/modals/PartnerSelectionModal.svelte new file mode 100644 index 0000000000..729a035ef1 --- /dev/null +++ b/web/src/lib/modals/PartnerSelectionModal.svelte @@ -0,0 +1,79 @@ +<script lang="ts"> + import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; + import { getPartners, PartnerDirection, searchUsers, type UserResponseDto } from '@immich/sdk'; + import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; + import { onMount } from 'svelte'; + import { t } from 'svelte-i18n'; + + interface Props { + user: UserResponseDto; + onClose: (users?: UserResponseDto[]) => void; + } + + let { user, onClose }: Props = $props(); + + let availableUsers: UserResponseDto[] = $state([]); + let selectedUsers: UserResponseDto[] = $state([]); + + onMount(async () => { + let users = await searchUsers(); + + // remove current user + users = users.filter((_user) => _user.id !== user.id); + + // exclude partners from the list of users available for selection + const partners = await getPartners({ direction: PartnerDirection.SharedBy }); + const partnerIds = new Set(partners.map((partner) => partner.id)); + availableUsers = users.filter((user) => !partnerIds.has(user.id)); + }); + + const selectUser = (user: UserResponseDto) => { + selectedUsers = selectedUsers.includes(user) + ? selectedUsers.filter((selectedUser) => selectedUser.id !== user.id) + : [...selectedUsers, user]; + }; +</script> + +<Modal title={$t('add_partner')} {onClose} size="small"> + <ModalBody> + <div class="immich-scrollbar max-h-[300px] overflow-y-auto"> + {#if availableUsers.length > 0} + {#each availableUsers as user (user.id)} + <button + type="button" + onclick={() => selectUser(user)} + class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl" + > + {#if selectedUsers.includes(user)} + <span + class="flex h-12 w-12 place-content-center place-items-center rounded-full border bg-immich-primary text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-primary dark:text-immich-dark-bg" + >✓</span + > + {:else} + <UserAvatar {user} size="lg" /> + {/if} + + <div class="text-start"> + <p class="text-immich-fg dark:text-immich-dark-fg"> + {user.name} + </p> + <p class="text-xs"> + {user.email} + </p> + </div> + </button> + {/each} + {:else} + <p class="py-5 text-sm"> + {$t('photo_shared_all_users')} + </p> + {/if} + + <ModalFooter> + {#if selectedUsers.length > 0} + <Button shape="round" fullWidth onclick={() => onClose(selectedUsers)}>{$t('add')}</Button> + {/if} + </ModalFooter> + </div> + </ModalBody> +</Modal> diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index 9981aed3ad..7f5ac89681 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -4,16 +4,19 @@ import { userInteraction } from '$lib/stores/user.svelte'; import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; - import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; + import { resetPincode, updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui'; - import { mdiAccountEditOutline } from '@mdi/js'; + import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { user: UserAdminResponseDto; canResetPassword?: boolean; onClose: ( - data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string }, + data?: + | { action: 'update'; data: UserAdminResponseDto } + | { action: 'resetPassword'; data: string } + | { action: 'resetPincode' }, ) => void; } @@ -76,6 +79,28 @@ } }; + const resetUserPincode = async () => { + const isConfirmed = await modalManager.openDialog({ + prompt: $t('admin.confirm_user_pincode_reset', { values: { user: user.name } }), + }); + + if (!isConfirmed) { + return; + } + + try { + await resetPincode({ + resetPincodeDto: { + userId: user.id, + }, + }); + + onClose({ action: 'resetPincode' }); + } catch (error) { + handleError(error, $t('errors.unable_to_reset_pincode')); + } + }; + // TODO move password reset server-side function generatePassword(length: number = 16) { let generatedPassword = ''; @@ -151,13 +176,34 @@ </ModalBody> <ModalFooter> - <div class="flex gap-3 w-full"> - {#if canResetPassword} - <Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword} - >{$t('reset_password')}</Button + <div class="w-full"> + <div class="flex gap-3 w-full"> + {#if canResetPassword} + <Button + shape="round" + color="warning" + variant="filled" + fullWidth + onclick={resetPassword} + leadingIcon={mdiOnepassword} + > + {$t('reset_password')}</Button + > + {/if} + + <Button + shape="round" + color="warning" + variant="filled" + fullWidth + onclick={resetUserPincode} + leadingIcon={mdiLockSmart}>{$t('reset_pincode')}</Button > - {/if} - <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> + </div> + + <div class="w-full mt-4"> + <Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button> + </div> </div> </ModalFooter> </Modal> diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 0a71f35ff2..b1fff3a0cd 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -3,21 +3,15 @@ import { goto } from '$app/navigation'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; - import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte'; import Map from '$lib/components/shared-components/map/map.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte'; import { AppRoute } from '$lib/constants'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { MapSettings } from '$lib/stores/preferences.store'; - import { mapSettings } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; - import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk'; - import { isEqual } from 'lodash-es'; - import { DateTime, Duration } from 'luxon'; - import { onDestroy, onMount } from 'svelte'; - import type { PageData } from './$types'; import { handlePromiseError } from '$lib/utils'; import { navigate } from '$lib/utils/navigation'; + import { onDestroy } from 'svelte'; + import type { PageData } from './$types'; interface Props { data: PageData; @@ -27,18 +21,10 @@ let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore; - let abortController: AbortController; - let mapMarkers: MapMarkerResponseDto[] = $state([]); let viewingAssets: string[] = $state([]); let viewingAssetCursor = 0; - let showSettingsModal = $state(false); - - onMount(async () => { - mapMarkers = await loadMapMarkers(); - }); onDestroy(() => { - abortController?.abort(); assetViewingStore.showAssetViewer(false); }); @@ -47,55 +33,6 @@ handlePromiseError(goto(AppRoute.PHOTOS)); } }); - const omit = (obj: MapSettings, key: string) => { - return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key)); - }; - - async function loadMapMarkers() { - if (abortController) { - abortController.abort(); - } - abortController = new AbortController(); - - const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings; - const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates(); - - return await getMapMarkers( - { - isArchived: includeArchived && undefined, - isFavorite: onlyFavorites || undefined, - fileCreatedAfter: fileCreatedAfter || undefined, - fileCreatedBefore, - withPartners: withPartners || undefined, - withSharedAlbums: withSharedAlbums || undefined, - }, - { - signal: abortController.signal, - }, - ); - } - - function getFileCreatedDates() { - const { relativeDate, dateAfter, dateBefore } = $mapSettings; - - if (relativeDate) { - const duration = Duration.fromISO(relativeDate); - return { - fileCreatedAfter: duration.isValid ? DateTime.now().minus(duration).toISO() : undefined, - }; - } - - try { - return { - fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined, - fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined, - }; - } catch { - $mapSettings.dateAfter = ''; - $mapSettings.dateBefore = ''; - return {}; - } - } async function onViewAssets(assetIds: string[]) { viewingAssets = assetIds; @@ -135,7 +72,7 @@ {#if $featureFlags.loaded && $featureFlags.map} <UserPageLayout title={data.meta.title}> <div class="isolate h-full w-full"> - <Map hash bind:mapMarkers bind:showSettingsModal onSelect={onViewAssets} /> + <Map hash onSelect={onViewAssets} /> </div> </UserPageLayout> <Portal target="body"> @@ -156,20 +93,4 @@ {/await} {/if} </Portal> - - {#if showSettingsModal} - <MapSettingsModal - settings={{ ...$mapSettings }} - onClose={() => (showSettingsModal = false)} - onSave={async (settings) => { - const shouldUpdate = !isEqual(omit(settings, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode')); - showSettingsModal = false; - $mapSettings = settings; - - if (shouldUpdate) { - mapMarkers = await loadMapMarkers(); - } - }} - /> - {/if} {/if} diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index c5327a4271..6636d748cf 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -39,7 +39,7 @@ <HStack gap={0}> <Button leadingIcon={mdiPlus} - onclick={() => modalManager.open(JobCreateModal)} + onclick={() => modalManager.show(JobCreateModal, {})} size="small" variant="ghost" color="secondary" diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 8ac3793046..bfe12f0a48 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -59,33 +59,40 @@ }; const handleCreate = async () => { - await modalManager.open(UserCreateModal); + await modalManager.show(UserCreateModal, {}); await refresh(); }; const handleEdit = async (dto: UserAdminResponseDto) => { - const result = await modalManager.open(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id }); + const result = await modalManager.show(UserEditModal, { user: dto, canResetPassword: dto.id !== $user.id }); switch (result?.action) { case 'resetPassword': { - await modalManager.open(PasswordResetSuccess, { newPassword: result.data }); + await modalManager.show(PasswordResetSuccess, { newPassword: result.data }); break; } case 'update': { await refresh(); break; } + case 'resetPincode': { + notificationController.show({ + type: NotificationType.Info, + message: $t('pincode_reset_successfully'), + }); + break; + } } }; const handleDelete = async (user: UserAdminResponseDto) => { - const result = await modalManager.open(UserDeleteConfirmModal, { user }); + const result = await modalManager.show(UserDeleteConfirmModal, { user }); if (result) { await refresh(); } }; const handleRestore = async (user: UserAdminResponseDto) => { - const result = await modalManager.open(UserRestoreConfirmModal, { user }); + const result = await modalManager.show(UserRestoreConfirmModal, { user }); if (result) { await refresh(); } @@ -137,7 +144,7 @@ {#if !immichUser.deletedAt} <IconButton shape="round" - size="small" + size="medium" icon={mdiPencilOutline} title={$t('edit_user')} onclick={() => handleEdit(immichUser)} @@ -146,7 +153,7 @@ {#if immichUser.id !== $user.id} <IconButton shape="round" - size="small" + size="medium" icon={mdiTrashCanOutline} title={$t('delete_user')} onclick={() => handleDelete(immichUser)}