diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 354176f121..9835dc13da 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -262,5 +262,11 @@ "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", - "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "description_input_submit_error": "Error updating description, check the log for more details", + "description_input_hint_text": "Add description..." } \ No newline at end of file diff --git a/mobile/flutter_01.png b/mobile/flutter_01.png new file mode 100644 index 0000000000..8fd3f8814a Binary files /dev/null and b/mobile/flutter_01.png differ diff --git a/mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart new file mode 100644 index 0000000000..9ac68761ae --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_description.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +class AssetDescriptionNotifier extends StateNotifier { + final Isar _db; + final AssetDescriptionService _service; + final Asset _asset; + + AssetDescriptionNotifier( + this._db, + this._service, + this._asset, + ) : super('') { + _fetchLocalDescription(); + _fetchRemoteDescription(); + } + + String get description => state; + + /// Fetches the local database value for description + /// and writes it to [state] + void _fetchLocalDescription() async { + final localExifId = _asset.exifInfo?.id; + + // Guard [localExifId] null + if (localExifId == null) { + return; + } + + // Subscribe to local changes + final exifInfo = await _db + .exifInfos + .get(localExifId); + + // Guard + if (exifInfo?.description == null) { + return; + } + + state = exifInfo!.description!; + } + + /// Fetches the remote value and sets the state + void _fetchRemoteDescription() async { + final remoteAssetId = _asset.remoteId; + final localExifId = _asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + // Reads the latest from the remote and writes it to DB in the service + final latest = await _service.readLatest(remoteAssetId, localExifId); + + state = latest; + } + + /// Sets the description to [description] + /// Uses the service to set the asset value + Future setDescription(String description) async { + state = description; + + final remoteAssetId = _asset.remoteId; + final localExifId = _asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + return _service + .setDescription(description, remoteAssetId, localExifId); + } +} + +final assetDescriptionProvider = StateNotifierProvider + .autoDispose + .family( + (ref, asset) => AssetDescriptionNotifier( + ref.watch(dbProvider), + ref.watch(assetDescriptionServiceProvider), + asset, + ), +); + + diff --git a/mobile/lib/modules/asset_viewer/services/asset_description.service.dart b/mobile/lib/modules/asset_viewer/services/asset_description.service.dart new file mode 100644 index 0000000000..9abf69a93a --- /dev/null +++ b/mobile/lib/modules/asset_viewer/services/asset_description.service.dart @@ -0,0 +1,62 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:isar/isar.dart'; +import 'package:openapi/api.dart'; + +class AssetDescriptionService { + AssetDescriptionService(this._db, this._api); + + final Isar _db; + final ApiService _api; + + setDescription( + String description, + String remoteAssetId, + int localExifId, + ) async { + final result = await _api.assetApi.updateAsset( + remoteAssetId, + UpdateAssetDto(description: description), + ); + + if (result?.exifInfo?.description != null) { + var exifInfo = await _db.exifInfos.get(localExifId); + + if (exifInfo != null) { + exifInfo.description = result!.exifInfo!.description; + await _db.writeTxn( + () => _db.exifInfos.put(exifInfo), + ); + } + } + } + + Future readLatest(String assetRemoteId, int localExifId) async { + final latestAssetFromServer = + await _api.assetApi.getAssetById(assetRemoteId); + final localExifInfo = await _db.exifInfos.get(localExifId); + + if (latestAssetFromServer != null && localExifInfo != null) { + localExifInfo.description = + latestAssetFromServer.exifInfo?.description ?? ''; + + await _db.writeTxn( + () => _db.exifInfos.put(localExifInfo), + ); + + return localExifInfo.description!; + } + + return ""; + } +} + +final assetDescriptionServiceProvider = Provider( + (ref) => AssetDescriptionService( + ref.watch(dbProvider), + ref.watch(apiServiceProvider), + ), +); diff --git a/mobile/lib/modules/asset_viewer/ui/description_input.dart b/mobile/lib/modules/asset_viewer/ui/description_input.dart new file mode 100644 index 0000000000..9b54633f21 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/description_input.dart @@ -0,0 +1,103 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:logging/logging.dart'; +import 'package:immich_mobile/shared/models/store.dart' as store; + +class DescriptionInput extends HookConsumerWidget { + DescriptionInput({ + super.key, + required this.asset, + }); + + final Asset asset; + final Logger _log = Logger('DescriptionInput'); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + final textColor = isDarkTheme ? Colors.white : Colors.black; + final controller = useTextEditingController(); + final focusNode = useFocusNode(); + final isFocus = useState(false); + final isTextEmpty = useState(controller.text.isEmpty); + final descriptionProvider = ref.watch(assetDescriptionProvider(asset).notifier); + final description = ref.watch(assetDescriptionProvider(asset)); + final owner = store.Store.get(store.StoreKey.currentUser); + final hasError = useState(false); + + controller.text = description; + + submitDescription(String description) async { + hasError.value = false; + try { + await descriptionProvider.setDescription( + description, + ); + } catch (error, stack) { + hasError.value = true; + _log.severe("Error updating description $error", error, stack); + ImmichToast.show( + context: context, + msg: "description_input_submit_error".tr(), + toastType: ToastType.error, + ); + } + } + + Widget? suffixIcon; + if (hasError.value) { + suffixIcon = const Icon(Icons.warning_outlined); + } else if (!isTextEmpty.value && isFocus.value) { + suffixIcon = IconButton( + onPressed: () { + controller.clear(); + isTextEmpty.value = true; + }, + icon: Icon( + Icons.cancel_rounded, + color: Colors.grey[500], + ), + splashRadius: 10, + ); + } + + return TextField( + enabled: owner.isarId == asset.ownerId, + focusNode: focusNode, + onTap: () => isFocus.value = true, + onChanged: (value) { + isTextEmpty.value = false; + }, + onTapOutside: (a) async { + isFocus.value = false; + focusNode.unfocus(); + + if (description != controller.text) { + await submitDescription(controller.text); + } + }, + autofocus: false, + maxLines: null, + keyboardType: TextInputType.multiline, + controller: controller, + style: const TextStyle( + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'description_input_hint_text'.tr(), + border: InputBorder.none, + hintStyle: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 12, + color: textColor.withOpacity(0.5), + ), + suffixIcon: suffixIcon, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index cd18b2fa48..428ea69759 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -2,25 +2,25 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:latlong2/latlong.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; class ExifBottomSheet extends HookConsumerWidget { - final Asset assetDetail; + final Asset asset; - const ExifBottomSheet({Key? key, required this.assetDetail}) - : super(key: key); + const ExifBottomSheet({Key? key, required this.asset}) : super(key: key); bool get showMap => - assetDetail.exifInfo?.latitude != null && - assetDetail.exifInfo?.longitude != null; + asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null; @override Widget build(BuildContext context, WidgetRef ref) { - final ExifInfo? exifInfo = assetDetail.exifInfo; + final exifInfo = asset.exifInfo; + var isDarkTheme = Theme.of(context).brightness == Brightness.dark; + var textColor = isDarkTheme ? Colors.white : Colors.black; buildMap() { return Padding( @@ -76,19 +76,6 @@ class ExifBottomSheet extends HookConsumerWidget { ); } - final textColor = Theme.of(context).primaryColor; - - buildLocationText() { - return Text( - "${exifInfo?.city}, ${exifInfo?.state}", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: textColor, - ), - ); - } - buildSizeText(Asset a) { String resolution = a.width != null && a.height != null ? "${a.height} x ${a.width} " @@ -128,13 +115,39 @@ class ExifBottomSheet extends HookConsumerWidget { children: [ Text( "exif_bottom_sheet_location", - style: TextStyle(fontSize: 11, color: textColor), + style: TextStyle( + fontSize: 11, + color: textColor, + fontWeight: FontWeight.bold, + ), ).tr(), buildMap(), - if (exifInfo != null && - exifInfo.city != null && - exifInfo.state != null) - buildLocationText(), + RichText( + text: TextSpan( + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: textColor, + fontFamily: 'WorkSans', + ), + children: [ + if (exifInfo != null && exifInfo.city != null) + TextSpan( + text: exifInfo.city, + ), + if (exifInfo != null && + exifInfo.city != null && + exifInfo.state != null) + const TextSpan( + text: ", ", + ), + if (exifInfo != null && exifInfo.state != null) + TextSpan( + text: "${exifInfo.state}", + ), + ], + ), + ), Text( "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}", style: const TextStyle(fontSize: 12), @@ -146,7 +159,7 @@ class ExifBottomSheet extends HookConsumerWidget { } buildDate() { - final fileCreatedAt = assetDetail.fileCreatedAt.toLocal(); + final fileCreatedAt = asset.fileCreatedAt.toLocal(); final date = DateFormat.yMMMEd().format(fileCreatedAt); final time = DateFormat.jm().format(fileCreatedAt); @@ -167,27 +180,37 @@ class ExifBottomSheet extends HookConsumerWidget { padding: const EdgeInsets.only(bottom: 8.0), child: Text( "exif_bottom_sheet_details", - style: TextStyle(fontSize: 11, color: textColor), + style: TextStyle( + fontSize: 11, + color: textColor, + fontWeight: FontWeight.bold, + ), ).tr(), ), ListTile( contentPadding: const EdgeInsets.all(0), dense: true, - leading: const Icon(Icons.image), + leading: Icon( + Icons.image, + color: textColor.withAlpha(200), + ), title: Text( - assetDetail.fileName, + asset.fileName, style: TextStyle( fontWeight: FontWeight.bold, color: textColor, ), ), - subtitle: buildSizeText(assetDetail), + subtitle: buildSizeText(asset), ), if (exifInfo?.make != null) ListTile( contentPadding: const EdgeInsets.all(0), dense: true, - leading: const Icon(Icons.camera), + leading: Icon( + Icons.camera, + color: textColor.withAlpha(200), + ), title: Text( "${exifInfo!.make} ${exifInfo.model}", style: TextStyle( @@ -203,80 +226,75 @@ class ExifBottomSheet extends HookConsumerWidget { ); } - return SingleChildScrollView( - child: Card( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(15), - topRight: Radius.circular(15), + return GestureDetector( + onTap: () { + // FocusScope.of(context).unfocus(); + }, + child: SingleChildScrollView( + child: Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), ), - ), - margin: const EdgeInsets.all(0), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8.0), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > 600) { - // Two column - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDragHeader(), - buildDate(), - const SizedBox(height: 32.0), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - flex: showMap ? 5 : 0, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: buildLocation(), + margin: const EdgeInsets.all(0), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + // Two column + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildDragHeader(), + buildDate(), + if (asset.isRemote) DescriptionInput(asset: asset), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: showMap ? 5 : 0, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: buildLocation(), + ), ), - ), - Flexible( - flex: 5, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: buildDetail(), + Flexible( + flex: 5, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: buildDetail(), + ), ), - ), - ], - ), - const SizedBox(height: 50), - ], - ), - ); - } - - // One column - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDragHeader(), - buildDate(), - const SizedBox(height: 16.0), - if (showMap) - Divider( - thickness: 1, - color: Colors.grey[600], + ], + ), + const SizedBox(height: 50), + ], ), - const SizedBox(height: 16.0), - buildLocation(), - const SizedBox(height: 16.0), - Divider( - thickness: 1, - color: Colors.grey[600], - ), - const SizedBox(height: 16.0), - buildDetail(), - const SizedBox(height: 50), - ], - ); - }, + ); + } + + // One column + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildDragHeader(), + buildDate(), + if (asset.isRemote) DescriptionInput(asset: asset), + const SizedBox(height: 8.0), + buildLocation(), + SizedBox(height: showMap ? 16.0 : 0.0), + buildDetail(), + const SizedBox(height: 50), + ], + ); + }, + ), ), ), ), diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 535ad968f7..41e704d134 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -195,7 +195,12 @@ class GalleryViewerPage extends HookConsumerWidget { .getSetting(AppSettingsEnum.advancedTroubleshooting)) { return AdvancedBottomSheet(assetDetail: assetDetail!); } - return ExifBottomSheet(assetDetail: assetDetail!); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: ExifBottomSheet(asset: assetDetail!), + ); }, ); } diff --git a/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart b/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart index 190f98c820..ae9f6c96ba 100644 --- a/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart +++ b/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -25,19 +26,25 @@ class AdvancedSettings extends HookConsumerWidget { return ExpansionTile( textColor: Theme.of(context).primaryColor, title: const Text( - "Advanced", + "advanced_settings_tile_title", style: TextStyle( fontWeight: FontWeight.bold, ), - ), + ).tr(), + subtitle: const Text( + "advanced_settings_tile_subtitle", + style: TextStyle( + fontSize: 13, + ), + ).tr(), children: [ SettingsSwitchListTile( enabled: true, appSettingService: appSettingService, valueNotifier: isEnabled, settingsEnum: AppSettingsEnum.advancedTroubleshooting, - title: "Troubleshooting", - subtitle: "Enable additional features for troubleshooting", + title: "advanced_settings_troubleshooting_title".tr(), + subtitle: "advanced_settings_troubleshooting_subtitle".tr(), ), ], ); diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index 0fd20aaf34..29cc913c2d 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -21,6 +21,7 @@ class ExifInfo { String? city; String? state; String? country; + String? description; @ignore String get exposureTime { @@ -58,7 +59,8 @@ class ExifInfo { long = dto.longitude?.toDouble(), city = dto.city, state = dto.state, - country = dto.country; + country = dto.country, + description = dto.description; ExifInfo({ this.fileSize, @@ -74,6 +76,7 @@ class ExifInfo { this.city, this.state, this.country, + this.description, }); } diff --git a/mobile/lib/shared/models/exif_info.g.dart b/mobile/lib/shared/models/exif_info.g.dart index 228e40b70b..825a3e5a08 100644 Binary files a/mobile/lib/shared/models/exif_info.g.dart and b/mobile/lib/shared/models/exif_info.g.dart differ diff --git a/mobile/openapi/doc/ExifResponseDto.md b/mobile/openapi/doc/ExifResponseDto.md index 1fb483d635..dd4b3b4f96 100644 Binary files a/mobile/openapi/doc/ExifResponseDto.md and b/mobile/openapi/doc/ExifResponseDto.md differ diff --git a/mobile/openapi/doc/UpdateAssetDto.md b/mobile/openapi/doc/UpdateAssetDto.md index 4b844a54f0..bc08c8175c 100644 Binary files a/mobile/openapi/doc/UpdateAssetDto.md and b/mobile/openapi/doc/UpdateAssetDto.md differ diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0c3ec07461..fceec66d37 100644 Binary files a/mobile/openapi/lib/model/exif_response_dto.dart and b/mobile/openapi/lib/model/exif_response_dto.dart differ diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 2faee2f7c4..c24a98dc3d 100644 Binary files a/mobile/openapi/lib/model/update_asset_dto.dart and b/mobile/openapi/lib/model/update_asset_dto.dart differ diff --git a/mobile/openapi/test/exif_response_dto_test.dart b/mobile/openapi/test/exif_response_dto_test.dart index 138bff9616..9918892d34 100644 Binary files a/mobile/openapi/test/exif_response_dto_test.dart and b/mobile/openapi/test/exif_response_dto_test.dart differ diff --git a/mobile/openapi/test/update_asset_dto_test.dart b/mobile/openapi/test/update_asset_dto_test.dart index 7f9d874afc..94dcb27fc7 100644 Binary files a/mobile/openapi/test/update_asset_dto_test.dart and b/mobile/openapi/test/update_asset_dto_test.dart differ diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 4bd1743dad..f01d35119c 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -1,6 +1,6 @@ import { SearchPropertiesDto } from './dto/search-properties.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; -import { AssetEntity, AssetType } from '@app/infra/entities'; +import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm/repository/Repository'; @@ -55,6 +55,7 @@ export class AssetRepository implements IAssetRepository { private assetRepository: Repository, @Inject(ITagRepository) private _tagRepository: ITagRepository, + @InjectRepository(ExifEntity) private exifRepository: Repository, ) {} async getAllVideos(): Promise { @@ -268,6 +269,17 @@ export class AssetRepository implements IAssetRepository { asset.tags = tags; } + if (asset.exifInfo != null) { + asset.exifInfo.description = dto.description || ''; + await this.exifRepository.save(asset.exifInfo); + } else { + const exifInfo = new ExifEntity(); + exifInfo.description = dto.description || ''; + exifInfo.asset = asset; + await this.exifRepository.save(exifInfo); + asset.exifInfo = exifInfo; + } + return await this.assetRepository.save(asset); } diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index 48e7d6502b..ebadeca755 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { AssetService } from './asset.service'; import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AssetEntity } from '@app/infra/entities'; +import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { DownloadModule } from '../../modules/download/download.module'; import { TagModule } from '../tag/tag.module'; @@ -16,7 +16,7 @@ const ASSET_REPOSITORY_PROVIDER = { @Module({ imports: [ // - TypeOrmModule.forFeature([AssetEntity]), + TypeOrmModule.forFeature([AssetEntity, ExifEntity]), DownloadModule, TagModule, AlbumModule, diff --git a/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts b/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts index 5b17b043fe..c180c256bb 100644 --- a/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts @@ -25,4 +25,8 @@ export class UpdateAssetDto { ], }) tagIds?: string[]; + + @IsOptional() + @IsString() + description?: string; } diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 76f8820883..12504e2bc8 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -223,7 +223,6 @@ export class MetadataExtractionProcessor { const newExif = new ExifEntity(); newExif.assetId = asset.id; - newExif.description = ''; newExif.fileSizeInByte = data.format.size || null; newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null; newExif.modifyDate = null; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index fab096fb32..42fd6e3cc2 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3675,6 +3675,11 @@ "type": "string", "nullable": true, "default": null + }, + "description": { + "type": "string", + "nullable": true, + "default": null } } }, @@ -5283,6 +5288,9 @@ }, "isArchived": { "type": "boolean" + }, + "description": { + "type": "string" } } }, diff --git a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts index cac1d24686..0f49f09b9a 100644 --- a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts +++ b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts @@ -23,6 +23,7 @@ export class ExifResponseDto { city?: string | null = null; state?: string | null = null; country?: string | null = null; + description?: string | null = null; } export function mapExif(entity: ExifEntity): ExifResponseDto { @@ -46,5 +47,6 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { city: entity.city, state: entity.state, country: entity.country, + description: entity.description, }; } diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 708dd39336..cc47da52e2 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -343,6 +343,7 @@ const assetInfo: ExifResponseDto = { city: 'city', state: 'state', country: 'country', + description: 'description', }; const assetResponse: AssetResponseDto = { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7eed6db389..62e679509a 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1208,6 +1208,12 @@ export interface ExifResponseDto { * @memberof ExifResponseDto */ 'country'?: string | null; + /** + * + * @type {string} + * @memberof ExifResponseDto + */ + 'description'?: string | null; } /** * @@ -2341,6 +2347,12 @@ export interface UpdateAssetDto { * @memberof UpdateAssetDto */ 'isArchived'?: boolean; + /** + * + * @type {string} + * @memberof UpdateAssetDto + */ + 'description'?: string; } /** * diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 4197d73265..887a773ad2 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -251,6 +251,18 @@ } }); }; + + const disableKeyDownEvent = () => { + if (browser) { + document.removeEventListener('keydown', onKeyboardPress); + } + }; + + const enableKeyDownEvent = () => { + if (browser) { + document.addEventListener('keydown', onKeyboardPress); + } + };
- (isShowDetail = false)} /> + (isShowDetail = false)} + on:description-focus-in={disableKeyDownEvent} + on:description-focus-out={enableKeyDownEvent} + /> {/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index d0dd9a8661..8a22667b0f 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -5,14 +5,26 @@ import CameraIris from 'svelte-material-icons/CameraIris.svelte'; import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte'; import { createEventDispatcher } from 'svelte'; - import { AssetResponseDto, AlbumResponseDto } from '@api'; + import { AssetResponseDto, AlbumResponseDto, api } from '@api'; import { asByteUnitString } from '../../utils/byte-units'; import { locale } from '$lib/stores/preferences.store'; import { DateTime } from 'luxon'; import type { LatLngTuple } from 'leaflet'; + import { page } from '$app/stores'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; + let textarea: HTMLTextAreaElement; + let description: string; + + $: { + // Get latest description from server + if (asset.id) { + api.assetApi + .getAssetById(asset.id) + .then((res) => (textarea.value = res.data?.exifInfo?.description || '')); + } + } $: latlng = (() => { const lat = asset.exifInfo?.latitude; @@ -34,6 +46,27 @@ return undefined; }; + + const autoGrowHeight = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + target.style.height = 'auto'; + target.style.height = `${target.scrollHeight}px`; + }; + + const handleFocusIn = () => { + dispatch('description-focus-in'); + }; + + const handleFocusOut = async () => { + dispatch('description-focus-out'); + try { + await api.assetApi.updateAsset(asset.id, { + description: description + }); + } catch (error) { + console.error(error); + } + };
@@ -48,6 +81,23 @@

Info

+
+