diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 20902698f6..eee2c9f488 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -5,11 +5,6 @@ on: paths: - 'open-api/**' - '.github/workflows/check-openapi.yml' - push: - branches: [main] - paths: - - 'open-api/**' - - '.github/workflows/check-openapi.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index e355803f17..d100dd281f 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -69,6 +69,14 @@ jobs: - name: Install dependencies run: dart pub get + - name: Install dependencies for UI package + run: dart pub get + working-directory: ./mobile/packages/ui + + - name: Install dependencies for UI Showcase + run: dart pub get + working-directory: ./mobile/packages/ui/showcase + - name: Install DCM uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1 with: diff --git a/docs/docs/developer/devcontainers.md b/docs/docs/developer/devcontainers.md index c4f673de6e..4bd60262ad 100644 --- a/docs/docs/developer/devcontainers.md +++ b/docs/docs/developer/devcontainers.md @@ -44,7 +44,7 @@ While this guide focuses on VS Code, you have many options for Dev Container dev **Self-Hostable Options:** - [Coder](https://coder.com) - Enterprise-focused, requires Terraform knowledge, self-managed -- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise) +- [DevPod](https://devpod.sh) - Client-only tool with excellent devcontainer.json support, works with any provider (local, cloud, or on-premise). Check [quick-start guide](#quick-start-guide-for-devpod-with-docker) ::: ## Dev Container Services @@ -410,6 +410,26 @@ If you encounter issues: 3. Review [common Docker issues](https://docs.docker.com/desktop/troubleshoot/) 4. Ask in [Discord](https://discord.immich.app) `#contributing` channel +### Quick-start guide for DevPod with docker + +You will need DevPod CLI (check [DevPod CLI installation guide](https://devpod.sh/docs/getting-started/install)) and Docker Desktop. + +```sh +# Step 1: Clone the Repository +git clone https://github.com/immich-app/immich.git +cd immich + +# Step 2: Prepare DevPod (if you haven't already) +devpod provider add docker +devpod provider use docker + +# Step 3: Build 'immich-server-dev' docker image first manually +docker build -f server/Dockerfile.dev -t immich-server-dev . + +# Step 4: Now you can start devcontainer +devpod up . +``` + ## Mobile Development While the Dev Container focuses on server and web development, you can connect mobile apps for testing: diff --git a/docs/docs/features/supported-formats.md b/docs/docs/features/supported-formats.md index 16f1ab0b6b..4c4ac6039a 100644 --- a/docs/docs/features/supported-formats.md +++ b/docs/docs/features/supported-formats.md @@ -38,6 +38,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a | `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | | | `MP4` | `.mp4` `.insv` | :white_check_mark: | | | `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | | +| `MXF` | `.mxf` | :white_check_mark: | | | `QUICKTIME` | `.mov` | :white_check_mark: | | | `WEBM` | `.webm` | :white_check_mark: | | | `WMV` | `.wmv` | :white_check_mark: | | diff --git a/i18n/en.json b/i18n/en.json index 6e35085be8..95e9584032 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1218,6 +1218,7 @@ "filter_description": "Conditions to filter the target assets", "filter_people": "Filter people", "filter_places": "Filter places", + "filter_tags": "Filter tags", "filters": "Filters", "find_them_fast": "Find them fast by name with search", "first": "First", @@ -1945,6 +1946,7 @@ "search_filter_ocr": "Search by OCR", "search_filter_people_title": "Select people", "search_filter_star_rating": "Star Rating", + "search_filter_tags_title": "Select tags", "search_for": "Search for", "search_for_existing_person": "Search for existing person", "search_no_more_result": "No more results", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index e3d24ce172..c43d0df2cc 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -8,7 +8,6 @@ readme = "README.md" dependencies = [ "aiocache>=0.12.1,<1.0", "fastapi>=0.95.2,<1.0", - "ftfy>=6.1.1", "gunicorn>=21.1.0", "huggingface-hub>=0.20.1,<1.0", "insightface>=0.7.3,<1.0", diff --git a/mise.toml b/mise.toml index 7cb3a024e3..5e3088974c 100644 --- a/mise.toml +++ b/mise.toml @@ -37,13 +37,12 @@ run = "pnpm install --filter @immich/sdk --frozen-lockfile" [tasks."sdk:build"] dir = "open-api/typescript-sdk" -env._.path = "./node_modules/.bin" -run = "tsc" +run = "pnpm run build" # i18n tasks [tasks."i18n:format"] dir = "i18n" -run = { task = ":i18n:format-fix" } +run = "pnpm run format" [tasks."i18n:format-fix"] dir = "i18n" diff --git a/mobile/lib/domain/models/tag.model.dart b/mobile/lib/domain/models/tag.model.dart new file mode 100644 index 0000000000..357367b13e --- /dev/null +++ b/mobile/lib/domain/models/tag.model.dart @@ -0,0 +1,29 @@ +import 'package:openapi/api.dart'; + +class Tag { + final String id; + final String value; + + const Tag({required this.id, required this.value}); + + @override + String toString() { + return 'Tag(id: $id, value: $value)'; + } + + @override + bool operator ==(covariant Tag other) { + if (identical(this, other)) return true; + + return other.id == id && other.value == value; + } + + @override + int get hashCode { + return id.hashCode ^ value.hashCode; + } + + static Tag fromDto(TagResponseDto dto) { + return Tag(id: dto.id, value: dto.value); + } +} diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart index 043a42b1a4..bcfddfce6e 100644 --- a/mobile/lib/infrastructure/repositories/search_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -35,6 +35,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 100, @@ -59,6 +60,7 @@ class SearchApiRepository extends ApiRepository { isFavorite: filter.display.isFavorite ? true : null, isNotInAlbum: filter.display.isNotInAlbum ? true : null, personIds: filter.people.map((e) => e.id).toList(), + tagIds: filter.tagIds, type: type, page: page, size: 1000, diff --git a/mobile/lib/infrastructure/repositories/tags_api.repository.dart b/mobile/lib/infrastructure/repositories/tags_api.repository.dart new file mode 100644 index 0000000000..e81b79c459 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/tags_api.repository.dart @@ -0,0 +1,17 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:openapi/api.dart'; + +final tagsApiRepositoryProvider = Provider( + (ref) => TagsApiRepository(ref.read(apiServiceProvider).tagsApi), +); + +class TagsApiRepository extends ApiRepository { + final TagsApi _api; + const TagsApiRepository(this._api); + + Future?> getAllTags() async { + return await _api.getAllTags(); + } +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 2d45913fcb..1b730e0c68 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -214,6 +214,7 @@ class SearchFilter { String? ocr; String? language; String? assetId; + List? tagIds; Set people; SearchLocationFilter location; SearchCameraFilter camera; @@ -231,6 +232,7 @@ class SearchFilter { this.ocr, this.language, this.assetId, + this.tagIds, required this.people, required this.location, required this.camera, @@ -246,6 +248,7 @@ class SearchFilter { (description == null || (description!.isEmpty)) && (assetId == null || (assetId!.isEmpty)) && (ocr == null || (ocr!.isEmpty)) && + (tagIds ?? []).isEmpty && people.isEmpty && location.country == null && location.state == null && @@ -269,6 +272,7 @@ class SearchFilter { String? ocr, String? assetId, Set? people, + List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, @@ -290,12 +294,13 @@ class SearchFilter { display: display ?? this.display, rating: rating ?? this.rating, mediaType: mediaType ?? this.mediaType, + tagIds: tagIds ?? this.tagIds, ); } @override String toString() { - return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; + return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)'; } @override @@ -309,6 +314,7 @@ class SearchFilter { other.ocr == ocr && other.assetId == assetId && other.people == people && + other.tagIds == tagIds && other.location == location && other.camera == camera && other.date == date && @@ -326,6 +332,7 @@ class SearchFilter { ocr.hashCode ^ assetId.hashCode ^ people.hashCode ^ + tagIds.hashCode ^ location.hashCode ^ camera.hashCode ^ date.hashCode ^ diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart deleted file mode 100644 index 37c412a0e9..0000000000 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_ui/immich_ui.dart'; - -List _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) builder) { - final children = []; - - final items = [ - (variant: ImmichVariant.filled, title: "Filled Variant"), - (variant: ImmichVariant.ghost, title: "Ghost Variant"), - ]; - - for (final (:variant, :title) in items) { - children.add(Text(title)); - children.add(Row(spacing: 10, children: [for (var color in ImmichColor.values) builder(variant, color)])); - } - - return children; -} - -class _ComponentTitle extends StatelessWidget { - final String title; - - const _ComponentTitle(this.title); - - @override - Widget build(BuildContext context) { - return Text(title, style: context.textTheme.titleLarge); - } -} - -@RoutePage() -class ImmichUIShowcasePage extends StatelessWidget { - const ImmichUIShowcasePage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Immich UI Showcase')), - body: Padding( - padding: const EdgeInsets.all(20), - child: SingleChildScrollView( - child: Column( - spacing: 10, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _ComponentTitle("IconButton"), - ..._showcaseBuilder( - (variant, color) => - ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}), - ), - const _ComponentTitle("CloseButton"), - ..._showcaseBuilder( - (variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}), - ), - const _ComponentTitle("TextButton"), - - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.filled, - color: ImmichColor.primary, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.filled, - color: ImmichColor.primary, - loading: true, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.ghost, - color: ImmichColor.primary, - ), - ImmichTextButton( - labelText: "Text Button", - onPressed: () {}, - variant: ImmichVariant.ghost, - color: ImmichColor.primary, - loading: true, - ), - const _ComponentTitle("Form"), - ImmichForm( - onSubmit: () {}, - child: const Column( - spacing: 10, - children: [ImmichTextInput(label: "Title", hintText: "Enter a title")], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 62ec11f7ed..0d9bba146a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -7,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -24,6 +25,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/feature_check.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:immich_mobile/widgets/common/tag_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; @@ -62,6 +64,7 @@ class DriftSearchPage extends HookConsumerWidget { mediaType: preFilter?.mediaType ?? AssetType.other, language: "${context.locale.languageCode}-${context.locale.countryCode}", assetId: preFilter?.assetId, + tagIds: preFilter?.tagIds ?? [], ), ); @@ -72,15 +75,14 @@ class DriftSearchPage extends HookConsumerWidget { final dateRangeCurrentFilterWidget = useState(null); final cameraCurrentFilterWidget = useState(null); final locationCurrentFilterWidget = useState(null); + final tagCurrentFilterWidget = useState(null); final mediaTypeCurrentFilterWidget = useState(null); final ratingCurrentFilterWidget = useState(null); final displayOptionCurrentFilterWidget = useState(null); final isSearching = useState(false); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + final userPreferences = ref.watch(userMetadataPreferencesProvider); SnackBar searchInfoSnackBar(String message) { return SnackBar( @@ -148,10 +150,12 @@ class DriftSearchPage extends HookConsumerWidget { handleOnSelect(Set value) { filter.value = filter.value.copyWith(people: value); - peopleCurrentFilterWidget.value = Text( - value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '), - style: context.textTheme.labelLarge, - ); + final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '); + if (label.isNotEmpty) { + peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge); + } else { + peopleCurrentFilterWidget.value = null; + } } handleClear() { @@ -177,6 +181,42 @@ class DriftSearchPage extends HookConsumerWidget { ); } + showTagPicker() { + handleOnSelect(Iterable tags) { + filter.value = filter.value.copyWith(tagIds: tags.map((t) => t.id).toList()); + final label = tags.map((t) => t.value).join(', '); + if (label.isEmpty) { + tagCurrentFilterWidget.value = null; + } else { + tagCurrentFilterWidget.value = Text( + label.isEmpty ? 'tags'.t(context: context) : label, + style: context.textTheme.labelLarge, + ); + } + } + + handleClear() { + filter.value = filter.value.copyWith(tagIds: []); + tagCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_tags_title'.t(context: context), + expanded: true, + onSearch: search, + onClear: handleClear, + child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()), + ), + ), + ); + } + showLocationPicker() { handleOnSelect(Map value) { filter.value = filter.value.copyWith( @@ -658,6 +698,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, ), + if (userPreferences.value?.tagsEnabled ?? false) + SearchFilterChip( + icon: Icons.sell_outlined, + onTap: showTagPicker, + label: 'tags'.t(context: context), + currentFilter: tagCurrentFilterWidget.value, + ), SearchFilterChip( icon: Icons.camera_alt_outlined, onTap: showCameraPicker, @@ -677,7 +724,7 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), - if (isRatingEnabled) ...[ + if (userPreferences.value?.ratingsEnabled ?? false) ...[ SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index a8f5f9d14a..a294adb669 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -45,7 +45,6 @@ class _AssetPageState extends ConsumerState { late PhotoViewControllerValue _initialPhotoViewState; - bool _blockGestures = false; bool _showingDetails = false; bool _isZoomed = false; @@ -58,7 +57,6 @@ class _AssetPageState extends ConsumerState { DragStartDetails? _dragStart; _DragIntent _dragIntent = _DragIntent.none; Drag? _drag; - bool _dragInProgress = false; bool _shouldPopOnDrag = false; @override @@ -137,14 +135,12 @@ class _AssetPageState extends ConsumerState { } void _updateDrag(DragUpdateDetails details) { - if (_blockGestures) return; - - _dragInProgress = true; + if (_dragStart == null) return; if (_dragIntent == _DragIntent.none) { _dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) { - < -kTouchSlop => _DragIntent.scroll, - > kTouchSlop => _DragIntent.dismiss, + < 0 => _DragIntent.scroll, + > 0 => _DragIntent.dismiss, _ => _DragIntent.none, }; } @@ -160,16 +156,12 @@ class _AssetPageState extends ConsumerState { } void _endDrag(DragEndDetails details) { - _dragInProgress = false; + if (_dragStart == null) return; - if (_blockGestures) { - _blockGestures = false; - return; - } + _dragStart = null; final intent = _dragIntent; _dragIntent = _DragIntent.none; - _dragStart = null; switch (intent) { case _DragIntent.none: @@ -201,10 +193,7 @@ class _AssetPageState extends ConsumerState { PhotoViewScaleStateController scaleStateController, ) { _viewController = controller; - if (!_showingDetails && _isZoomed) { - _blockGestures = true; - return; - } + if (!_showingDetails && _isZoomed) return; _beginDrag(details); } @@ -235,7 +224,7 @@ class _AssetPageState extends ConsumerState { } void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) { - if (!_showingDetails && !_dragInProgress) _viewer.toggleControls(); + if (!_showingDetails && _dragStart == null) _viewer.toggleControls(); } void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) => @@ -249,7 +238,7 @@ class _AssetPageState extends ConsumerState { _viewer.setZoomed(_isZoomed); if (scaleState != PhotoViewScaleState.initial) { - if (!_dragInProgress) _viewer.setControls(false); + if (_dragStart == null) _viewer.setControls(false); ref.read(videoPlayerControlsProvider.notifier).pause(); return; @@ -382,9 +371,10 @@ class _AssetPageState extends ConsumerState { final viewportHeight = MediaQuery.heightOf(context); final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset); - final margin = (viewportHeight - imageHeight) / 2; - final overflowBoxHeight = margin + imageHeight - (kMinInteractiveDimension / 2); - _snapOffset = (margin + imageHeight) - (viewportHeight / 4); + final detailsOffset = (viewportHeight + imageHeight - kMinInteractiveDimension) / 2; + final snapTarget = viewportHeight / 3; + + _snapOffset = detailsOffset - snapTarget; if (_proxyScrollController.hasClients) { _proxyScrollController.snapPosition.snapOffset = _snapOffset; @@ -429,7 +419,7 @@ class _AssetPageState extends ConsumerState { ignoring: !_showingDetails, child: Column( children: [ - SizedBox(height: overflowBoxHeight), + SizedBox(height: detailsOffset), GestureDetector( onVerticalDragStart: _beginDrag, onVerticalDragUpdate: _updateDrag, @@ -438,7 +428,7 @@ class _AssetPageState extends ConsumerState { child: AnimatedOpacity( opacity: _showingDetails ? 1.0 : 0.0, duration: Durations.short2, - child: AssetDetails(minHeight: _snapOffset + viewportHeight - overflowBoxHeight), + child: AssetDetails(minHeight: viewportHeight - snapTarget), ), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 13311fc4b2..515f635493 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -87,39 +87,37 @@ class AssetViewer extends ConsumerStatefulWidget { } class _AssetViewerState extends ConsumerState { - late PageController pageController; + late final _heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; + late final _pageController = PageController(initialPage: widget.initialIndex); + late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); StreamSubscription? _reloadSubscription; - - late final int heroOffset; - bool _assetReloadRequested = false; - int _totalAssets = 0; - - late final AssetPreloader _preloader; KeepAliveLink? _stackChildrenKeepAlive; + bool _assetReloadRequested = false; + @override void initState() { super.initState(); - assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); - pageController = PageController(initialPage: widget.initialIndex); - final timelineService = ref.read(timelineServiceProvider); - _totalAssets = timelineService.totalAssets; - _preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted); - WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); - _reloadSubscription = EventStream.shared.listen(_onEvent); - heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; + final asset = ref.read(currentAssetNotifier); + assert(asset != null, "Current asset should not be null when opening the AssetViewer"); if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); + + _reloadSubscription = EventStream.shared.listen(_onEvent); + + WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); } @override void dispose() { - pageController.dispose(); + _pageController.dispose(); _preloader.dispose(); _reloadSubscription?.cancel(); _stackChildrenKeepAlive?.close(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); } @@ -176,26 +174,26 @@ class _AssetViewerState extends ConsumerState { void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); - _totalAssets = timelineService.totalAssets; + final totalAssets = timelineService.totalAssets; - if (_totalAssets == 0) { + if (totalAssets == 0) { context.maybePop(); return; } - var index = pageController.page?.round() ?? 0; + var index = _pageController.page?.round() ?? 0; final currentAsset = ref.read(currentAssetNotifier); if (currentAsset != null) { final newIndex = timelineService.getIndex(currentAsset.heroTag); if (newIndex != null && newIndex != index) { index = newIndex; - pageController.jumpToPage(index); + _pageController.jumpToPage(index); } } - if (index >= _totalAssets) { - index = _totalAssets - 1; - pageController.jumpToPage(index); + if (index >= totalAssets) { + index = totalAssets - 1; + _pageController.jumpToPage(index); } if (_assetReloadRequested) { @@ -264,15 +262,15 @@ class _AssetViewerState extends ConsumerState { PhotoViewGestureDetectorScope( axis: Axis.horizontal, child: PageView.builder( - controller: pageController, + controller: _pageController, physics: isZoomed ? const NeverScrollableScrollPhysics() : CurrentPlatform.isIOS ? const FastScrollPhysics() : const FastClampingScrollPhysics(), - itemCount: _totalAssets, + itemCount: ref.read(timelineServiceProvider).totalAssets, onPageChanged: (index) => _onAssetChanged(index), - itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset), + itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset), ), ), if (!CurrentPlatform.isIOS) diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart new file mode 100644 index 0000000000..23d4d86861 --- /dev/null +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -0,0 +1,17 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/tags_api.repository.dart'; + +class TagNotifier extends AsyncNotifier> { + @override + Future> build() async { + final repo = ref.read(tagsApiRepositoryProvider); + final allTags = await repo.getAllTags(); + if (allTags == null) { + return {}; + } + return allTags.map((t) => Tag.fromDto(t)).toSet(); + } +} + +final tagProvider = AsyncNotifierProvider>(TagNotifier.new); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 2bc000db45..81616f8880 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -78,9 +78,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; -import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart'; import 'package:immich_mobile/presentation/pages/download_info.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; @@ -88,7 +88,6 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; -import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; @@ -338,7 +337,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: ImmichUIShowcaseRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 5fd8d2be85..86c52d90dc 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1873,22 +1873,6 @@ class HeaderSettingsRoute extends PageRouteInfo { ); } -/// generated route for -/// [ImmichUIShowcasePage] -class ImmichUIShowcaseRoute extends PageRouteInfo { - const ImmichUIShowcaseRoute({List? children}) - : super(ImmichUIShowcaseRoute.name, initialChildren: children); - - static const String name = 'ImmichUIShowcaseRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const ImmichUIShowcasePage(); - }, - ); -} - /// generated route for /// [LibraryPage] class LibraryRoute extends PageRouteInfo { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 1a714b6f40..bafe780647 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -35,6 +35,7 @@ class ApiService implements Authentication { late ViewsApi viewApi; late MemoriesApi memoriesApi; late SessionsApi sessionsApi; + late TagsApi tagsApi; ApiService() { // The below line ensures that the api clients are initialized when the service is instantiated @@ -74,6 +75,7 @@ class ApiService implements Authentication { viewApi = ViewsApi(_apiClient); memoriesApi = MemoriesApi(_apiClient); sessionsApi = SessionsApi(_apiClient); + tagsApi = TagsApi(_apiClient); } Future _setUserAgentHeader() async { diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index ebd8ed8b36..56b7e91eec 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -153,11 +152,6 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { actions: [ if (actions != null) ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if (kDebugMode || kProfileMode) - IconButton( - icon: const Icon(Icons.palette_rounded), - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - ), if (isCasting) Padding( padding: const EdgeInsets.only(right: 12), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 141f7e5e8b..541b7c28c3 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -74,11 +74,6 @@ class ImmichSliverAppBar extends ConsumerWidget { icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), ), if (actions != null) ...actions!, - if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) - IconButton( - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - icon: const Icon(Icons.palette_rounded), - ), if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), const _ProfileIndicator(), const SizedBox(width: 8), diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart new file mode 100644 index 0000000000..0ab25d14cb --- /dev/null +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/tag.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/tag.provider.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; + +class TagPicker extends HookConsumerWidget { + const TagPicker({super.key, required this.onSelect, required this.filter}); + + final Function(Iterable) onSelect; + final Set filter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formFocus = useFocusNode(); + final searchQuery = useState(''); + final tags = ref.watch(tagProvider); + final selectedTagIds = useState>(filter); + final borderRadius = const BorderRadius.all(Radius.circular(10)); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: SearchField( + focusNode: formFocus, + onChanged: (value) => searchQuery.value = value, + onTapOutside: (_) => formFocus.unfocus(), + filled: true, + hintText: 'filter_tags'.tr(), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 0), + child: Divider(color: context.colorScheme.surfaceContainerHighest, thickness: 1), + ), + Expanded( + child: tags.widgetWhen( + onData: (tags) { + final queryResult = tags + .where((t) => t.value.toLowerCase().contains(searchQuery.value.toLowerCase())) + .toList(); + return ListView.builder( + itemCount: queryResult.length, + padding: const EdgeInsets.all(8), + itemBuilder: (context, index) { + final tag = queryResult[index]; + final isSelected = selectedTagIds.value.any((id) => id == tag.id); + + return Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Container( + decoration: BoxDecoration( + color: isSelected ? context.primaryColor : context.primaryColor.withAlpha(25), + borderRadius: borderRadius, + ), + child: ListTile( + title: Text( + tag.value, + style: context.textTheme.bodyLarge?.copyWith( + color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface, + ), + ), + onTap: () { + final newSelected = {...selectedTagIds.value}; + if (isSelected) { + newSelected.removeWhere((id) => id == tag.id); + } else { + newSelected.add(tag.id); + } + selectedTagIds.value = newSelected; + onSelect(tags.where((t) => newSelected.contains(t.id))); + }, + ), + ), + ); + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4ebe5c7c65..afeeb694e1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -416,6 +416,7 @@ Class | Method | HTTP request | Description - [DatabaseBackupDeleteDto](doc//DatabaseBackupDeleteDto.md) - [DatabaseBackupDto](doc//DatabaseBackupDto.md) - [DatabaseBackupListResponseDto](doc//DatabaseBackupListResponseDto.md) + - [DownloadArchiveDto](doc//DownloadArchiveDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f10490e093..0d6a98c001 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -155,6 +155,7 @@ part 'model/database_backup_config.dart'; part 'model/database_backup_delete_dto.dart'; part 'model/database_backup_dto.dart'; part 'model/database_backup_list_response_dto.dart'; +part 'model/download_archive_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart index 5245622753..4d0c5c8165 100644 --- a/mobile/openapi/lib/api/download_api.dart +++ b/mobile/openapi/lib/api/download_api.dart @@ -24,17 +24,17 @@ class DownloadApi { /// /// Parameters: /// - /// * [AssetIdsDto] assetIdsDto (required): + /// * [DownloadArchiveDto] downloadArchiveDto (required): /// /// * [String] key: /// /// * [String] slug: - Future downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future downloadArchiveWithHttpInfo(DownloadArchiveDto downloadArchiveDto, { String? key, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/download/archive'; // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = downloadArchiveDto; final queryParams = []; final headerParams = {}; @@ -67,13 +67,13 @@ class DownloadApi { /// /// Parameters: /// - /// * [AssetIdsDto] assetIdsDto (required): + /// * [DownloadArchiveDto] downloadArchiveDto (required): /// /// * [String] key: /// /// * [String] slug: - Future downloadArchive(AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await downloadArchiveWithHttpInfo(assetIdsDto, key: key, slug: slug, ); + Future downloadArchive(DownloadArchiveDto downloadArchiveDto, { String? key, String? slug, }) async { + final response = await downloadArchiveWithHttpInfo(downloadArchiveDto, key: key, slug: slug, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 470f3aec27..5aabf5cd4b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -356,6 +356,8 @@ class ApiClient { return DatabaseBackupDto.fromJson(value); case 'DatabaseBackupListResponseDto': return DatabaseBackupListResponseDto.fromJson(value); + case 'DownloadArchiveDto': + return DownloadArchiveDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': diff --git a/mobile/openapi/lib/model/download_archive_dto.dart b/mobile/openapi/lib/model/download_archive_dto.dart new file mode 100644 index 0000000000..20e8527f18 --- /dev/null +++ b/mobile/openapi/lib/model/download_archive_dto.dart @@ -0,0 +1,120 @@ +// +// 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 DownloadArchiveDto { + /// Returns a new [DownloadArchiveDto] instance. + DownloadArchiveDto({ + this.assetIds = const [], + this.edited, + }); + + /// Asset IDs + List assetIds; + + /// Download edited asset if available + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? edited; + + @override + bool operator ==(Object other) => identical(this, other) || other is DownloadArchiveDto && + _deepEquality.equals(other.assetIds, assetIds) && + other.edited == edited; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode) + + (edited == null ? 0 : edited!.hashCode); + + @override + String toString() => 'DownloadArchiveDto[assetIds=$assetIds, edited=$edited]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + if (this.edited != null) { + json[r'edited'] = this.edited; + } else { + // json[r'edited'] = null; + } + return json; + } + + /// Returns a new [DownloadArchiveDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DownloadArchiveDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveDto"); + if (value is Map) { + final json = value.cast(); + + return DownloadArchiveDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + edited: mapValueOfType(json, r'edited'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DownloadArchiveDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DownloadArchiveDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DownloadArchiveDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DownloadArchiveDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + }; +} + diff --git a/mobile/packages/ui/.gitignore b/mobile/packages/ui/.gitignore new file mode 100644 index 0000000000..b84f47ac2c --- /dev/null +++ b/mobile/packages/ui/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +build/ + +# Platform-specific files are not needed as this is a Flutter UI package +android/ +ios/ + +# Test cache and generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies + +# Fonts copied by build process +fonts/ \ No newline at end of file diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 9f2a886ab3..909ab65bce 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,5 +1,6 @@ export 'src/components/close_button.dart'; export 'src/components/form.dart'; +export 'src/components/html_text.dart'; export 'src/components/icon_button.dart'; export 'src/components/password_input.dart'; export 'src/components/text_button.dart'; diff --git a/mobile/packages/ui/lib/src/components/html_text.dart b/mobile/packages/ui/lib/src/components/html_text.dart new file mode 100644 index 0000000000..72b54b8da5 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/html_text.dart @@ -0,0 +1,189 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html_parser; + +enum _HtmlTagType { + bold, + link, + unsupported, +} + +class _HtmlTag { + final _HtmlTagType type; + final String tagName; + + const _HtmlTag._({required this.type, required this.tagName}); + + static const unsupported = _HtmlTag._(type: _HtmlTagType.unsupported, tagName: 'unsupported'); + + static _HtmlTag? fromString(dom.Node node) { + final tagName = (node is dom.Element) ? node.localName : null; + if (tagName == null) { + return null; + } + + final tag = tagName.toLowerCase(); + return switch (tag) { + 'b' || 'strong' => _HtmlTag._(type: _HtmlTagType.bold, tagName: tag), + // Convert back to 'link' for handler lookup + 'a' => const _HtmlTag._(type: _HtmlTagType.link, tagName: 'link'), + _ when tag.endsWith('-link') => _HtmlTag._(type: _HtmlTagType.link, tagName: tag), + _ => _HtmlTag.unsupported, + }; + } +} + +/// A widget that renders text with optional HTML-style formatting. +/// +/// Supports the following tags: +/// - `` or `` for bold text +/// - `` or any tag ending with `-link` for tappable links +/// +/// Example: +/// ```dart +/// ImmichHtmlText( +/// 'Refer to docs and other', +/// linkHandlers: { +/// 'link': () => launchUrl(docsUrl), +/// 'other-link': () => launchUrl(otherUrl), +/// }, +/// ) +/// ``` +class ImmichHtmlText extends StatefulWidget { + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final TextOverflow? overflow; + final int? maxLines; + final bool? softWrap; + final Map? linkHandlers; + final TextStyle? linkStyle; + + const ImmichHtmlText( + this.text, { + super.key, + this.style, + this.textAlign, + this.overflow, + this.maxLines, + this.softWrap, + this.linkHandlers, + this.linkStyle, + }); + + @override + State createState() => _ImmichHtmlTextState(); +} + +class _ImmichHtmlTextState extends State { + final _recognizers = []; + dom.DocumentFragment _document = dom.DocumentFragment(); + + @override + void initState() { + super.initState(); + _document = html_parser.parseFragment(_preprocessHtml(widget.text)); + } + + @override + void didUpdateWidget(covariant ImmichHtmlText oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text != widget.text) { + _document = html_parser.parseFragment(_preprocessHtml(widget.text)); + } + } + + /// `` tags are preprocessed to `` tags because `` is a + /// void element in HTML5 and cannot have children. The linkHandlers still use + /// 'link' as the key. + String _preprocessHtml(String html) { + return html + .replaceAllMapped( + RegExp(r'<(link)>(.*?)', caseSensitive: false), + (match) => '${match.group(2)}', + ) + .replaceAllMapped( + RegExp(r'<(link)\s*/>', caseSensitive: false), + (match) => '', + ); + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + void _disposeRecognizers() { + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + List _buildSpans() { + _disposeRecognizers(); + + return _document.nodes.expand((node) => _buildNode(node, null, null)).toList(); + } + + Iterable _buildNode( + dom.Node node, + TextStyle? style, + _HtmlTag? parentTag, + ) sync* { + if (node is dom.Text) { + if (node.text.isEmpty) { + return; + } + + GestureRecognizer? recognizer; + if (parentTag?.type == _HtmlTagType.link) { + final handler = widget.linkHandlers?[parentTag?.tagName]; + if (handler != null) { + recognizer = TapGestureRecognizer()..onTap = handler; + _recognizers.add(recognizer); + } + } + + yield TextSpan(text: node.text, style: style, recognizer: recognizer); + } else if (node is dom.Element) { + final htmlTag = _HtmlTag.fromString(node); + final tagStyle = _styleForTag(htmlTag); + final mergedStyle = style?.merge(tagStyle) ?? tagStyle; + final newParentTag = htmlTag?.type == _HtmlTagType.link ? htmlTag : parentTag; + + for (final child in node.nodes) { + yield* _buildNode(child, mergedStyle, newParentTag); + } + } + } + + TextStyle? _styleForTag(_HtmlTag? tag) { + if (tag == null) { + return null; + } + + return switch (tag.type) { + _HtmlTagType.bold => const TextStyle(fontWeight: FontWeight.bold), + _HtmlTagType.link => widget.linkStyle ?? + TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + _HtmlTagType.unsupported => null, + }; + } + + @override + Widget build(BuildContext context) { + return Text.rich( + TextSpan(style: widget.style, children: _buildSpans()), + textAlign: widget.textAlign, + overflow: widget.overflow, + maxLines: widget.maxLines, + softWrap: widget.softWrap, + ); + } +} diff --git a/mobile/packages/ui/pubspec.lock b/mobile/packages/ui/pubspec.lock index fa0b425230..c74422dd97 100644 --- a/mobile/packages/ui/pubspec.lock +++ b/mobile/packages/ui/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" characters: dependency: transitive description: @@ -9,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" collection: dependency: transitive description: @@ -17,11 +41,72 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + html: + dependency: "direct main" + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -34,15 +119,71 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" vector_math: dependency: transitive description: @@ -51,5 +192,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" sdks: dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/mobile/packages/ui/pubspec.yaml b/mobile/packages/ui/pubspec.yaml index 47b9a9dd8a..d23f34f1a7 100644 --- a/mobile/packages/ui/pubspec.yaml +++ b/mobile/packages/ui/pubspec.yaml @@ -7,6 +7,11 @@ environment: dependencies: flutter: sdk: flutter + html: ^0.15.6 + +dev_dependencies: + flutter_test: + sdk: flutter flutter: uses-material-design: true \ No newline at end of file diff --git a/mobile/packages/ui/showcase/.gitignore b/mobile/packages/ui/showcase/.gitignore new file mode 100644 index 0000000000..b285cd608b --- /dev/null +++ b/mobile/packages/ui/showcase/.gitignore @@ -0,0 +1,11 @@ +# Build artifacts +build/ + +# Test cache and generated files +.dart_tool/ +.packages +.flutter-plugins +.flutter-plugins-dependencies + +# IDE-specific files +.vscode/ \ No newline at end of file diff --git a/mobile/packages/ui/showcase/.metadata b/mobile/packages/ui/showcase/.metadata new file mode 100644 index 0000000000..b95fa4d74e --- /dev/null +++ b/mobile/packages/ui/showcase/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/mobile/packages/ui/showcase/analysis_options.yaml b/mobile/packages/ui/showcase/analysis_options.yaml new file mode 100644 index 0000000000..f9b303465f --- /dev/null +++ b/mobile/packages/ui/showcase/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/mobile/packages/ui/showcase/assets/immich-text-dark.png b/mobile/packages/ui/showcase/assets/immich-text-dark.png new file mode 100644 index 0000000000..215687af8f Binary files /dev/null and b/mobile/packages/ui/showcase/assets/immich-text-dark.png differ diff --git a/mobile/packages/ui/showcase/assets/immich-text-light.png b/mobile/packages/ui/showcase/assets/immich-text-light.png new file mode 100644 index 0000000000..478158d39c Binary files /dev/null and b/mobile/packages/ui/showcase/assets/immich-text-light.png differ diff --git a/mobile/packages/ui/showcase/assets/immich_logo.png b/mobile/packages/ui/showcase/assets/immich_logo.png new file mode 100644 index 0000000000..49fd3ae289 Binary files /dev/null and b/mobile/packages/ui/showcase/assets/immich_logo.png differ diff --git a/mobile/packages/ui/showcase/assets/themes/github_dark.json b/mobile/packages/ui/showcase/assets/themes/github_dark.json new file mode 100644 index 0000000000..bd4801482e --- /dev/null +++ b/mobile/packages/ui/showcase/assets/themes/github_dark.json @@ -0,0 +1,339 @@ +{ + "name": "GitHub Dark", + "settings": [ + { + "settings": { + "foreground": "#e1e4e8", + "background": "#24292e" + } + }, + { + "scope": [ + "comment", + "punctuation.definition.comment", + "string.comment" + ], + "settings": { + "foreground": "#6a737d" + } + }, + { + "scope": [ + "constant", + "entity.name.constant", + "variable.other.constant", + "variable.other.enummember", + "variable.language" + ], + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "entity", + "entity.name" + ], + "settings": { + "foreground": "#b392f0" + } + }, + { + "scope": "variable.parameter.function", + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": "entity.name.tag", + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": "keyword", + "settings": { + "foreground": "#f97583" + } + }, + { + "scope": [ + "storage", + "storage.type" + ], + "settings": { + "foreground": "#f97583" + } + }, + { + "scope": [ + "storage.modifier.package", + "storage.modifier.import", + "storage.type.java" + ], + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": [ + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source" + ], + "settings": { + "foreground": "#9ecbff" + } + }, + { + "scope": "support", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.property-name", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "variable", + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": "variable.other", + "settings": { + "foreground": "#e1e4e8" + } + }, + { + "scope": "invalid.broken", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.deprecated", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.illegal", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "invalid.unimplemented", + "settings": { + "fontStyle": "italic", + "foreground": "#fdaeb7" + } + }, + { + "scope": "message.error", + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": "string variable", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "source.regexp", + "string.regexp" + ], + "settings": { + "foreground": "#dbedff" + } + }, + { + "scope": [ + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition" + ], + "settings": { + "foreground": "#dbedff" + } + }, + { + "scope": "string.regexp constant.character.escape", + "settings": { + "fontStyle": "bold", + "foreground": "#85e89d" + } + }, + { + "scope": "support.constant", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "support.variable", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.module-reference", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "punctuation.definition.list.begin.markdown", + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": [ + "markup.heading", + "markup.heading entity.name" + ], + "settings": { + "fontStyle": "bold", + "foreground": "#79b8ff" + } + }, + { + "scope": "markup.quote", + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": "markup.italic", + "settings": { + "fontStyle": "italic", + "foreground": "#e1e4e8" + } + }, + { + "scope": "markup.bold", + "settings": { + "fontStyle": "bold", + "foreground": "#e1e4e8" + } + }, + { + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "scope": "markup.inline.raw", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted" + ], + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": [ + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted" + ], + "settings": { + "foreground": "#85e89d" + } + }, + { + "scope": [ + "markup.changed", + "punctuation.definition.changed" + ], + "settings": { + "foreground": "#ffab70" + } + }, + { + "scope": [ + "markup.ignored", + "markup.untracked" + ], + "settings": { + "foreground": "#2f363d" + } + }, + { + "scope": "meta.diff.range", + "settings": { + "fontStyle": "bold", + "foreground": "#b392f0" + } + }, + { + "scope": "meta.diff.header", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.separator", + "settings": { + "fontStyle": "bold", + "foreground": "#79b8ff" + } + }, + { + "scope": "meta.output", + "settings": { + "foreground": "#79b8ff" + } + }, + { + "scope": [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote" + ], + "settings": { + "foreground": "#d1d5da" + } + }, + { + "scope": "brackethighlighter.unmatched", + "settings": { + "foreground": "#fdaeb7" + } + }, + { + "scope": [ + "constant.other.reference.link", + "string.other.link" + ], + "settings": { + "fontStyle": "underline", + "foreground": "#dbedff" + } + } + ] +} diff --git a/mobile/packages/ui/showcase/lib/app_theme.dart b/mobile/packages/ui/showcase/lib/app_theme.dart new file mode 100644 index 0000000000..995bf3c91e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/app_theme.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Light theme colors + static const _primary500 = Color(0xFF4250AF); + static const _primary100 = Color(0xFFD4D6F0); + static const _primary900 = Color(0xFF181E44); + static const _danger500 = Color(0xFFE53E3E); + static const _light50 = Color(0xFFFAFAFA); + static const _light300 = Color(0xFFD4D4D4); + static const _light500 = Color(0xFF737373); + + // Dark theme colors + static const _darkPrimary500 = Color(0xFFACCBFA); + static const _darkPrimary300 = Color(0xFF616D94); + static const _darkDanger500 = Color(0xFFE88080); + static const _darkLight50 = Color(0xFF0A0A0A); + static const _darkLight100 = Color(0xFF171717); + static const _darkLight200 = Color(0xFF262626); + + static ThemeData get lightTheme { + return ThemeData( + colorScheme: const ColorScheme.light( + primary: _primary500, + onPrimary: Colors.white, + primaryContainer: _primary100, + onPrimaryContainer: _primary900, + secondary: _light500, + onSecondary: Colors.white, + error: _danger500, + onError: Colors.white, + surface: _light50, + onSurface: Color(0xFF1A1C1E), + surfaceContainerHighest: Color(0xFFE3E4E8), + outline: Color(0xFFD1D3D9), + outlineVariant: _light300, + ), + useMaterial3: true, + fontFamily: 'GoogleSans', + scaffoldBackgroundColor: _light50, + cardTheme: const CardThemeData( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: _light300, width: 1), + ), + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + foregroundColor: Color(0xFF1A1C1E), + ), + ); + } + + static ThemeData get darkTheme { + return ThemeData( + colorScheme: const ColorScheme.dark( + primary: _darkPrimary500, + onPrimary: Color(0xFF0F1433), + primaryContainer: _darkPrimary300, + onPrimaryContainer: _primary100, + secondary: Color(0xFFC4C6D0), + onSecondary: Color(0xFF2E3042), + error: _darkDanger500, + onError: Color(0xFF0F1433), + surface: _darkLight50, + onSurface: Color(0xFFE3E3E6), + surfaceContainerHighest: _darkLight200, + outline: Color(0xFF8E9099), + outlineVariant: Color(0xFF43464F), + ), + useMaterial3: true, + fontFamily: 'GoogleSans', + scaffoldBackgroundColor: _darkLight50, + cardTheme: const CardThemeData( + elevation: 0, + color: _darkLight100, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + side: BorderSide(color: _darkLight200, width: 1), + ), + ), + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: _darkLight50, + surfaceTintColor: Colors.transparent, + foregroundColor: Color(0xFFE3E3E6), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/constants.dart b/mobile/packages/ui/showcase/lib/constants.dart new file mode 100644 index 0000000000..cfca4cfda9 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/constants.dart @@ -0,0 +1,16 @@ +const String appTitle = '@immich/ui'; + +class LayoutConstants { + static const double sidebarWidth = 220.0; + + static const double gridSpacing = 16.0; + static const double gridAspectRatio = 2.5; + + static const double borderRadiusSmall = 6.0; + static const double borderRadiusMedium = 8.0; + static const double borderRadiusLarge = 12.0; + + static const double iconSizeSmall = 16.0; + static const double iconSizeMedium = 18.0; + static const double iconSizeLarge = 20.0; +} diff --git a/mobile/packages/ui/showcase/lib/main.dart b/mobile/packages/ui/showcase/lib/main.dart new file mode 100644 index 0000000000..6cd2df4fe5 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/main.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/app_theme.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/router.dart'; +import 'package:showcase/widgets/example_card.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await initializeCodeHighlighter(); + runApp(const ShowcaseApp()); +} + +class ShowcaseApp extends StatefulWidget { + const ShowcaseApp({super.key}); + + @override + State createState() => _ShowcaseAppState(); +} + +class _ShowcaseAppState extends State { + ThemeMode _themeMode = ThemeMode.light; + late final GoRouter _router; + + @override + void initState() { + super.initState(); + _router = AppRouter.createRouter(_toggleTheme); + } + + void _toggleTheme() { + setState(() { + _themeMode = _themeMode == ThemeMode.light + ? ThemeMode.dark + : ThemeMode.light; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: appTitle, + themeMode: _themeMode, + routerConfig: _router, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + debugShowCheckedModeBanner: false, + builder: (context, child) => ImmichThemeProvider( + colorScheme: Theme.of(context).colorScheme, + child: child!, + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart new file mode 100644 index 0000000000..1bae98e0a4 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class CloseButtonPage extends StatelessWidget { + const CloseButtonPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.closeButton.name, + child: ComponentExamples( + title: 'ImmichCloseButton', + subtitle: 'Pre-configured close button for dialogs and sheets.', + examples: [ + ExampleCard( + title: 'Default & Custom', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichCloseButton(onPressed: () {}), + ImmichCloseButton( + variant: ImmichVariant.filled, + onPressed: () {}, + ), + ImmichCloseButton( + color: ImmichColor.secondary, + onPressed: () {}, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart new file mode 100644 index 0000000000..af4c87f40e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextBoldText extends StatelessWidget { + const HtmlTextBoldText({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'This is bold text and strong text.', + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart new file mode 100644 index 0000000000..a764d7173e --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextLinks extends StatelessWidget { + const HtmlTextLinks({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'Read the documentation or visit GitHub.', + linkHandlers: { + 'docs-link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Docs link clicked!'))); + }, + 'github-link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('GitHub link clicked!'))); + }, + }, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart new file mode 100644 index 0000000000..836d949b66 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; + +class HtmlTextNestedTags extends StatelessWidget { + const HtmlTextNestedTags({super.key}); + + @override + Widget build(BuildContext context) { + return ImmichHtmlText( + 'You can combine bold and links together.', + linkHandlers: { + 'link': () { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Nested link clicked!'))); + }, + }, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/form_page.dart b/mobile/packages/ui/showcase/lib/pages/components/form_page.dart new file mode 100644 index 0000000000..14567031de --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/form_page.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class FormPage extends StatefulWidget { + const FormPage({super.key}); + + @override + State createState() => _FormPageState(); +} + +class _FormPageState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + String _result = ''; + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.form.name, + child: ComponentExamples( + title: 'ImmichForm', + subtitle: + 'Form container with built-in validation and submit handling.', + examples: [ + ExampleCard( + title: 'Login Form', + preview: Column( + children: [ + ImmichForm( + submitText: 'Login', + submitIcon: Icons.login, + onSubmit: () async { + await Future.delayed(const Duration(seconds: 1)); + setState(() { + _result = 'Form submitted!'; + }); + }, + child: Column( + spacing: 10, + children: [ + ImmichTextInput( + label: 'Email', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + ImmichPasswordInput( + label: 'Password', + controller: _passwordController, + validator: (value) => + value?.isEmpty ?? true ? 'Required' : null, + ), + ], + ), + ), + if (_result.isNotEmpty) ...[ + const SizedBox(height: 16), + Text(_result, style: const TextStyle(color: Colors.green)), + ], + ], + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart b/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart new file mode 100644 index 0000000000..64dbc70597 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:showcase/pages/components/examples/html_text_bold_text.dart'; +import 'package:showcase/pages/components/examples/html_text_links.dart'; +import 'package:showcase/pages/components/examples/html_text_nested_tags.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class HtmlTextPage extends StatelessWidget { + const HtmlTextPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.htmlText.name, + child: ComponentExamples( + title: 'ImmichHtmlText', + subtitle: 'Render text with HTML formatting (bold, links).', + examples: [ + ExampleCard( + title: 'Bold Text', + preview: const HtmlTextBoldText(), + code: 'html_text_bold_text.dart', + ), + ExampleCard( + title: 'Links', + preview: const HtmlTextLinks(), + code: 'html_text_links.dart', + ), + ExampleCard( + title: 'Nested Tags', + preview: const HtmlTextNestedTags(), + code: 'html_text_nested_tags.dart', + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart new file mode 100644 index 0000000000..4418b1de4f --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class IconButtonPage extends StatelessWidget { + const IconButtonPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.iconButton.name, + child: ComponentExamples( + title: 'ImmichIconButton', + subtitle: 'Icon-only button with customizable styling.', + examples: [ + ExampleCard( + title: 'Variants & Colors', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichIconButton( + icon: Icons.add, + onPressed: () {}, + variant: ImmichVariant.filled, + ), + ImmichIconButton( + icon: Icons.edit, + onPressed: () {}, + variant: ImmichVariant.ghost, + ), + ImmichIconButton( + icon: Icons.delete, + onPressed: () {}, + color: ImmichColor.secondary, + ), + ImmichIconButton( + icon: Icons.settings, + onPressed: () {}, + disabled: true, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart b/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart new file mode 100644 index 0000000000..772dd7882f --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class PasswordInputPage extends StatelessWidget { + const PasswordInputPage({super.key}); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.passwordInput.name, + child: ComponentExamples( + title: 'ImmichPasswordInput', + subtitle: 'Password field with visibility toggle.', + examples: [ + ExampleCard( + title: 'Password Input', + preview: ImmichPasswordInput( + label: 'Password', + hintText: 'Enter your password', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + return null; + }, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart b/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart new file mode 100644 index 0000000000..59e5b86294 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class TextButtonPage extends StatefulWidget { + const TextButtonPage({super.key}); + + @override + State createState() => _TextButtonPageState(); +} + +class _TextButtonPageState extends State { + bool _isLoading = false; + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.textButton.name, + child: ComponentExamples( + title: 'ImmichTextButton', + subtitle: + 'A versatile button component with multiple variants and color options.', + examples: [ + ExampleCard( + title: 'Variants', + description: + 'Filled and ghost variants for different visual hierarchy', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Filled', + variant: ImmichVariant.filled, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Ghost', + variant: ImmichVariant.ghost, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Colors', + description: 'Primary and secondary color options', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Primary', + color: ImmichColor.primary, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Secondary', + color: ImmichColor.secondary, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'With Icons', + description: 'Add leading icons', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'With Icon', + icon: Icons.add, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Download', + icon: Icons.download, + variant: ImmichVariant.ghost, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Loading State', + description: 'Shows loading indicator during async operations', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ImmichTextButton( + onPressed: () async { + setState(() => _isLoading = true); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _isLoading = false); + }, + labelText: _isLoading ? 'Loading...' : 'Click Me', + loading: _isLoading, + expanded: false, + ), + ], + ), + ), + ExampleCard( + title: 'Disabled State', + description: 'Buttons can be disabled', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ImmichTextButton( + onPressed: () {}, + labelText: 'Disabled', + disabled: true, + expanded: false, + ), + ImmichTextButton( + onPressed: () {}, + labelText: 'Disabled Ghost', + variant: ImmichVariant.ghost, + disabled: true, + expanded: false, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart b/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart new file mode 100644 index 0000000000..5a0bfec6cd --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class TextInputPage extends StatefulWidget { + const TextInputPage({super.key}); + + @override + State createState() => _TextInputPageState(); +} + +class _TextInputPageState extends State { + final _controller1 = TextEditingController(); + final _controller2 = TextEditingController(); + + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.textInput.name, + child: ComponentExamples( + title: 'ImmichTextInput', + subtitle: 'Text field with validation support.', + examples: [ + ExampleCard( + title: 'Basic Usage', + preview: Column( + children: [ + ImmichTextInput( + label: 'Email', + hintText: 'Enter your email', + controller: _controller1, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + ImmichTextInput( + label: 'Username', + controller: _controller2, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Username is required'; + } + if (value.length < 3) { + return 'Username must be at least 3 characters'; + } + return null; + }, + ), + ], + ), + ), + ], + ), + ); + } + + @override + void dispose() { + _controller1.dispose(); + _controller2.dispose(); + super.dispose(); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart b/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart new file mode 100644 index 0000000000..17de02d80a --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart @@ -0,0 +1,396 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/component_examples.dart'; +import 'package:showcase/widgets/example_card.dart'; +import 'package:showcase/widgets/page_title.dart'; + +class ConstantsPage extends StatefulWidget { + const ConstantsPage({super.key}); + + @override + State createState() => _ConstantsPageState(); +} + +class _ConstantsPageState extends State { + @override + Widget build(BuildContext context) { + return PageTitle( + title: AppRoute.constants.name, + child: ComponentExamples( + title: 'Constants', + subtitle: 'Consistent spacing, sizing, and styling constants.', + expand: true, + examples: [ + const ExampleCard( + title: 'Spacing', + description: 'ImmichSpacing (4.0 → 48.0)', + preview: Column( + children: [ + _SpacingBox(label: 'xs', size: ImmichSpacing.xs), + _SpacingBox(label: 'sm', size: ImmichSpacing.sm), + _SpacingBox(label: 'md', size: ImmichSpacing.md), + _SpacingBox(label: 'lg', size: ImmichSpacing.lg), + _SpacingBox(label: 'xl', size: ImmichSpacing.xl), + _SpacingBox(label: 'xxl', size: ImmichSpacing.xxl), + _SpacingBox(label: 'xxxl', size: ImmichSpacing.xxxl), + ], + ), + ), + const ExampleCard( + title: 'Border Radius', + description: 'ImmichRadius (0.0 → 24.0)', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _RadiusBox(label: 'none', radius: ImmichRadius.none), + _RadiusBox(label: 'xs', radius: ImmichRadius.xs), + _RadiusBox(label: 'sm', radius: ImmichRadius.sm), + _RadiusBox(label: 'md', radius: ImmichRadius.md), + _RadiusBox(label: 'lg', radius: ImmichRadius.lg), + _RadiusBox(label: 'xl', radius: ImmichRadius.xl), + _RadiusBox(label: 'xxl', radius: ImmichRadius.xxl), + ], + ), + ), + const ExampleCard( + title: 'Icon Sizes', + description: 'ImmichIconSize (16.0 → 48.0)', + preview: Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.start, + children: [ + _IconSizeBox(label: 'xs', size: ImmichIconSize.xs), + _IconSizeBox(label: 'sm', size: ImmichIconSize.sm), + _IconSizeBox(label: 'md', size: ImmichIconSize.md), + _IconSizeBox(label: 'lg', size: ImmichIconSize.lg), + _IconSizeBox(label: 'xl', size: ImmichIconSize.xl), + _IconSizeBox(label: 'xxl', size: ImmichIconSize.xxl), + ], + ), + ), + const ExampleCard( + title: 'Text Sizes', + description: 'ImmichTextSize (10.0 → 60.0)', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Caption', + style: TextStyle(fontSize: ImmichTextSize.caption), + ), + Text('Label', style: TextStyle(fontSize: ImmichTextSize.label)), + Text('Body', style: TextStyle(fontSize: ImmichTextSize.body)), + Text('H6', style: TextStyle(fontSize: ImmichTextSize.h6)), + Text('H5', style: TextStyle(fontSize: ImmichTextSize.h5)), + Text('H4', style: TextStyle(fontSize: ImmichTextSize.h4)), + Text('H3', style: TextStyle(fontSize: ImmichTextSize.h3)), + Text('H2', style: TextStyle(fontSize: ImmichTextSize.h2)), + Text('H1', style: TextStyle(fontSize: ImmichTextSize.h1)), + ], + ), + ), + const ExampleCard( + title: 'Elevation', + description: 'ImmichElevation (0.0 → 16.0)', + preview: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _ElevationBox(label: 'none', elevation: ImmichElevation.none), + _ElevationBox(label: 'xs', elevation: ImmichElevation.xs), + _ElevationBox(label: 'sm', elevation: ImmichElevation.sm), + _ElevationBox(label: 'md', elevation: ImmichElevation.md), + _ElevationBox(label: 'lg', elevation: ImmichElevation.lg), + _ElevationBox(label: 'xl', elevation: ImmichElevation.xl), + _ElevationBox(label: 'xxl', elevation: ImmichElevation.xxl), + ], + ), + ), + const ExampleCard( + title: 'Border Width', + description: 'ImmichBorderWidth (0.5 → 4.0)', + preview: Column( + children: [ + _BorderBox( + label: 'hairline', + borderWidth: ImmichBorderWidth.hairline, + ), + _BorderBox(label: 'base', borderWidth: ImmichBorderWidth.base), + _BorderBox(label: 'md', borderWidth: ImmichBorderWidth.md), + _BorderBox(label: 'lg', borderWidth: ImmichBorderWidth.lg), + _BorderBox(label: 'xl', borderWidth: ImmichBorderWidth.xl), + ], + ), + ), + const ExampleCard( + title: 'Animation Durations', + description: 'ImmichDuration (100ms → 700ms)', + preview: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + _AnimatedDurationBox( + label: 'Extra Fast', + duration: ImmichDuration.extraFast, + ), + _AnimatedDurationBox( + label: 'Fast', + duration: ImmichDuration.fast, + ), + _AnimatedDurationBox( + label: 'Normal', + duration: ImmichDuration.normal, + ), + _AnimatedDurationBox( + label: 'Slow', + duration: ImmichDuration.slow, + ), + _AnimatedDurationBox( + label: 'Extra Slow', + duration: ImmichDuration.extraSlow, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SpacingBox extends StatelessWidget { + final String label; + final double size; + + const _SpacingBox({required this.label, required this.size}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text( + label, + style: const TextStyle(fontFamily: 'GoogleSansCode'), + ), + ), + Container( + width: size, + height: 24, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text('${size.toStringAsFixed(1)}px'), + ], + ), + ); + } +} + +class _RadiusBox extends StatelessWidget { + final String label; + final double radius; + + const _RadiusBox({required this.label, required this.radius}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(radius), + ), + ), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + ], + ); + } +} + +class _IconSizeBox extends StatelessWidget { + final String label; + final double size; + + const _IconSizeBox({required this.label, required this.size}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(Icons.palette_rounded, size: size), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12)), + Text( + '${size.toStringAsFixed(0)}px', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + ], + ); + } +} + +class _ElevationBox extends StatelessWidget { + final String label; + final double elevation; + + const _ElevationBox({required this.label, required this.elevation}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Material( + elevation: elevation, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Container( + width: 60, + height: 60, + alignment: Alignment.center, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ), + const SizedBox(height: 4), + Text( + elevation.toStringAsFixed(1), + style: const TextStyle(fontSize: 10), + ), + ], + ); + } +} + +class _BorderBox extends StatelessWidget { + final String label; + final double borderWidth; + + const _BorderBox({required this.label, required this.borderWidth}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: const TextStyle(fontFamily: 'GoogleSansCode'), + ), + ), + Expanded( + child: Container( + height: 40, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: borderWidth, + ), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + ), + ), + const SizedBox(width: 8), + Text('${borderWidth.toStringAsFixed(1)}px'), + ], + ), + ); + } +} + +class _AnimatedDurationBox extends StatefulWidget { + final String label; + final Duration duration; + + const _AnimatedDurationBox({required this.label, required this.duration}); + + @override + State<_AnimatedDurationBox> createState() => _AnimatedDurationBoxState(); +} + +class _AnimatedDurationBoxState extends State<_AnimatedDurationBox> { + bool _atEnd = false; + bool _isAnimating = false; + + void _playAnimation() async { + if (_isAnimating) return; + setState(() => _isAnimating = true); + setState(() => _atEnd = true); + await Future.delayed(widget.duration); + if (!mounted) return; + setState(() => _atEnd = false); + await Future.delayed(widget.duration); + if (!mounted) return; + setState(() => _isAnimating = false); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + SizedBox( + width: 90, + child: Text( + widget.label, + style: const TextStyle(fontFamily: 'GoogleSansCode', fontSize: 12), + ), + ), + Expanded( + child: Container( + height: 32, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: AnimatedAlign( + duration: widget.duration, + curve: Curves.easeInOut, + alignment: _atEnd ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 60, + height: 28, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: Text( + '${widget.duration.inMilliseconds}ms', + style: TextStyle( + fontSize: 11, + color: colorScheme.onPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: _isAnimating ? null : _playAnimation, + icon: Icon( + Icons.play_arrow_rounded, + color: _isAnimating ? colorScheme.outline : colorScheme.primary, + ), + iconSize: 24, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + ), + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/pages/home_page.dart b/mobile/packages/ui/showcase/lib/pages/home_page.dart new file mode 100644 index 0000000000..de7af6c26b --- /dev/null +++ b/mobile/packages/ui/showcase/lib/pages/home_page.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/routes.dart'; + +class HomePage extends StatelessWidget { + final VoidCallback onThemeToggle; + + const HomePage({super.key, required this.onThemeToggle}); + + @override + Widget build(BuildContext context) { + return Title( + title: appTitle, + color: Theme.of(context).colorScheme.primary, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + children: [ + Text( + appTitle, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + 'A collection of Flutter components that are shared across all Immich projects', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w400, + height: 1.5, + ), + ), + const SizedBox(height: 48), + ...routesByCategory.entries.map((entry) { + if (entry.key == AppRouteCategory.root) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.key.displayName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: LayoutConstants.gridSpacing, + mainAxisSpacing: LayoutConstants.gridSpacing, + childAspectRatio: LayoutConstants.gridAspectRatio, + ), + itemCount: entry.value.length, + itemBuilder: (context, index) { + return _ComponentCard(route: entry.value[index]); + }, + ), + const SizedBox(height: 48), + ], + ); + }), + ], + ), + ); + } +} + +class _ComponentCard extends StatelessWidget { + final AppRoute route; + + const _ComponentCard({required this.route}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => context.go(route.path), + borderRadius: const BorderRadius.all(Radius.circular(LayoutConstants.borderRadiusLarge)), + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(route.icon, size: 32, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text( + route.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + + const SizedBox(height: 8), + Text( + route.description, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant, height: 1.4), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/router.dart b/mobile/packages/ui/showcase/lib/router.dart new file mode 100644 index 0000000000..014de44fd8 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/router.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/pages/components/close_button_page.dart'; +import 'package:showcase/pages/components/form_page.dart'; +import 'package:showcase/pages/components/html_text_page.dart'; +import 'package:showcase/pages/components/icon_button_page.dart'; +import 'package:showcase/pages/components/password_input_page.dart'; +import 'package:showcase/pages/components/text_button_page.dart'; +import 'package:showcase/pages/components/text_input_page.dart'; +import 'package:showcase/pages/design_system/constants_page.dart'; +import 'package:showcase/pages/home_page.dart'; +import 'package:showcase/routes.dart'; +import 'package:showcase/widgets/shell_layout.dart'; + +class AppRouter { + static GoRouter createRouter(VoidCallback onThemeToggle) { + return GoRouter( + initialLocation: AppRoute.home.path, + routes: [ + ShellRoute( + builder: (context, state, child) => + ShellLayout(onThemeToggle: onThemeToggle, child: child), + routes: AppRoute.values + .map( + (route) => GoRoute( + path: route.path, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: switch (route) { + AppRoute.home => HomePage(onThemeToggle: onThemeToggle), + AppRoute.textButton => const TextButtonPage(), + AppRoute.iconButton => const IconButtonPage(), + AppRoute.closeButton => const CloseButtonPage(), + AppRoute.textInput => const TextInputPage(), + AppRoute.passwordInput => const PasswordInputPage(), + AppRoute.form => const FormPage(), + AppRoute.htmlText => const HtmlTextPage(), + AppRoute.constants => const ConstantsPage(), + }, + ), + ), + ) + .toList(), + ), + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/routes.dart b/mobile/packages/ui/showcase/lib/routes.dart new file mode 100644 index 0000000000..a39fb7bc34 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/routes.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +enum AppRouteCategory { + root(''), + forms('Forms'), + buttons('Buttons'), + designSystem('Design System'); + + final String displayName; + const AppRouteCategory(this.displayName); +} + +enum AppRoute { + home( + name: 'Home', + description: 'Home page', + path: '/', + category: AppRouteCategory.root, + icon: Icons.home_outlined, + ), + textButton( + name: 'Text Button', + description: 'Versatile button with filled and ghost variants', + path: '/text-button', + category: AppRouteCategory.buttons, + icon: Icons.smart_button_rounded, + ), + iconButton( + name: 'Icon Button', + description: 'Icon-only button with customizable styling', + path: '/icon-button', + category: AppRouteCategory.buttons, + icon: Icons.radio_button_unchecked_rounded, + ), + closeButton( + name: 'Close Button', + description: 'Pre-configured close button for dialogs', + path: '/close-button', + category: AppRouteCategory.buttons, + icon: Icons.close_rounded, + ), + textInput( + name: 'Text Input', + description: 'Text field with validation support', + path: '/text-input', + category: AppRouteCategory.forms, + icon: Icons.text_fields_outlined, + ), + passwordInput( + name: 'Password Input', + description: 'Password field with visibility toggle', + path: '/password-input', + category: AppRouteCategory.forms, + icon: Icons.password_outlined, + ), + form( + name: 'Form', + description: 'Form container with built-in validation', + path: '/form', + category: AppRouteCategory.forms, + icon: Icons.description_outlined, + ), + htmlText( + name: 'Html Text', + description: 'Render text with HTML formatting', + path: '/html-text', + category: AppRouteCategory.forms, + icon: Icons.code_rounded, + ), + constants( + name: 'Constants', + description: 'Spacing, colors, typography, and more', + path: '/constants', + category: AppRouteCategory.designSystem, + icon: Icons.palette_outlined, + ); + + final String name; + final String description; + final String path; + final AppRouteCategory category; + final IconData icon; + + const AppRoute({ + required this.name, + required this.description, + required this.path, + required this.category, + required this.icon, + }); +} + +final routesByCategory = AppRoute.values + .fold>>({}, (map, route) { + map.putIfAbsent(route.category, () => []).add(route); + return map; + }); diff --git a/mobile/packages/ui/showcase/lib/widgets/component_examples.dart b/mobile/packages/ui/showcase/lib/widgets/component_examples.dart new file mode 100644 index 0000000000..21e6516079 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/component_examples.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class ComponentExamples extends StatelessWidget { + final String title; + final String? subtitle; + final List examples; + final bool expand; + + const ComponentExamples({ + super.key, + required this.title, + this.subtitle, + required this.examples, + this.expand = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 24, 24, 24), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _PageHeader(title: title, subtitle: subtitle), + ), + const SliverPadding(padding: EdgeInsets.only(top: 24)), + if (expand) + SliverList.builder( + itemCount: examples.length, + itemBuilder: (context, index) => examples[index], + ) + else + SliverLayoutBuilder( + builder: (context, constraints) { + return SliverList.builder( + itemCount: examples.length, + itemBuilder: (context, index) => Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.crossAxisExtent * 0.6, + maxWidth: constraints.crossAxisExtent, + ), + child: IntrinsicWidth(child: examples[index]), + ), + ), + ); + }, + ), + ], + ), + ); + } +} + +class _PageHeader extends StatelessWidget { + final String title; + final String? subtitle; + + const _PageHeader({required this.title, this.subtitle}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.bold), + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/example_card.dart b/mobile/packages/ui/showcase/lib/widgets/example_card.dart new file mode 100644 index 0000000000..fea561afb6 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/example_card.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:showcase/constants.dart'; +import 'package:syntax_highlight/syntax_highlight.dart'; + +late final Highlighter _codeHighlighter; + +Future initializeCodeHighlighter() async { + await Highlighter.initialize(['dart']); + final darkTheme = await HighlighterTheme.loadFromAssets([ + 'assets/themes/github_dark.json', + ], const TextStyle(color: Color(0xFFe1e4e8))); + + _codeHighlighter = Highlighter(language: 'dart', theme: darkTheme); +} + +class ExampleCard extends StatefulWidget { + final String title; + final String? description; + final Widget preview; + final String? code; + + const ExampleCard({ + super.key, + required this.title, + this.description, + required this.preview, + this.code, + }); + + @override + State createState() => _ExampleCardState(); +} + +class _ExampleCardState extends State { + bool _showPreview = true; + String? code; + + @override + void initState() { + super.initState(); + if (widget.code != null) { + rootBundle + .loadString('lib/pages/components/examples/${widget.code!}') + .then((value) { + setState(() { + code = value; + }); + }); + } + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + if (widget.description != null) + Text( + widget.description!, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (code != null) ...[ + const SizedBox(width: 16), + Row( + children: [ + _ToggleButton( + icon: Icons.visibility_rounded, + label: 'Preview', + isSelected: _showPreview, + onTap: () => setState(() => _showPreview = true), + ), + const SizedBox(width: 8), + _ToggleButton( + icon: Icons.code_rounded, + label: 'Code', + isSelected: !_showPreview, + onTap: () => setState(() => _showPreview = false), + ), + ], + ), + ], + ], + ), + ), + const Divider(height: 1), + if (_showPreview) + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox(width: double.infinity, child: widget.preview), + ) + else + Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Color(0xFF24292e), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular( + LayoutConstants.borderRadiusMedium, + ), + bottomRight: Radius.circular( + LayoutConstants.borderRadiusMedium, + ), + ), + ), + child: _CodeCard(code: code!), + ), + ], + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _ToggleButton({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) + : Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(24)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: Theme.of(context).colorScheme.onPrimary, + ), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ); + } +} + +class _CodeCard extends StatelessWidget { + final String code; + + const _CodeCard({required this.code}); + + @override + Widget build(BuildContext context) { + final lines = code.split('\n'); + final lineNumberColor = Colors.white.withValues(alpha: 0.4); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate( + lines.length, + (index) => SizedBox( + height: 20, + child: Text( + '${index + 1}', + style: TextStyle( + fontFamily: 'GoogleSansCode', + fontSize: 13, + color: lineNumberColor, + height: 1.5, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + SelectableText.rich( + _codeHighlighter.highlight(code), + style: const TextStyle( + fontFamily: 'GoogleSansCode', + fontSize: 13, + height: 1.54, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/page_title.dart b/mobile/packages/ui/showcase/lib/widgets/page_title.dart new file mode 100644 index 0000000000..eae3bf6ffb --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/page_title.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class PageTitle extends StatelessWidget { + final String title; + final Widget child; + + const PageTitle({super.key, required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Title( + title: '$title | @immich/ui', + color: Theme.of(context).colorScheme.primary, + child: child, + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart b/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart new file mode 100644 index 0000000000..8bcb687e75 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/shell_layout.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/widgets/sidebar_navigation.dart'; + +class ShellLayout extends StatelessWidget { + final Widget child; + final VoidCallback onThemeToggle; + + const ShellLayout({ + super.key, + required this.child, + required this.onThemeToggle, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/immich_logo.png', height: 32, width: 32), + const SizedBox(width: 8), + Image.asset( + isDark + ? 'assets/immich-text-dark.png' + : 'assets/immich-text-light.png', + height: 24, + filterQuality: FilterQuality.none, + isAntiAlias: true, + ), + ], + ), + actions: [ + IconButton( + icon: Icon( + isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, + size: LayoutConstants.iconSizeLarge, + ), + onPressed: onThemeToggle, + tooltip: 'Toggle theme', + ), + ], + shape: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1), + ), + ), + body: Row( + children: [ + const SidebarNavigation(), + const VerticalDivider(), + Expanded(child: child), + ], + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart b/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart new file mode 100644 index 0000000000..10eba170e6 --- /dev/null +++ b/mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:showcase/constants.dart'; +import 'package:showcase/routes.dart'; + +class SidebarNavigation extends StatelessWidget { + const SidebarNavigation({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: LayoutConstants.sidebarWidth, + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + children: [ + ...routesByCategory.entries.expand((entry) { + final category = entry.key; + final routes = entry.value; + return [ + if (category != AppRouteCategory.root) _CategoryHeader(category), + ...routes.map((route) => _NavItem(route)), + const SizedBox(height: 24), + ]; + }), + ], + ), + ); + } +} + +class _CategoryHeader extends StatelessWidget { + final AppRouteCategory category; + + const _CategoryHeader(this.category); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 12, top: 8, bottom: 8), + child: Text( + category.displayName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class _NavItem extends StatelessWidget { + final AppRoute route; + + const _NavItem(this.route); + + @override + Widget build(BuildContext context) { + final currentRoute = GoRouterState.of(context).uri.toString(); + final isSelected = currentRoute == route.path; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + context.go(route.path); + }, + borderRadius: BorderRadius.circular( + LayoutConstants.borderRadiusMedium, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? (isDark + ? Colors.white.withValues(alpha: 0.1) + : Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.5)) + : Colors.transparent, + borderRadius: BorderRadius.circular( + LayoutConstants.borderRadiusMedium, + ), + ), + child: Row( + children: [ + Icon( + route.icon, + size: 20, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + route.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/packages/ui/showcase/pubspec.lock b/mobile/packages/ui/showcase/pubspec.lock new file mode 100644 index 0000000000..4d8ec62b90 --- /dev/null +++ b/mobile/packages/ui/showcase/pubspec.lock @@ -0,0 +1,393 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a + url: "https://pub.dev" + source: hosted + version: "17.0.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + immich_ui: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.0" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" + url: "https://pub.dev" + source: hosted + version: "0.5.5" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + super_clipboard: + dependency: transitive + description: + name: super_clipboard + sha256: e73f3bb7e66cc9260efa1dc507f979138e7e106c3521e2dda2d0311f6d728a16 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: b9611dcb68f1047d6f3ef11af25e4e68a21b1a705bbcc3eb8cb4e9f5c3148569 + url: "https://pub.dev" + source: hosted + version: "0.9.1" + syntax_highlight: + dependency: "direct main" + description: + name: syntax_highlight + sha256: "4d3ba40658cadba6ba55d697f29f00b43538ebb6eb4a0ca0e895c568eaced138" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/mobile/packages/ui/showcase/pubspec.yaml b/mobile/packages/ui/showcase/pubspec.yaml new file mode 100644 index 0000000000..e45ce07e66 --- /dev/null +++ b/mobile/packages/ui/showcase/pubspec.yaml @@ -0,0 +1,47 @@ +name: showcase +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + immich_ui: + path: ../ + go_router: ^17.0.1 + syntax_highlight: ^0.5.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/ + - assets/themes/ + - lib/pages/components/examples/ + + fonts: + - family: GoogleSans + fonts: + - asset: ../../../fonts/GoogleSans/GoogleSans-Regular.ttf + - asset: ../../../fonts/GoogleSans/GoogleSans-Italic.ttf + style: italic + - asset: ../../../fonts/GoogleSans/GoogleSans-Medium.ttf + weight: 500 + - asset: ../../../fonts/GoogleSans/GoogleSans-SemiBold.ttf + weight: 600 + - asset: ../../../fonts/GoogleSans/GoogleSans-Bold.ttf + weight: 700 + - family: GoogleSansCode + fonts: + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Regular.ttf + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-Medium.ttf + weight: 500 + - asset: ../../../fonts/GoogleSansCode/GoogleSansCode-SemiBold.ttf + weight: 600 \ No newline at end of file diff --git a/mobile/packages/ui/showcase/web/favicon.ico b/mobile/packages/ui/showcase/web/favicon.ico new file mode 100644 index 0000000000..7ec34e9e53 Binary files /dev/null and b/mobile/packages/ui/showcase/web/favicon.ico differ diff --git a/mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png b/mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000..49fd3ae289 Binary files /dev/null and b/mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png differ diff --git a/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png b/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000..a7220554bc Binary files /dev/null and b/mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png differ diff --git a/mobile/packages/ui/showcase/web/icons/apple-icon-180.png b/mobile/packages/ui/showcase/web/icons/apple-icon-180.png new file mode 100644 index 0000000000..4e642631a3 Binary files /dev/null and b/mobile/packages/ui/showcase/web/icons/apple-icon-180.png differ diff --git a/mobile/packages/ui/showcase/web/index.html b/mobile/packages/ui/showcase/web/index.html new file mode 100644 index 0000000000..abf42ad1fd --- /dev/null +++ b/mobile/packages/ui/showcase/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + @immich/ui + + + + + + diff --git a/mobile/packages/ui/showcase/web/manifest.json b/mobile/packages/ui/showcase/web/manifest.json new file mode 100644 index 0000000000..25b44bd1ae --- /dev/null +++ b/mobile/packages/ui/showcase/web/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "@immich/ui Showcase", + "short_name": "@immich/ui", + "start_url": ".", + "display": "standalone", + "background_color": "#FCFCFD", + "theme_color": "#4250AF", + "description": "Immich UI component library showcase and documentation", + "orientation": "landscape", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/mobile/packages/ui/test/html_test.dart b/mobile/packages/ui/test/html_test.dart new file mode 100644 index 0000000000..27f68ff66c --- /dev/null +++ b/mobile/packages/ui/test/html_test.dart @@ -0,0 +1,266 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/components/html_text.dart'; + +import 'test_utils.dart'; + +/// Text.rich creates a nested structure: root -> wrapper -> actual children +List _getContentSpans(WidgetTester tester) { + final richText = tester.widget(find.byType(RichText)); + final root = richText.text as TextSpan; + + if (root.children?.isNotEmpty ?? false) { + final wrapper = root.children!.first; + if (wrapper is TextSpan && wrapper.children != null) { + return wrapper.children!; + } + } + return []; +} + +TextSpan _findSpan(List spans, String text) { + return spans.firstWhere( + (span) => span is TextSpan && span.text == text, + orElse: () => throw StateError('No span found with text: "$text"'), + ) as TextSpan; +} + +String _concatenateText(List spans) { + return spans.whereType().map((s) => s.text ?? '').join(); +} + +void _triggerTap(TextSpan span) { + final recognizer = span.recognizer; + if (recognizer is TapGestureRecognizer) { + recognizer.onTap?.call(); + } +} + +void main() { + group('ImmichHtmlText', () { + testWidgets('renders plain text without HTML tags', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is plain text'), + ); + + expect(find.text('This is plain text'), findsOneWidget); + }); + + testWidgets('handles mixed content with bold and links', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'This is an example of HTML text with bold.', + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + + final exampleSpan = _findSpan(spans, 'example'); + expect(exampleSpan.style?.fontWeight, FontWeight.bold); + + final boldSpan = _findSpan(spans, 'bold'); + expect(boldSpan.style?.fontWeight, FontWeight.bold); + + final linkSpan = _findSpan(spans, 'HTML text'); + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.style?.fontWeight, FontWeight.bold); + expect(linkSpan.recognizer, isA()); + + expect(_concatenateText(spans), 'This is an example of HTML text with bold.'); + }); + + testWidgets('applies text style properties', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText( + 'Test text', + style: TextStyle( + fontSize: 16, + color: Colors.purple, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + + final text = tester.widget(find.byType(Text)); + final richText = text.textSpan as TextSpan; + + expect(richText.style?.fontSize, 16); + expect(richText.style?.color, Colors.purple); + expect(text.textAlign, TextAlign.center); + expect(text.maxLines, 2); + expect(text.overflow, TextOverflow.ellipsis); + }); + + testWidgets('handles text with special characters', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('Text with & < > " \' characters'), + ); + + expect(find.byType(RichText), findsOneWidget); + + final spans = _getContentSpans(tester); + expect(_concatenateText(spans), 'Text with & < > " \' characters'); + }); + + group('bold', () { + testWidgets('renders bold text with tag', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is bold text'), + ); + + final spans = _getContentSpans(tester); + final boldSpan = _findSpan(spans, 'bold'); + + expect(boldSpan.style?.fontWeight, FontWeight.bold); + expect(_concatenateText(spans), 'This is bold text'); + }); + + testWidgets('renders bold text with tag', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('This is strong text'), + ); + + final spans = _getContentSpans(tester); + final strongSpan = _findSpan(spans, 'strong'); + + expect(strongSpan.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('handles nested bold tags', (tester) async { + await tester.pumpTestWidget( + const ImmichHtmlText('Text with bold and nested'), + ); + + final spans = _getContentSpans(tester); + + final nestedSpan = _findSpan(spans, 'nested'); + expect(nestedSpan.style?.fontWeight, FontWeight.bold); + + final boldSpan = _findSpan(spans, 'bold and '); + expect(boldSpan.style?.fontWeight, FontWeight.bold); + + expect(_concatenateText(spans), 'Text with bold and nested'); + }); + }); + + group('link', () { + testWidgets('renders link text with tag', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'This is a custom link text', + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'custom link'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isA()); + }); + + testWidgets('handles link tap with callback', (tester) async { + var linkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Tap here', + linkHandlers: {'link': () => linkTapped = true}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + expect(linkSpan.recognizer, isA()); + + _triggerTap(linkSpan); + expect(linkTapped, isTrue); + }); + + testWidgets('handles custom prefixed link tags', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Refer to docs and other', + linkHandlers: { + 'docs-link': () {}, + 'other-link': () {}, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final otherSpan = _findSpan(spans, 'other'); + + expect(docsSpan.style?.decoration, TextDecoration.underline); + expect(otherSpan.style?.decoration, TextDecoration.underline); + }); + + testWidgets('applies custom link style', (tester) async { + const customLinkStyle = TextStyle( + color: Colors.red, + decoration: TextDecoration.overline, + ); + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Click here', + linkStyle: customLinkStyle, + linkHandlers: {'link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'here'); + + expect(linkSpan.style?.color, Colors.red); + expect(linkSpan.style?.decoration, TextDecoration.overline); + }); + + testWidgets('link without handler renders but is not tappable', (tester) async { + await tester.pumpTestWidget( + ImmichHtmlText( + 'Link without handler: click me', + linkHandlers: {'other-link': () {}}, + ), + ); + + final spans = _getContentSpans(tester); + final linkSpan = _findSpan(spans, 'click me'); + + expect(linkSpan.style?.decoration, TextDecoration.underline); + expect(linkSpan.recognizer, isNull); + }); + + testWidgets('handles multiple links with different handlers', (tester) async { + var firstLinkTapped = false; + var secondLinkTapped = false; + + await tester.pumpTestWidget( + ImmichHtmlText( + 'Go to docs or help', + linkHandlers: { + 'docs-link': () => firstLinkTapped = true, + 'help-link': () => secondLinkTapped = true, + }, + ), + ); + + final spans = _getContentSpans(tester); + final docsSpan = _findSpan(spans, 'docs'); + final helpSpan = _findSpan(spans, 'help'); + + _triggerTap(docsSpan); + expect(firstLinkTapped, isTrue); + expect(secondLinkTapped, isFalse); + + _triggerTap(helpSpan); + expect(secondLinkTapped, isTrue); + }); + }); + }); +} diff --git a/mobile/packages/ui/test/test_utils.dart b/mobile/packages/ui/test/test_utils.dart new file mode 100644 index 0000000000..42cc74da87 --- /dev/null +++ b/mobile/packages/ui/test/test_utils.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension WidgetTesterExtension on WidgetTester { + /// Pumps a widget wrapped in MaterialApp and Scaffold for testing. + Future pumpTestWidget(Widget widget) { + return pumpWidget(MaterialApp(home: Scaffold(body: widget))); + } +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 13d6ba7e56..0e57fc4819 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5052,7 +5052,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/DownloadArchiveDto" } } }, @@ -16117,7 +16117,15 @@ { "$ref": "#/components/schemas/AssetEditActionMirror" } - ] + ], + "discriminator": { + "mapping": { + "crop": "#/components/schemas/AssetEditActionCrop", + "mirror": "#/components/schemas/AssetEditActionMirror", + "rotate": "#/components/schemas/AssetEditActionRotate" + }, + "propertyName": "action" + } }, "minItems": 1, "type": "array" @@ -16188,7 +16196,15 @@ { "$ref": "#/components/schemas/AssetEditActionMirror" } - ] + ], + "discriminator": { + "mapping": { + "crop": "#/components/schemas/AssetEditActionCrop", + "mirror": "#/components/schemas/AssetEditActionMirror", + "rotate": "#/components/schemas/AssetEditActionRotate" + }, + "propertyName": "action" + } }, "minItems": 1, "type": "array" @@ -17662,6 +17678,26 @@ }, "type": "object" }, + "DownloadArchiveDto": { + "properties": { + "assetIds": { + "description": "Asset IDs", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "edited": { + "description": "Download edited asset if available", + "type": "boolean" + } + }, + "required": [ + "assetIds" + ], + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 59a25d58b3..acd8109cd3 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1132,9 +1132,11 @@ export type ValidateAccessTokenResponseDto = { /** Authentication status */ authStatus: boolean; }; -export type AssetIdsDto = { +export type DownloadArchiveDto = { /** Asset IDs */ assetIds: string[]; + /** Download edited asset if available */ + edited?: boolean; }; export type DownloadInfoDto = { /** Album ID to download */ @@ -2309,6 +2311,10 @@ export type SharedLinkEditDto = { /** Custom URL slug */ slug?: string | null; }; +export type AssetIdsDto = { + /** Asset IDs */ + assetIds: string[]; +}; export type AssetIdsResponseDto = { /** Asset ID */ assetId: string; @@ -4433,10 +4439,10 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { /** * Download asset archive */ -export function downloadArchive({ key, slug, assetIdsDto }: { +export function downloadArchive({ key, slug, downloadArchiveDto }: { key?: string; slug?: string; - assetIdsDto: AssetIdsDto; + downloadArchiveDto: DownloadArchiveDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; @@ -4447,7 +4453,7 @@ export function downloadArchive({ key, slug, assetIdsDto }: { }))}`, oazapfts.json({ ...opts, method: "POST", - body: assetIdsDto + body: downloadArchiveDto }))); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ffb892936..83fe7384bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: specifier: workspace:* version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.63.0 - version: 0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + specifier: ^0.64.0 + version: 0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -775,13 +775,13 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.50.2) + version: 0.3.9(svelte@5.51.5) dom-to-image: specifier: ^2.6.0 version: 2.6.0 fabric: - specifier: ^6.5.4 - version: 6.9.1 + specifier: ^7.0.0 + version: 7.2.0 geo-coordinates-parser: specifier: ^1.7.4 version: 1.7.4 @@ -826,16 +826,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.50.2) + version: 4.0.1(svelte@5.51.5) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.50.2) + version: 3.11.0(svelte@5.51.5) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.50.2) + version: 1.2.6(svelte@5.51.5) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.50.2) + version: 0.12.0(svelte@5.51.5) tabbable: specifier: ^6.2.0 version: 6.4.0 @@ -863,16 +863,16 @@ importers: version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) + version: 3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))) '@sveltejs/enhanced-img': specifier: ^0.10.0 - version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/kit': specifier: ^2.27.1 - version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@sveltejs/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tailwindcss/vite': specifier: ^4.1.7 version: 4.1.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -881,7 +881,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.3.1(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -920,7 +920,7 @@ importers: version: 6.1.0(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2) + version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.51.5) eslint-plugin-unicorn: specifier: ^62.0.0 version: 62.0.0(eslint@9.39.2(jiti@2.6.1)) @@ -941,19 +941,19 @@ importers: version: 4.2.0(prettier@3.8.1) prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.4.1(prettier@3.8.1)(svelte@5.50.2) + version: 3.4.1(prettier@3.8.1)(svelte@5.51.5) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.50.2 - version: 5.50.2 + specifier: 5.51.5 + version: 5.51.5 svelte-check: specifier: ^4.1.5 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3) + version: 4.3.6(picomatch@4.0.3)(svelte@5.51.5)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.50.2) + version: 1.4.1(svelte@5.51.5) tailwindcss: specifier: ^4.1.7 version: 4.1.18 @@ -3018,8 +3018,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.63.0': - resolution: {integrity: sha512-WTdEZi1XEvhcdQymFCIb8Us2DJv+Vp4wTytYwIgQUeXMFSQ8aUT7m76Wsa6uphmuFqyyJioFU+g4rIfJ+w2R5w==} + '@immich/ui@0.64.0': + resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==} peerDependencies: svelte: ^5.0.0 @@ -4321,8 +4321,8 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || >=7.0.0 - '@sveltejs/kit@2.50.2': - resolution: {integrity: sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA==} + '@sveltejs/kit@2.52.2': + resolution: {integrity: sha512-1in76dftrofUt138rVLvYuwiQLkg9K3cG8agXEE6ksf7gCGs8oIr3+pFrVtbRmY9JvW+psW5fvLM/IwVybOLBA==} engines: {node: '>=18.13'} hasBin: true peerDependencies: @@ -4650,10 +4650,6 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} - engines: {node: '>= 10'} - '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -5285,10 +5281,6 @@ packages: peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 - abab@2.0.6: - resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} - deprecated: Use your platform's native atob() and btoa() methods instead - abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -5308,9 +5300,6 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - acorn-globals@7.0.1: - resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} - acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -5331,8 +5320,8 @@ packages: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -5658,8 +5647,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bits-ui@2.14.4: - resolution: {integrity: sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==} + bits-ui@2.16.0: + resolution: {integrity: sha512-utsUZE7W7MxOQF1jmSYfzUrt2nZxgkq0yPqQcBQ0WQDMq8ETd1yEiHlPpqhMrpKU7IivjSf4XVysDDy+UVkMUw==} engines: {node: '>=20'} peerDependencies: '@internationalized/date': ^3.8.1 @@ -6363,16 +6352,6 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} - cssom@0.3.8: - resolution: {integrity: sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==} - - cssom@0.5.0: - resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} - - cssstyle@2.3.0: - resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} - engines: {node: '>=8'} - cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -6540,10 +6519,6 @@ packages: dagre-d3-es@7.0.13: resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} - data-urls@3.0.2: - resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} - engines: {node: '>=12'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -6701,8 +6676,8 @@ packages: engines: {node: '>= 4.0.0'} hasBin: true - devalue@5.6.2: - resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -6782,11 +6757,6 @@ packages: domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - domexception@4.0.0: - resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} - engines: {node: '>=12'} - deprecated: Use your platform's native DOMException instead - domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -6982,11 +6952,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true @@ -7203,9 +7168,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fabric@6.9.1: - resolution: {integrity: sha512-TqG08Xbt4rtlPsXgCjSUcZz/RsyEP57Qo21nCVRkw7zz9nR0co4SLkL9Q/zQh3tC1Yxap6M5jKFHUKV6SgPovg==} - engines: {node: '>=16.20.0'} + fabric@7.2.0: + resolution: {integrity: sha512-XSYmSqSMrlbCg+/j7/uU/PFeZuA5hHRDp7sGbDlMvz/T6BHt2MQSOYtz/AIdr+kmReA1s5jTzHJ8AjHwYUcmfQ==} + engines: {node: '>=20.0.0'} factory.ts@1.4.2: resolution: {integrity: sha512-8x2hqK1+EGkja4Ah8H3nkP7rDUJsBK1N3iFDqzqsaOV114o2IphSdVkFIw9nDHHr37gFFy2NXeN6n10ieqHzZg==} @@ -7694,10 +7659,6 @@ packages: hpack.js@2.1.6: resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} - html-encoding-sniffer@3.0.0: - resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} - engines: {node: '>=12'} - html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -7769,10 +7730,6 @@ packages: http-parser-js@0.5.10: resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} - http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} - engines: {node: '>= 6'} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -8211,15 +8168,6 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsdom@20.0.3: - resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} - engines: {node: '>=14'} - peerDependencies: - canvas: 2.11.2 - peerDependenciesMeta: - canvas: - optional: true - jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -10224,9 +10172,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -10250,9 +10195,6 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -11184,8 +11126,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.50.2: - resolution: {integrity: sha512-WCxzm3BBf+Ase6RwiDPR4G36cM4Kb0NuhmLK6x44I+D6reaxizDDg8kBkk4jT/19+Rgmc44eZkOvMO6daoSFIw==} + svelte@5.51.5: + resolution: {integrity: sha512-/4tR5cLsWOgH3wnNRXnFoWaJlwPGbJanZPSKSD6nHM2y01dvXeEF4Nx7jevoZ+UpJpkIHh6mY2tqDncuI4GHng==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11428,10 +11370,6 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -11439,10 +11377,6 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - tr46@3.0.0: - resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} - engines: {node: '>=12'} - tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -11667,10 +11601,6 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -11714,9 +11644,6 @@ packages: file-loader: optional: true - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -11913,10 +11840,6 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - w3c-xmlserializer@4.0.0: - resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} - engines: {node: '>=14'} - w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -12014,11 +11937,6 @@ packages: resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} engines: {node: '>=0.8.0'} - whatwg-encoding@2.0.0: - resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} - engines: {node: '>=12'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -12032,10 +11950,6 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} - whatwg-url@11.0.0: - resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} - engines: {node: '>=12'} - whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -12148,10 +12062,6 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true - xml-name-validator@4.0.0: - resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} - engines: {node: '>=12'} - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -14906,22 +14816,22 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.2)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.51.5)': dependencies: front-matter: 4.0.2 marked: 17.0.3 node-emoji: 2.2.0 - svelte: 5.50.2 + svelte: 5.51.5 - '@immich/ui@0.63.0(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)': + '@immich/ui@0.64.0(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.2) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.51.5) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) luxon: 3.7.2 simple-icons: 16.9.0 - svelte: 5.50.2 + svelte: 5.51.5 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) @@ -15326,7 +15236,7 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.13 - acorn: 8.15.0 + acorn: 8.16.0 collapse-white-space: 2.1.0 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 @@ -15335,7 +15245,7 @@ snapshots: hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.15.0) + recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 rehype-recma: 1.0.0 remark-mdx: 3.1.1 @@ -16252,21 +16162,21 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)': + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))': dependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) - '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.50.2 - svelte-parse-markup: 0.1.5(svelte@5.50.2) + svelte: 5.51.5 + svelte-parse-markup: 0.1.5(svelte@5.51.5) vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite-imagetools: 9.0.2(rollup@4.55.1) zimmerframe: 1.1.4 @@ -16274,44 +16184,43 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@types/cookie': 0.6.0 - acorn: 8.15.0 + acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.6.2 + devalue: 5.6.3 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 mrmime: 2.0.1 - sade: 1.8.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.50.2 + svelte: 5.51.5 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@opentelemetry/api': 1.9.0 typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) debug: 4.4.3 - svelte: 5.50.2 + svelte: 5.51.5 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.50.2 + svelte: 5.51.5 vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitefu: 1.1.1(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: @@ -16559,15 +16468,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.50.2)': + '@testing-library/svelte-core@1.0.0(svelte@5.51.5)': dependencies: - svelte: 5.50.2 + svelte: 5.51.5 - '@testing-library/svelte@5.3.1(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@testing-library/svelte@5.3.1(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.50.2) - svelte: 5.50.2 + '@testing-library/svelte-core': 1.0.0(svelte@5.51.5) + svelte: 5.51.5 optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -16585,9 +16494,6 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@tootallnate/once@2.0.0': - optional: true - '@trysound/sax@0.2.0': {} '@turf/boolean-point-in-polygon@7.3.2': @@ -17148,8 +17054,7 @@ snapshots: dependencies: '@types/node': 24.10.13 - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/ua-parser-js@0.7.39': {} @@ -17438,13 +17343,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.50.2)': + '@zoom-image/svelte@0.3.9(svelte@5.51.5)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.50.2 - - abab@2.0.6: - optional: true + svelte: 5.51.5 abbrev@1.1.1: {} @@ -17464,29 +17366,23 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-globals@7.0.1: + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: - acorn: 8.15.0 - acorn-walk: 8.3.4 - optional: true + acorn: 8.16.0 - acorn-import-attributes@1.9.5(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 - - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk@8.3.4: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} address@1.2.2: {} @@ -17809,15 +17705,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): + bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 '@internationalized/date': 3.10.0 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) - svelte: 5.50.2 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) + svelte: 5.51.5 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -18589,17 +18485,6 @@ snapshots: dependencies: css-tree: 2.2.1 - cssom@0.3.8: - optional: true - - cssom@0.5.0: - optional: true - - cssstyle@2.3.0: - dependencies: - cssom: 0.3.8 - optional: true - cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -18797,13 +18682,6 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 - data-urls@3.0.2: - dependencies: - abab: 2.0.6 - whatwg-mimetype: 3.0.0 - whatwg-url: 11.0.0 - optional: true - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -18918,7 +18796,7 @@ snapshots: transitivePeerDependencies: - supports-color - devalue@5.6.2: {} + devalue@5.6.3: {} devlop@1.1.0: dependencies: @@ -19020,11 +18898,6 @@ snapshots: domelementtype@2.3.0: {} - domexception@4.0.0: - dependencies: - webidl-conversions: 7.0.0 - optional: true - domhandler@4.3.1: dependencies: domelementtype: 2.3.0 @@ -19206,7 +19079,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.15.0 + acorn: 8.16.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 @@ -19306,15 +19179,6 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - optional: true - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -19341,7 +19205,7 @@ snapshots: '@types/eslint': 9.6.1 eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2): + eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.51.5): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -19353,9 +19217,9 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.6) postcss-safe-parser: 7.0.1(postcss@8.5.6) semver: 7.7.4 - svelte-eslint-parser: 1.4.1(svelte@5.50.2) + svelte-eslint-parser: 1.4.1(svelte@5.51.5) optionalDependencies: - svelte: 5.50.2 + svelte: 5.51.5 transitivePeerDependencies: - ts-node @@ -19447,8 +19311,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -19653,10 +19517,10 @@ snapshots: extend@3.0.2: {} - fabric@6.9.1: + fabric@7.2.0: optionalDependencies: canvas: 2.11.2 - jsdom: 20.0.3(canvas@2.11.2) + jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - bufferutil - encoding @@ -20294,11 +20158,6 @@ snapshots: readable-stream: 2.3.8 wbuf: 1.7.3 - html-encoding-sniffer@3.0.0: - dependencies: - whatwg-encoding: 2.0.0 - optional: true - html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -20396,15 +20255,6 @@ snapshots: http-parser-js@0.5.10: {} - http-proxy-agent@5.0.0: - dependencies: - '@tootallnate/once': 2.0.0 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - optional: true - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -20498,8 +20348,8 @@ snapshots: import-in-the-middle@2.0.0: dependencies: - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 @@ -20805,42 +20655,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@20.0.3(canvas@2.11.2): - dependencies: - abab: 2.0.6 - acorn: 8.15.0 - acorn-globals: 7.0.1 - cssom: 0.5.0 - cssstyle: 2.3.0 - data-urls: 3.0.2 - decimal.js: 10.6.0 - domexception: 4.0.0 - escodegen: 2.1.0 - form-data: 4.0.5 - html-encoding-sniffer: 3.0.0 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 - parse5: 7.3.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 4.1.4 - w3c-xmlserializer: 4.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 2.0.0 - whatwg-mimetype: 3.0.0 - whatwg-url: 11.0.0 - ws: 8.19.0 - xml-name-validator: 4.0.0 - optionalDependencies: - canvas: 2.11.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)): dependencies: cssstyle: 4.6.0 @@ -21696,8 +21510,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -21974,7 +21788,7 @@ snapshots: mlly@1.8.0: dependencies: - acorn: 8.15.0 + acorn: 8.16.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.2 @@ -23112,10 +22926,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.2): + prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.51.5): dependencies: prettier: 3.8.1 - svelte: 5.50.2 + svelte: 5.51.5 prettier@3.8.1: {} @@ -23217,11 +23031,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - optional: true - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -23245,9 +23054,6 @@ snapshots: dependencies: side-channel: 1.1.0 - querystringify@2.2.0: - optional: true - queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -23432,10 +23238,10 @@ snapshots: estree-util-build-jsx: 3.0.1 vfile: 6.0.3 - recma-jsx@1.0.1(acorn@8.15.0): + recma-jsx@1.0.1(acorn@8.16.0): dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) estree-util-to-js: 2.0.0 recma-parse: 1.0.0 recma-stringify: 1.0.0 @@ -23740,14 +23546,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): + runed@0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.50.2 + svelte: 5.51.5 optionalDependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/kit': 2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) rw@1.3.3: {} @@ -24373,23 +24179,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.50.2): + svelte-awesome@3.3.5(svelte@5.51.5): dependencies: - svelte: 5.50.2 + svelte: 5.51.5 - svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3): + svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.51.5)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.3) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.50.2 + svelte: 5.51.5 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.50.2): + svelte-eslint-parser@1.4.1(svelte@5.51.5): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -24398,7 +24204,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.50.2 + svelte: 5.51.5 svelte-floating-ui@1.5.8: dependencies: @@ -24411,7 +24217,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.50.2): + svelte-i18n@4.0.1(svelte@5.51.5): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24419,10 +24225,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.50.2 + svelte: 5.51.5 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.50.2): + svelte-jsoneditor@3.11.0(svelte@5.51.5): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24449,52 +24255,53 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.50.2 - svelte-awesome: 3.3.5(svelte@5.50.2) + svelte: 5.51.5 + svelte-awesome: 3.3.5(svelte@5.51.5) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.50.2): + svelte-maplibre@1.2.6(svelte@5.51.5): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.18.0 pmtiles: 3.2.1 - svelte: 5.50.2 + svelte: 5.51.5 - svelte-parse-markup@0.1.5(svelte@5.50.2): + svelte-parse-markup@0.1.5(svelte@5.51.5): dependencies: - svelte: 5.50.2 + svelte: 5.51.5 - svelte-persisted-store@0.12.0(svelte@5.50.2): + svelte-persisted-store@0.12.0(svelte@5.51.5): dependencies: - svelte: 5.50.2 + svelte: 5.51.5 svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.2)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.50.2) + runed: 0.35.1(@sveltejs/kit@2.52.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.51.5)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.51.5) style-to-object: 1.0.14 - svelte: 5.50.2 + svelte: 5.51.5 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.50.2: + svelte@5.51.5: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) '@types/estree': 1.0.8 - acorn: 8.15.0 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.6.2 + devalue: 5.6.3 esm-env: 1.2.2 esrap: 2.2.3 is-reference: 3.0.3 @@ -24667,7 +24474,7 @@ snapshots: terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 + acorn: 8.16.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -24794,14 +24601,6 @@ snapshots: totalist@3.0.1: {} - tough-cookie@4.1.4: - dependencies: - psl: 1.15.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - optional: true - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -24809,11 +24608,6 @@ snapshots: tr46@0.0.3: {} - tr46@3.0.0: - dependencies: - punycode: 2.3.1 - optional: true - tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -25035,9 +24829,6 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - universalify@0.2.0: - optional: true - universalify@2.0.1: {} unpipe@1.0.0: {} @@ -25054,7 +24845,7 @@ snapshots: unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.15.0 + acorn: 8.16.0 picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 @@ -25096,12 +24887,6 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@5.104.1) - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - optional: true - url@0.11.4: dependencies: punycode: 1.4.1 @@ -25432,11 +25217,6 @@ snapshots: w3c-keyname@2.2.8: {} - w3c-xmlserializer@4.0.0: - dependencies: - xml-name-validator: 4.0.0 - optional: true - w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -25467,7 +25247,7 @@ snapshots: webpack-bundle-analyzer@4.10.2: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.15.0 + acorn: 8.16.0 acorn-walk: 8.3.4 commander: 7.2.0 debounce: 1.2.1 @@ -25557,8 +25337,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 @@ -25589,8 +25369,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.15.0 - acorn-import-phases: 1.0.4(acorn@8.15.0) + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 enhanced-resolve: 5.19.0 @@ -25633,11 +25413,6 @@ snapshots: websocket-extensions@0.1.4: {} - whatwg-encoding@2.0.0: - dependencies: - iconv-lite: 0.6.3 - optional: true - whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -25648,12 +25423,6 @@ snapshots: whatwg-mimetype@4.0.0: optional: true - whatwg-url@11.0.0: - dependencies: - tr46: 3.0.0 - webidl-conversions: 7.0.0 - optional: true - whatwg-url@14.2.0: dependencies: tr46: 5.1.1 @@ -25741,9 +25510,6 @@ snapshots: dependencies: sax: 1.4.3 - xml-name-validator@4.0.0: - optional: true - xml-name-validator@5.0.0: optional: true diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 942d44f4c3..e45eeb23f3 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,9 +1,8 @@ import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; +import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { DownloadService } from 'src/services/download.service'; @@ -36,7 +35,7 @@ export class DownloadController { 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { + downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } } diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index 2f877e3c0b..ef52a72bd0 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsInt, IsPositive } from 'class-validator'; -import { Optional, ValidateUUID } from 'src/validation'; +import { AssetIdsDto } from 'src/dtos/asset.dto'; +import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DownloadInfoDto { @ValidateUUID({ each: true, optional: true, description: 'Asset IDs to download' }) @@ -32,3 +33,8 @@ export class DownloadArchiveInfo { @ApiProperty({ description: 'Asset IDs in this archive' }) assetIds!: string[]; } + +export class DownloadArchiveDto extends AssetIdsDto { + @ValidateBoolean({ optional: true, description: 'Download edited asset if available' }) + edited?: boolean; +} diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts index 8bb1eef47b..3c4c063b10 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -118,7 +118,15 @@ export class AssetEditActionListDto { Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits, ) @ApiProperty({ - anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })), + items: { + anyOf: Object.values(actionToClass).map((type) => ({ $ref: getSchemaPath(type) })), + discriminator: { + propertyName: 'action', + mapping: Object.fromEntries( + Object.entries(actionToClass).map(([action, type]) => [action, getSchemaPath(type)]), + ), + }, + }, description: 'List of edit actions to apply (crop, rotate, or mirror)', }) edits!: AssetEditActionItem[]; diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 0f3a458c35..4b8323cd59 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -587,6 +587,7 @@ where -- AssetRepository.getForOriginal select + "asset"."id", "originalFileName", "asset_file"."path" as "editedPath", "originalPath" @@ -596,7 +597,21 @@ from and "asset_file"."isEdited" = $1 and "asset_file"."type" = $2 where - "asset"."id" = $3 + "asset"."id" in ($3) + +-- AssetRepository.getForOriginals +select + "asset"."id", + "originalFileName", + "asset_file"."path" as "editedPath", + "originalPath" +from + "asset" + left join "asset_file" on "asset"."id" = "asset_file"."assetId" + and "asset_file"."isEdited" = $1 + and "asset_file"."type" = $2 +where + "asset"."id" in ($3) -- AssetRepository.getForThumbnail select @@ -621,3 +636,98 @@ from where "asset"."id" = $1 and "asset"."type" = $2 + +-- AssetRepository.getForOcr +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForEdit +select + "asset"."type", + "asset"."livePhotoVideoId", + "asset"."originalPath", + "asset"."originalFileName", + "asset_exif"."exifImageWidth", + "asset_exif"."exifImageHeight", + "asset_exif"."orientation", + "asset_exif"."projectionType" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForMetadataExtractionTags +select + "asset_exif"."tags" +from + "asset_exif" +where + "asset_exif"."assetId" = $1 + +-- AssetRepository.getForFaces +select + "asset_exif"."exifImageHeight", + "asset_exif"."exifImageWidth", + "asset_exif"."orientation", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits" +from + "asset" + inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id" +where + "asset"."id" = $1 + +-- AssetRepository.getForUpdateTags +select + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tag"."value" + from + "tag" + inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId" + where + "asset"."id" = "tag_asset"."assetId" + ) as agg + ) as "tags" +from + "asset" +where + "asset"."id" = $1 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 59f0f12424..964aaaccee 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -286,19 +286,6 @@ from -- PersonRepository.getFacesByIds select "asset_face".*, - ( - select - to_json(obj) - from - ( - select - "asset".* - from - "asset" - where - "asset"."id" = "asset_face"."assetId" - ) as obj - ) as "asset", ( select to_json(obj) @@ -355,3 +342,14 @@ from "person" where "id" in ($1) + +-- PersonRepository.getForFeatureFaceUpdate +select + "asset_face"."id" +from + "asset_face" + inner join "asset" on "asset"."id" = "asset_face"."assetId" + and "asset"."isOffline" = $1 +where + "asset_face"."assetId" = $2 + and "asset_face"."personId" = $3 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 6d20245a22..4d82b4433f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; @@ -1009,12 +1010,12 @@ export class AssetRepository { return count; } - @GenerateSql({ params: [DummyValue.UUID, true] }) - async getForOriginal(id: string, isEdited: boolean) { + private buildGetForOriginal(ids: string[], isEdited: boolean) { return this.db .selectFrom('asset') + .select('asset.id') .select('originalFileName') - .where('asset.id', '=', id) + .where('asset.id', 'in', ids) .$if(isEdited, (qb) => qb .leftJoin('asset_file', (join) => @@ -1025,8 +1026,17 @@ export class AssetRepository { ) .select('asset_file.path as editedPath'), ) - .select('originalPath') - .executeTakeFirstOrThrow(); + .select('originalPath'); + } + + @GenerateSql({ params: [DummyValue.UUID, true] }) + getForOriginal(id: string, isEdited: boolean) { + return this.buildGetForOriginal([id], isEdited).executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [[DummyValue.UUID], true] }) + getForOriginals(ids: string[], isEdited: boolean) { + return this.buildGetForOriginal(ids, isEdited).execute(); } @GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview, true] }) @@ -1051,4 +1061,68 @@ export class AssetRepository { .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForOcr(id: string) { + return this.db + .selectFrom('asset') + .where('asset.id', '=', id) + .select(withEdits) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select(['asset_exif.exifImageWidth', 'asset_exif.exifImageHeight', 'asset_exif.orientation']) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForEdit(id: string) { + return this.db + .selectFrom('asset') + .select(['asset.type', 'asset.livePhotoVideoId', 'asset.originalPath', 'asset.originalFileName']) + .where('asset.id', '=', id) + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select([ + 'asset_exif.exifImageWidth', + 'asset_exif.exifImageHeight', + 'asset_exif.orientation', + 'asset_exif.projectionType', + ]) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForMetadataExtractionTags(id: string) { + return this.db + .selectFrom('asset_exif') + .select('asset_exif.tags') + .where('asset_exif.assetId', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForFaces(id: string) { + return this.db + .selectFrom('asset') + .innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id')) + .select(['asset_exif.exifImageHeight', 'asset_exif.exifImageWidth', 'asset_exif.orientation']) + .select(withEdits) + .where('asset.id', '=', id) + .executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getForUpdateTags(id: string) { + return this.db + .selectFrom('asset') + .select((eb) => + jsonArrayFrom( + eb + .selectFrom('tag') + .select('tag.value') + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId') + .whereRef('asset.id', '=', 'tag_asset.assetId'), + ).as('tags'), + ) + .where('asset.id', '=', id) + .executeTakeFirstOrThrow(); + } } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 17647d065d..650820b18e 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -248,11 +248,11 @@ export class DatabaseRepository { } const dimSize = await this.getDimensionSize(table); lists ||= this.targetListCount(await this.getRowCount(table)); - await this.db.schema.dropIndex(indexName).ifExists().execute(); - if (table === 'smart_search') { - await this.db.schema.alterTable(table).dropConstraint('dim_size_constraint').ifExists().execute(); - } await this.db.transaction().execute(async (tx) => { + await sql`DROP INDEX IF EXISTS ${sql.raw(indexName)}`.execute(tx); + if (table === 'smart_search') { + await sql`ALTER TABLE ${sql.raw(table)} DROP CONSTRAINT IF EXISTS dim_size_constraint`.execute(tx); + } if (!rows.some((row) => row.columnName === 'embedding')) { this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`); await sql`TRUNCATE TABLE ${sql.raw(table)}`.execute(tx); diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 85e75483c5..00156a2492 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; @@ -485,12 +485,6 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select((eb) => - jsonObjectFrom(eb.selectFrom('asset').selectAll('asset').whereRef('asset.id', '=', 'asset_face.assetId')).as( - 'asset', - ), - ) - .$narrowType<{ asset: NotNull }>() .select(withPerson) .where('asset_face.assetId', 'in', assetIds) .where('asset_face.personId', 'in', personIds) @@ -583,4 +577,15 @@ export class PersonRepository { } }); } + + @GenerateSql({ params: [{ personId: DummyValue.UUID, assetId: DummyValue.UUID }] }) + getForFeatureFaceUpdate({ personId, assetId }: { personId: string; assetId: string }) { + return this.db + .selectFrom('asset_face') + .select('asset_face.id') + .where('asset_face.assetId', '=', assetId) + .where('asset_face.personId', '=', personId) + .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) + .executeTakeFirst(); + } } diff --git a/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts b/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts new file mode 100644 index 0000000000..f09257a3ce --- /dev/null +++ b/server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX "asset_id_timeline_notDeleted_idx" ON "asset" ("id") WHERE visibility = 'timeline' AND "deletedAt" IS NULL;`.execute(db); + await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE "deletedAt" IS NULL AND "isVisible" IS TRUE;`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_id_timeline_notDeleted_idx', '{"type":"index","name":"asset_id_timeline_notDeleted_idx","sql":"CREATE INDEX \\"asset_id_timeline_notDeleted_idx\\" ON \\"asset\\" (\\"id\\") WHERE visibility = ''timeline'' AND \\"deletedAt\\" IS NULL;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE \\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE;"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "asset_id_timeline_notDeleted_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_id_timeline_notDeleted_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db); +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 8b156f2a17..8a3b3ac611 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -27,6 +27,11 @@ import { }) // schemaFromDatabase does not preserve column order @Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) +@Index({ + name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', + columns: ['personId', 'assetId'], + where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', +}) @Index({ columns: ['personId', 'assetId'] }) export class AssetFaceTable { @PrimaryGeneratedColumn() diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 0b3da710ac..765a2900e5 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -55,6 +55,11 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; using: 'gin', expression: 'f_unaccent("originalFileName") gin_trgm_ops', }) +@Index({ + name: 'asset_id_timeline_notDeleted_idx', + columns: ['id'], + where: `visibility = 'timeline' AND "deletedAt" IS NULL`, +}) // For all assets, each originalpath must be unique per user and library export class AssetTable { @PrimaryGeneratedColumn() diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 84440fd4b6..5fb45690cf 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -110,6 +110,7 @@ const validVideos = [ '.mp4', '.mpg', '.mts', + '.mxf', '.vob', '.webm', '.wmv', diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index b677881cfe..db895f8321 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -660,7 +660,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([ocr1, ocr2]); @@ -676,7 +676,7 @@ describe(AssetService.name, () => { const asset = AssetFactory.from().exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.ocr.getByAssetId.mockResolvedValue([]); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...asset.exifInfo }); await expect(sut.getOcr(authStub.admin, asset.id)).resolves.toEqual([]); expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith(asset.id); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ed427684f1..f6098248ed 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -404,15 +404,19 @@ export class AssetService extends BaseService { async getOcr(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const ocr = await this.ocrRepository.getByAssetId(id); - const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true }); + const asset = await this.assetRepository.getForOcr(id); - if (!asset || !asset.exifInfo || !asset.edits) { + if (!asset) { throw new BadRequestException('Asset not found'); } - const dimensions = getDimensions(asset.exifInfo); + const dimensions = getDimensions({ + exifImageHeight: asset.exifImageHeight, + exifImageWidth: asset.exifImageWidth, + orientation: asset.orientation, + }); - return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions)); + return ocr.map((item) => transformOcrBoundingBox(item, asset.edits, dimensions)); } async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { @@ -551,7 +555,7 @@ export class AssetService extends BaseService { async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] }); - const asset = await this.assetRepository.getById(id, { exifInfo: true }); + const asset = await this.assetRepository.getForEdit(id); if (!asset) { throw new BadRequestException('Asset not found'); } @@ -576,15 +580,21 @@ export class AssetService extends BaseService { throw new BadRequestException('Editing SVG images is not supported'); } + // check that crop parameters will not go out of bounds + const { width: assetWidth, height: assetHeight } = getDimensions(asset); + + if (!assetWidth || !assetHeight) { + throw new BadRequestException('Asset dimensions are not available for editing'); + } + const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop); if (cropIndex > 0) { throw new BadRequestException('Crop action must be the first edit action'); } - const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop); if (crop) { // check that crop parameters will not go out of bounds - const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!); + const { width: assetWidth, height: assetHeight } = getDimensions(asset); if (!assetWidth || !assetHeight) { throw new BadRequestException('Asset dimensions are not available for editing'); diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index 9ca37200b7..429e60aede 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -554,7 +554,7 @@ describe(DatabaseBackupService.name, () => { "bin": "/usr/lib/postgresql/14/bin/psql", "databaseMajorVersion": 14, "databasePassword": "", - "databaseUsername": "", + "databaseUsername": "postgres", "databaseVersion": "14.10 (Debian 14.10-1.pgdg120+1)", } `); diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index de7090fa83..3c964c950c 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -139,7 +139,8 @@ export class DatabaseBackupService { // remove known bad parameters parsedUrl.searchParams.delete('uselibpqcompat'); - databaseUsername = parsedUrl.username; + databaseUsername = parsedUrl.username || parsedUrl.searchParams.get('user'); + url = parsedUrl.toString(); } diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index ae010623d8..1ae1b0b4d8 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -39,7 +39,7 @@ describe(DownloadService.name, () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id, 'unknown-asset'])); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id, 'unknown-asset'] })).resolves.toEqual({ @@ -62,7 +62,7 @@ describe(DownloadService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -86,7 +86,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -108,7 +108,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -130,7 +130,7 @@ describe(DownloadService.name, () => { const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); - mocks.asset.getByIds.mockResolvedValue([asset2, asset1]); + mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ @@ -151,7 +151,7 @@ describe(DownloadService.name, () => { const asset = AssetFactory.create({ originalPath: '/path/to/symlink.jpg' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.asset.getForOriginals.mockResolvedValue([asset]); mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg'); mocks.storage.createZipStream.mockReturnValue(archiveMock); diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index a5f734e59c..8d939e9635 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,9 +1,8 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; -import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; +import { DownloadArchiveDto, DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { Permission } from 'src/enum'; import { ImmichReadStream } from 'src/repositories/storage.repository'; import { BaseService } from 'src/services/base.service'; @@ -80,11 +79,11 @@ export class DownloadService extends BaseService { return { totalSize, archives }; } - async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { + async downloadArchive(auth: AuthDto, dto: DownloadArchiveDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); - const assets = await this.assetRepository.getByIds(dto.assetIds); + const assets = await this.assetRepository.getForOriginals(dto.assetIds, dto.edited ?? false); const assetMap = new Map(assets.map((asset) => [asset.id, asset])); const paths: Record = {}; @@ -94,7 +93,7 @@ export class DownloadService extends BaseService { continue; } - const { originalPath, originalFileName } = asset; + const { originalPath, editedPath, originalFileName } = asset; let filename = originalFileName; const count = paths[filename] || 0; @@ -104,9 +103,10 @@ export class DownloadService extends BaseService { filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`; } - let realpath = originalPath; + let realpath = dto.edited && editedPath ? editedPath : originalPath; + try { - realpath = await this.storageRepository.realpath(originalPath); + realpath = await this.storageRepository.realpath(realpath); } catch { this.logger.warn('Unable to resolve realpath', { originalPath }); } diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 00b658f470..c4f2384be2 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -248,9 +248,11 @@ export class LibraryService extends BaseService { return JobStatus.Failed; } + const newPaths = await this.assetRepository.filterNewExternalAssetPaths(library.id, job.paths); + const assetImports: Insertable[] = []; await Promise.all( - job.paths.map((path) => + newPaths.map((path) => this.processEntity(path, library.ownerId, job.libraryId) .then((asset) => assetImports.push(asset)) .catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)), @@ -654,7 +656,7 @@ export class LibraryService extends BaseService { const paths: string[] = []; for (const item of walkItems) { if (item.type === 'error') { - this.logger.warn(`Error walking ${item.path ?? 'unknown path'}: ${item.message}`); + this.logger.warn(`Error walking ${item.path ?? 'unknown path'}: ${item.message} for library ${library.id}`); } else { paths.push(item.path); } diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index bf2cbc62fa..399eb5d6a0 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -21,8 +21,8 @@ import { } from 'src/enum'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; -import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; @@ -108,7 +108,7 @@ describe(MediaService.name, () => { it('should queue all people with missing thumbnail path', async () => { mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail])); - mocks.person.getRandomFace.mockResolvedValueOnce(faceStub.face1); + mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create()); await sut.handleQueueGenerateThumbnails({ force: false }); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 7a5d5037fe..1080407922 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -382,11 +382,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from TagsList', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -396,11 +394,9 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from TagsList', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -420,11 +416,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a string', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -434,11 +428,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] }); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -448,11 +440,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from Keywords as a list with a number', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent', '2024'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -463,11 +453,9 @@ describe(MetadataService.name, () => { }); it('should extract hierarchal tags from Keywords', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] }); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -485,11 +473,9 @@ describe(MetadataService.name, () => { }); it('should ignore Keywords when TagsList is present', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child', 'Child'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Child'] }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -508,11 +494,9 @@ describe(MetadataService.name, () => { }); it('should extract hierarchy from HierarchicalSubject', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent/Child', 'TagA'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'TagA'] }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -537,11 +521,9 @@ describe(MetadataService.name, () => { }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - const asset = AssetFactory.from() - .exif({ tags: ['Parent', '2024'] }) - .build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(asset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -554,7 +536,7 @@ describe(MetadataService.name, () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => { const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) }); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -568,11 +550,9 @@ describe(MetadataService.name, () => { }); it('should ignore HierarchicalSubject when TagsList is present', async () => { - const baseAsset = AssetFactory.from(); - const asset = baseAsset.build(); - const updatedAsset = baseAsset.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }).build(); + const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); - mocks.asset.getById.mockResolvedValue(updatedAsset); + mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index dac79343e0..983d905aad 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -289,8 +289,8 @@ export class MetadataService extends BaseService { colorspace: exifTags.ColorSpace ?? null, // camera - make: exifTags.Make ?? exifTags?.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, - model: exifTags.Model ?? exifTags?.Device?.ModelName ?? exifTags.AndroidModel ?? null, + make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null, + model: exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? null, fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, @@ -590,10 +590,10 @@ export class MetadataService extends BaseService { } private async applyTagList({ id, ownerId }: { id: string; ownerId: string }) { - const asset = await this.assetRepository.getById(id, { exifInfo: true }); + const asset = await this.assetRepository.getForMetadataExtractionTags(id); const results = await upsertTags(this.tagRepository, { userId: ownerId, - tags: asset?.exifInfo?.tags ?? [], + tags: asset?.tags ?? [], }); await this.tagRepository.replaceAssetTags( id, diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 0928b57f97..d7c9fa9f59 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,16 +2,19 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; -import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PersonFactory } from 'test/factories/person.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory } from 'test/small.factory'; +import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers'; +import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const responseDto: PersonResponseDto = { @@ -27,35 +30,6 @@ const responseDto: PersonResponseDto = { const statistics = { assets: 3 }; -const faceId = 'face-id'; -const face = { - id: faceId, - assetId: 'asset-id', - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageHeight: 500, - imageWidth: 400, -}; -const faceSearch = { faceId, embedding: '[1, 2, 3, 4]' }; -const detectFaceMock: DetectedFaces = { - faces: [ - { - boundingBox: { - x1: face.boundingBoxX1, - y1: face.boundingBoxY1, - x2: face.boundingBoxX2, - y2: face.boundingBoxY2, - }, - embedding: faceSearch.embedding, - score: 0.2, - }, - ], - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, -}; - describe(PersonService.name, () => { let sut: PersonService; let mocks: ServiceMocks; @@ -259,27 +233,25 @@ describe(PersonService.name, () => { }); it("should update a person's thumbnailPath", async () => { + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); mocks.person.update.mockResolvedValue(personStub.withName); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect( - sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), - ).resolves.toEqual(responseDto); + await expect(sut.update(auth, 'person-1', { featureFaceAssetId: face.assetId })).resolves.toEqual(responseDto); - expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); - expect(mocks.person.getFacesByIds).toHaveBeenCalledWith([ - { - assetId: faceStub.face1.assetId, - personId: 'person-1', - }, - ]); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: face.id }); + expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ + assetId: face.assetId, + personId: 'person-1', + }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonGenerateThumbnail, data: { id: 'person-1' }, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['person-1'])); }); it('should throw an error when the face feature assetId is invalid', async () => { @@ -319,19 +291,21 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should reassign a face', async () => { + const face = AssetFaceFactory.create(); + const auth = AuthFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); mocks.person.getById.mockResolvedValue(personStub.noName); - mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); + mocks.person.getFacesByIds.mockResolvedValue([face]); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); mocks.person.refreshFaces.mockResolvedValue(); mocks.person.reassignFace.mockResolvedValue(5); mocks.person.update.mockResolvedValue(personStub.noName); await expect( - sut.reassignFaces(authStub.admin, personStub.noName.id, { - data: [{ personId: personStub.withName.id, assetId: faceStub.face1.assetId }], + sut.reassignFaces(auth, personStub.noName.id, { + data: [{ personId: personStub.withName.id, assetId: face.assetId }], }), ).resolves.toBeDefined(); @@ -352,18 +326,20 @@ describe(PersonService.name, () => { describe('getFacesById', () => { it('should get the bounding boxes for an asset', async () => { - const asset = AssetFactory.from({ id: faceStub.face1.assetId }).exif().build(); + const auth = AuthFactory.create(); + const face = AssetFaceFactory.create(); + const asset = AssetFactory.from({ id: face.assetId }).exif().build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); - mocks.asset.getById.mockResolvedValue(asset); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ - mapFaces(faceStub.primaryFace1, authStub.admin), - ]); + mocks.person.getFaces.mockResolvedValue([face]); + mocks.asset.getForFaces.mockResolvedValue({ edits: [], ...asset.exifInfo }); + await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([mapFaces(face, auth)]); }); + it('should reject if the user has not access to the asset', async () => { + const face = AssetFaceFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); - mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( + mocks.person.getFaces.mockResolvedValue([face]); + await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf( BadRequestException, ); }); @@ -371,7 +347,7 @@ describe(PersonService.name, () => { describe('createNewFeaturePhoto', () => { it('should change person feature photo', async () => { - mocks.person.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -384,38 +360,38 @@ describe(PersonService.name, () => { describe('reassignFacesById', () => { it('should create a new person', async () => { + const face = AssetFaceFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); - mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); + mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(personStub.noName); - await expect( - sut.reassignFacesById(authStub.admin, personStub.noName.id, { - id: faceStub.face1.id, - }), - ).resolves.toEqual({ - birthDate: personStub.noName.birthDate, - isHidden: personStub.noName.isHidden, - isFavorite: personStub.noName.isFavorite, - id: personStub.noName.id, - name: personStub.noName.name, - thumbnailPath: personStub.noName.thumbnailPath, - updatedAt: expect.any(Date), - color: personStub.noName.color, - }); + await expect(sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id })).resolves.toEqual( + { + birthDate: personStub.noName.birthDate, + isHidden: personStub.noName.isHidden, + isFavorite: personStub.noName.isFavorite, + id: personStub.noName.id, + name: personStub.noName.name, + thumbnailPath: personStub.noName.thumbnailPath, + updatedAt: expect.any(Date), + color: personStub.noName.color, + }, + ); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); it('should fail if user has not the correct permissions on the asset', async () => { + const face = AssetFaceFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); - mocks.person.getFaceById.mockResolvedValue(faceStub.face1); + mocks.person.getFaceById.mockResolvedValue(face); mocks.person.reassignFace.mockResolvedValue(1); mocks.person.getById.mockResolvedValue(personStub.noName); await expect( - sut.reassignFacesById(authStub.admin, personStub.noName.id, { - id: faceStub.face1.id, + sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { + id: face.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -513,8 +489,9 @@ describe(PersonService.name, () => { it('should delete existing people and faces if forced', async () => { const asset = AssetFactory.create(); - mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + const face = AssetFaceFactory.from().person().build(); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.deleteFaces.mockResolvedValue(); @@ -568,6 +545,7 @@ describe(PersonService.name, () => { }); it('should queue missing assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -576,7 +554,7 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({}); @@ -588,7 +566,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -598,6 +576,7 @@ describe(PersonService.name, () => { }); it('should queue all assets', async () => { + const face = AssetFaceFactory.create(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -607,7 +586,7 @@ describe(PersonService.name, () => { delayed: 0, }); mocks.person.getAll.mockReturnValue(makeStream()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true }); @@ -616,7 +595,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -626,8 +605,9 @@ describe(PersonService.name, () => { }); it('should run nightly if new face has been added since last run', async () => { + const face = AssetFaceFactory.create(); mocks.person.getLatestFaceDate.mockResolvedValue(new Date().toISOString()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -637,7 +617,7 @@ describe(PersonService.name, () => { delayed: 0, }); mocks.person.getAll.mockReturnValue(makeStream()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); mocks.person.unassignFaces.mockResolvedValue(); @@ -652,7 +632,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { @@ -666,7 +646,7 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ lastRun: lastRun.toISOString() }); mocks.person.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString()); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAllFaces.mockReturnValue(makeStream([AssetFaceFactory.create()])); mocks.person.getAllWithoutFaces.mockResolvedValue([]); await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); @@ -680,6 +660,7 @@ describe(PersonService.name, () => { }); it('should delete existing people if forced', async () => { + const face = AssetFaceFactory.from().person().build(); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, @@ -688,8 +669,8 @@ describe(PersonService.name, () => { failed: 0, delayed: 0, }); - mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); - mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); + mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson])); + mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.unassignFaces.mockResolvedValue(); @@ -700,7 +681,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognition, - data: { id: faceStub.face1.id, deferred: false }, + data: { id: face.id, deferred: false }, }, ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); @@ -710,10 +691,6 @@ describe(PersonService.name, () => { }); describe('handleDetectFaces', () => { - beforeEach(() => { - mocks.crypto.randomUUID.mockReturnValue(faceId); - }); - it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -753,85 +730,104 @@ describe(PersonService.name, () => { it('should create a face with no person and queue recognition job', async () => { const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); - mocks.search.searchFaces.mockResolvedValue([{ ...faceStub.face1, distance: 0.7 }]); + const face = AssetFaceFactory.create({ assetId: asset.id }); + mocks.crypto.randomUUID.mockReturnValue(face.id); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); + mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should delete an existing face not among the new detected faces', async () => { - const asset = AssetFactory.from().face(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); + const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build(); mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [asset.faces[0].id], []); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add new face and delete an existing face not among the new detected faces', async () => { - const asset = AssetFactory.from().face(faceStub.primaryFace1).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + const assetId = newUuid(); + const face = AssetFaceFactory.create({ + assetId, + boundingBoxX1: 200, + boundingBoxX2: 300, + boundingBoxY1: 200, + boundingBoxY2: 300, + }); + const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.crypto.randomUUID.mockReturnValue(face.id); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( - [{ ...face, assetId: asset.id }], - [faceStub.primaryFace1.id], - [faceSearch], + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [asset.faces[0].id], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should add embedding to matching metadata face', async () => { - const asset = AssetFactory.from().face(faceStub.fromExif1).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + const face = AssetFaceFactory.create({ sourceType: SourceType.Exif }); + const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); mocks.person.refreshFaces.mockResolvedValue(); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith( - [], - [], - [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], - ); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [], [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }]); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); }); it('should not add embedding to non-matching metadata face', async () => { - const asset = AssetFactory.from().face(faceStub.fromExif2).file({ type: AssetFileType.Preview }).build(); - mocks.machineLearning.detectFaces.mockResolvedValue(detectFaceMock); + const assetId = newUuid(); + const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif }); + const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).build(); + mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset); + mocks.crypto.randomUUID.mockReturnValue(face.id); await sut.handleDetectFaces({ id: asset.id }); - expect(mocks.person.refreshFaces).toHaveBeenCalledWith([{ ...face, assetId: asset.id }], [], [faceSearch]); + expect(mocks.person.refreshFaces).toHaveBeenCalledWith( + [expect.objectContaining({ id: face.id, assetId: asset.id })], + [], + [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }], + ); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, - { name: JobName.FacialRecognition, data: { id: faceId } }, + { name: JobName.FacialRecognition, data: { id: face.id } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); @@ -840,153 +836,172 @@ describe(PersonService.name, () => { describe('handleRecognizeFaces', () => { it('should fail if face does not exist', async () => { - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); + expect(await sut.handleRecognizeFaces({ id: 'unknown-face' })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { - const face = { ...faceStub.face1, asset: null }; - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face); + const face = AssetFaceFactory.create(); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, null)); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); + expect(await sut.handleRecognizeFaces({ id: face.id })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1); + const asset = AssetFactory.create(); + const face = AssetFaceFactory.from({ assetId: asset.id }).person().build(); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Skipped); + expect(await sut.handleRecognizeFaces({ id: face.id })).toBe(JobStatus.Skipped); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); }); it('should match existing person', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + + const [noPerson1, noPerson2, primaryFace, face] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.create(), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person().build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.primaryFace1, distance: 0.2 }, - { ...faceStub.noPerson2, distance: 0.3 }, - { ...faceStub.face1, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...primaryFace, distance: 0.2 }, + { ...noPerson2, distance: 0.3 }, + { ...face, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(primaryFace.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.arrayContaining([noPerson1.id]), + newPersonId: primaryFace.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: primaryFace.person!.id, }); }); it('should match existing person if their birth date is unknown', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + const [noPerson, face, faceWithBirthDate] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.primaryFace1, distance: 0.2 }, - { ...faceStub.withBirthDate, distance: 0.3 }, + { ...noPerson, distance: 0 }, + { ...face, distance: 0.2 }, + { ...faceWithBirthDate, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.create.mockResolvedValue(face.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.arrayContaining([noPerson.id]), + newPersonId: face.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.primaryFace1.person.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: face.person!.id, }); }); it('should match existing person if their birth date is before file creation', async () => { - if (!faceStub.primaryFace1.person) { - throw new Error('faceStub.primaryFace1.person is null'); - } + const asset = AssetFactory.create(); + const [noPerson, face, faceWithBirthDate] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + ]; const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.withBirthDate, distance: 0.2 }, - { ...faceStub.primaryFace1, distance: 0.3 }, + { ...noPerson, distance: 0 }, + { ...faceWithBirthDate, distance: 0.2 }, + { ...face, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.create.mockResolvedValue(face.person!); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson.id }); expect(mocks.person.create).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.arrayContaining([faceStub.noPerson1.id]), - newPersonId: faceStub.withBirthDate.person?.id, + faceIds: expect.arrayContaining([noPerson.id]), + newPersonId: faceWithBirthDate.person!.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: expect.not.arrayContaining([faceStub.face1.id]), - newPersonId: faceStub.withBirthDate.person?.id, + faceIds: expect.not.arrayContaining([face.id]), + newPersonId: faceWithBirthDate.person!.id, }); }); it('should create a new person if the face is a core point with no person', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const person = PersonFactory.create(); + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.3 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.3 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(person); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).toHaveBeenCalledWith({ - ownerId: faceStub.noPerson1.asset.ownerId, - faceAssetId: faceStub.noPerson1.id, + ownerId: asset.ownerId, + faceAssetId: noPerson1.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - faceIds: [faceStub.noPerson1.id], - newPersonId: personStub.withName.id, + faceIds: [noPerson1.id], + newPersonId: person.id, }); }); it('should not queue face with no matches', async () => { - const faces = [{ ...faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; + const asset = AssetFactory.create(); + const face = AssetFaceFactory.create({ assetId: asset.id }); + const faces = [{ ...face, distance: 0 }] as FaceSearchResult[]; mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: face.id }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); @@ -995,21 +1010,24 @@ describe(PersonService.name, () => { }); it('should defer non-core faces to end of queue', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValue(faces); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognition, - data: { id: faceStub.noPerson1.id, deferred: true }, + data: { id: noPerson1.id, deferred: true }, }); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); expect(mocks.person.create).not.toHaveBeenCalled(); @@ -1017,17 +1035,20 @@ describe(PersonService.name, () => { }); it('should not assign person to deferred non-core face with no matching person', async () => { + const asset = AssetFactory.create(); + const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; + const faces = [ - { ...faceStub.noPerson1, distance: 0 }, - { ...faceStub.noPerson2, distance: 0.4 }, + { ...noPerson1, distance: 0 }, + { ...noPerson2, distance: 0.4 }, ] as FaceSearchResult[]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); - mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.noPerson1); - mocks.person.create.mockResolvedValue(personStub.withName); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.create.mockResolvedValue(PersonFactory.create()); - await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); + await sut.handleRecognizeFaces({ id: noPerson1.id, deferred: true }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(2); @@ -1152,26 +1173,30 @@ describe(PersonService.name, () => { describe('mapFace', () => { it('should map a face', () => { - const authDto = factory.auth({ user: { id: faceStub.face1.person.ownerId } }); - expect(mapFaces(faceStub.face1, authDto)).toEqual({ - boundingBoxX1: 0, - boundingBoxX2: 1, - boundingBoxY1: 0, - boundingBoxY2: 1, - id: faceStub.face1.id, - imageHeight: 1024, - imageWidth: 1024, + const user = UserFactory.create(); + const auth = AuthFactory.create({ id: user.id }); + const person = PersonFactory.create({ ownerId: user.id }); + const face = AssetFaceFactory.from().person(person).build(); + + expect(mapFaces(face, auth)).toEqual({ + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, + id: face.id, + imageHeight: 500, + imageWidth: 400, sourceType: SourceType.MachineLearning, - person: mapPerson(personStub.withName), + person: mapPerson(person), }); }); it('should not map person if person is null', () => { - expect(mapFaces({ ...faceStub.face1, person: null }, authStub.user1).person).toBeNull(); + expect(mapFaces(AssetFaceFactory.create(), AuthFactory.create()).person).toBeNull(); }); it('should not map person if person does not match auth user id', () => { - expect(mapFaces(faceStub.face1, authStub.user1).person).toBeNull(); + expect(mapFaces(AssetFaceFactory.from().person().build(), AuthFactory.create()).person).toBeNull(); }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e63dcedb7d..090b358223 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -128,10 +128,10 @@ export class PersonService extends BaseService { async getFacesById(auth: AuthDto, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); const faces = await this.personRepository.getFaces(dto.id); - const asset = await this.assetRepository.getById(dto.id, { edits: true, exifInfo: true }); - const assetDimensions = getDimensions(asset!.exifInfo!); + const asset = await this.assetRepository.getForFaces(dto.id); + const assetDimensions = getDimensions(asset); - return faces.map((face) => mapFaces(face, auth, asset!.edits!, assetDimensions)); + return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions)); } async createNewFeaturePhoto(changeFeaturePhoto: string[]) { @@ -197,13 +197,9 @@ export class PersonService extends BaseService { let faceId: string | undefined = undefined; if (assetId) { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] }); - const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); + const face = await this.personRepository.getForFeatureFaceUpdate({ personId: id, assetId }); if (!face) { - throw new BadRequestException('Invalid assetId for feature face'); - } - - if (face.asset.isOffline) { - throw new BadRequestException('An offline asset cannot be used for feature face'); + throw new BadRequestException('Invalid assetId for feature face or asset is offline'); } faceId = face.id; diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index f42f40940d..6fc472bb87 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -4,7 +4,6 @@ import { JobStatus } from 'src/enum'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TagService.name, () => { @@ -192,10 +191,7 @@ describe(TagService.name, () => { it('should upsert records', async () => { mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })], - }); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] }); mocks.tag.upsertAssetIds.mockResolvedValue([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, @@ -246,10 +242,7 @@ describe(TagService.name, () => { mocks.tag.get.mockResolvedValue(tagStub.tag); mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); mocks.tag.addAssetIds.mockResolvedValue(); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - tags: [factory.tag({ value: 'tag-1' })], - }); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [{ value: 'tag-1' }] }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( @@ -278,6 +271,7 @@ describe(TagService.name, () => { it('should throw an error for an invalid id', async () => { mocks.tag.getAssetIds.mockResolvedValue(new Set()); mocks.tag.removeAssetIds.mockResolvedValue(); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [] }); await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([ { id: 'asset-1', success: false, error: 'not_found' }, @@ -288,6 +282,7 @@ describe(TagService.name, () => { mocks.tag.get.mockResolvedValue(tagStub.tag); mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); mocks.tag.removeAssetIds.mockResolvedValue(); + mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [] }); await expect( sut.removeAssets(authStub.admin, 'tag-1', { diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 20303421c1..d34cd84ecd 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -151,10 +151,9 @@ export class TagService extends BaseService { } private async updateTags(assetId: string) { - const asset = await this.assetRepository.getById(assetId, { tags: true }); - await this.assetRepository.upsertExif( - updateLockedColumns({ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }), - { lockedPropertiesBehavior: 'append' }, - ); + const { tags } = await this.assetRepository.getForUpdateTags(assetId); + await this.assetRepository.upsertExif(updateLockedColumns({ assetId, tags: tags.map(({ value }) => value) }), { + lockedPropertiesBehavior: 'append', + }); } } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f8fb3d215d..c5d1476f65 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,10 +1,9 @@ import { BadRequestException } from '@nestjs/common'; import { StorageCore } from 'src/cores/storage.core'; -import { AssetFile, Exif } from 'src/database'; +import { AssetFile } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { ExifResponseDto } from 'src/dtos/exif.dto'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -210,20 +209,26 @@ const isFlipped = (orientation?: string | null) => { return value && [5, 6, 7, 8, -90, 90].includes(value); }; -export const getDimensions = (exifInfo: ExifResponseDto | Exif) => { - const { exifImageWidth: width, exifImageHeight: height } = exifInfo; - +export const getDimensions = ({ + exifImageHeight: height, + exifImageWidth: width, + orientation, +}: { + exifImageHeight: number | null; + exifImageWidth: number | null; + orientation: string | null; +}) => { if (!width || !height) { return { width: 0, height: 0 }; } - if (isFlipped(exifInfo.orientation)) { + if (isFlipped(orientation)) { return { width: height, height: width }; } return { width, height }; }; -export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => { - return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); +export const isPanorama = (asset: { projectionType: string | null; originalFileName: string }) => { + return asset.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); }; diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index c09f3a381b..b0e31afe39 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -76,6 +76,7 @@ describe('mimeTypes', () => { { mimetype: 'image/x-sony-sr2', extension: '.sr2' }, { mimetype: 'image/x-sony-srf', extension: '.srf' }, { mimetype: 'image/x3f', extension: '.x3f' }, + { mimetype: 'application/mxf', extension: '.mxf' }, { mimetype: 'video/3gpp', extension: '.3gp' }, { mimetype: 'video/3gpp', extension: '.3gpp' }, { mimetype: 'video/avi', extension: '.avi' }, @@ -188,7 +189,9 @@ describe('mimeTypes', () => { it('should contain only video mime types', () => { const values = Object.values(mimeTypes.video).flat(); - expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/'))); + expect(values).toEqual( + values.filter((mimeType) => mimeType.startsWith('video/') || mimeType === 'application/mxf'), + ); }); for (const [extension, v] of Object.entries(mimeTypes.video)) { diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index f6dca4e103..4e91bbd7f1 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -98,6 +98,7 @@ const video: Record = { '.mpeg': ['video/mpeg'], '.mpg': ['video/mpeg'], '.mts': ['video/mp2t'], + '.mxf': ['application/mxf'], '.vob': ['video/mpeg'], '.webm': ['video/webm'], '.wmv': ['video/x-ms-wmv'], @@ -141,9 +142,12 @@ export const mimeTypes = { const contentType = lookup(filename); if (contentType.startsWith('image/')) { return AssetType.Image; - } else if (contentType.startsWith('video/')) { + } + + if (contentType.startsWith('video/') || contentType === 'application/mxf') { return AssetType.Video; } + return AssetType.Other; }, getSupportedFileExtensions: () => [...Object.keys(image), ...Object.keys(video)], diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts index 899b529766..b2286cad54 100644 --- a/server/test/factories/asset-face.factory.ts +++ b/server/test/factories/asset-face.factory.ts @@ -18,14 +18,14 @@ export class AssetFaceFactory { static from(dto: AssetFaceLike = {}) { return new AssetFaceFactory({ assetId: newUuid(), - boundingBoxX1: 11, - boundingBoxX2: 12, - boundingBoxY1: 21, - boundingBoxY2: 22, + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, deletedAt: null, id: newUuid(), - imageHeight: 42, - imageWidth: 420, + imageHeight: 500, + imageWidth: 400, isVisible: true, personId: null, sourceType: SourceType.MachineLearning, diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 258e2aff38..4d54ba820b 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -96,7 +96,7 @@ export class AssetFactory { } face(dto: AssetFaceLike = {}, builder?: FactoryBuilder) { - this.#faces.push(build(AssetFaceFactory.from(dto), builder)); + this.#faces.push(build(AssetFaceFactory.from({ assetId: this.value?.id, ...dto }), builder)); return this; } diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts deleted file mode 100644 index e01394e84f..0000000000 --- a/server/test/fixtures/face.stub.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { SourceType } from 'src/enum'; -import { AssetFactory } from 'test/factories/asset.factory'; -import { personStub } from 'test/fixtures/person.stub'; - -export const faceStub = { - face1: Object.freeze({ - id: 'assetFaceId1', - assetId: 'asset-id', - asset: { - ...AssetFactory.create({ id: 'asset-id' }), - libraryId: null, - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - stackId: null, - }, - personId: personStub.withName.id, - person: personStub.withName, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, - deletedAt: new Date(), - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - primaryFace1: Object.freeze({ - id: 'assetFaceId2', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.primaryPerson.id, - person: personStub.primaryPerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - mergeFace1: Object.freeze({ - id: 'assetFaceId3', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.mergePerson.id, - person: personStub.mergePerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - noPerson1: Object.freeze({ - id: 'assetFaceId8', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: null, - person: null, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - noPerson2: Object.freeze({ - id: 'assetFaceId9', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: null, - person: null, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - fromExif1: Object.freeze({ - id: 'assetFaceId9', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.randomPerson.id, - person: personStub.randomPerson, - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageHeight: 500, - imageWidth: 400, - sourceType: SourceType.Exif, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - fromExif2: Object.freeze({ - id: 'assetFaceId9', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.randomPerson.id, - person: personStub.randomPerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.Exif, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), - withBirthDate: Object.freeze({ - id: 'assetFaceId10', - assetId: 'asset-id', - asset: AssetFactory.create({ id: 'asset-id' }), - personId: personStub.withBirthDate.id, - person: personStub.withBirthDate, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MachineLearning, - deletedAt: null, - updatedAt: new Date('2023-01-01T00:00:00Z'), - updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', - isVisible: true, - }), -}; diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 89ca79d864..eb57c10e2e 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,3 +1,6 @@ +import { Selectable } from 'kysely'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; export const getForStorageTemplate = (asset: ReturnType) => { @@ -20,3 +23,29 @@ export const getForStorageTemplate = (asset: ReturnType) isEdited: asset.isEdited, }; }; + +export const getAsDetectedFace = (face: ReturnType) => ({ + faces: [ + { + boundingBox: { + x1: face.boundingBoxX1, + y1: face.boundingBoxY1, + x2: face.boundingBoxX2, + y2: face.boundingBoxY2, + }, + embedding: '[1, 2, 3, 4]', + score: 0.2, + }, + ], + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, +}); + +export const getForFacialRecognitionJob = ( + face: ReturnType, + asset: Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null, +) => ({ + ...face, + asset, + faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' }, +}); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index db1b944e1f..477414dafe 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,12 +1,15 @@ import { Kysely } from 'kysely'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, AssetMetadataKey, AssetStatus, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { OcrRepository } from 'src/repositories/ocr.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; @@ -25,6 +28,7 @@ const setup = (db?: Kysely) => { database: db || defaultDatabase, real: [ AssetRepository, + AssetEditRepository, AssetJobRepository, AlbumRepository, AccessRepository, @@ -32,7 +36,7 @@ const setup = (db?: Kysely) => { StackRepository, UserRepository, ], - mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository], + mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository, OcrRepository], }); }; @@ -586,6 +590,108 @@ describe(AssetService.name, () => { }); }); + describe('getOcr', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }), + ]); + }); + + it('should apply rotation', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + await ctx.database + .insertInto('asset_edit') + .values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 }) + .execute(); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ + x1: 0.6, + x2: 0.8, + x3: 0.8, + x4: 0.6, + y1: expect.any(Number), + y2: expect.any(Number), + y3: 0.3, + y4: 0.3, + }), + ]); + }); + }); + + describe('getOcr', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }), + ]); + }); + + it('should apply rotation', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + await ctx.database + .insertInto('asset_edit') + .values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 }) + .execute(); + ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]); + + await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([ + expect.objectContaining({ + x1: 0.6, + x2: 0.8, + x3: 0.8, + x4: 0.6, + y1: expect.any(Number), + y2: expect.any(Number), + y3: 0.3, + y4: 0.3, + }), + ]); + }); + }); + describe('upsertBulkMetadata', () => { it('should work', async () => { const { sut, ctx } = setup(); @@ -758,4 +864,38 @@ describe(AssetService.name, () => { expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); }); }); + + describe('editAsset', () => { + it('should require access', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + + await expect( + sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }), + ).rejects.toThrow('Not found or no asset.edit.create access'); + }); + + it('should work', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' }); + + const editAction = { action: AssetEditAction.Rotate, parameters: { angle: 90 } } as const; + await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({ + assetId: asset.id, + edits: [editAction], + }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ isEdited: true }), + ); + await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editAction]); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 55dcf6456f..68667fa109 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -51,7 +51,13 @@ export const newAssetRepositoryMock = (): Mocked - import { isEnabled } from '$lib/utils'; - import { IconButton, type ActionItem } from '@immich/ui'; - - type Props = { - action: ActionItem; - }; - - const { action }: Props = $props(); - const { title, icon, color = 'secondary', onAction } = $derived(action); - - -{#if icon && isEnabled(action)} - onAction(action)} /> -{/if} diff --git a/web/src/lib/components/album-page/album-shared-link.svelte b/web/src/lib/components/album-page/album-shared-link.svelte index c99a5f6407..0969b60d29 100644 --- a/web/src/lib/components/album-page/album-shared-link.svelte +++ b/web/src/lib/components/album-page/album-shared-link.svelte @@ -1,9 +1,8 @@ - - - - diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6754ad70cf..dc81614c64 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -1,9 +1,7 @@ - +
- - - - - - - - - - - + + + + + + + + + + + {#if isOwner} {/if} - + {#if isOwner} @@ -178,18 +133,15 @@ {/if} - - + + - {#if !isLocked} - {#if asset.isTrashed} - - {:else} - - - {/if} + {#if !isLocked && asset.isTrashed} + {/if} + + {#if isOwner} {#if stack} @@ -251,10 +203,10 @@ {/if} {#if isOwner}
- - - - + + + + {/if} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 848870b654..c011a5e466 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,4 +1,5 @@ - + diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 5b18dbb4e3..1d597062cb 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -12,6 +12,8 @@ let { asset }: Props = $props(); + const assetId = $derived(asset.id); + const loadAssetData = async (id: string) => { const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); return URL.createObjectURL(data); @@ -19,7 +21,7 @@
- {#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} + {#await Promise.all([loadAssetData(assetId), import('./photo-sphere-viewer-adapter.svelte')])} {:then [data, { default: PhotoSphereViewer }]} diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index e64b674ac1..6f6caad0fc 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -1,6 +1,6 @@ -
- +
- - -
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index f671aa1b1c..f4ba6868e0 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -2,8 +2,10 @@ import { shortcuts } from '$lib/actions/shortcut'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; + import { calculateBoundingBoxMatrix, getOcrBoundingBoxesAtSize, type Point } from '$lib/utils/ocr-utils'; import { EquirectangularAdapter, Viewer, @@ -27,6 +29,17 @@ strokeLinejoin: 'round', }; + // Adapted as well as possible from classlist 'border-2 border-blue-500 bg-blue-500/10 hover:border-blue-600 hover:border-3' + const OCR_BOX_SVG_STYLE = { + fill: 'var(--color-blue-500)', + fillOpacity: '0.1', + stroke: 'var(--color-blue-500)', + strokeWidth: '2px', + }; + + const OCR_TOOLTIP_HTML_CLASS = + 'flex items-center justify-center text-white bg-black/50 cursor-text pointer-events-auto whitespace-pre-wrap wrap-break-word select-text'; + type Props = { panorama: string | { source: string }; originalPanorama?: string | { source: string }; @@ -96,6 +109,59 @@ } }); + $effect(() => { + updateOcrBoxes(ocrManager.showOverlay, ocrManager.data); + }); + + /** Use updateOnly=true on zoom, pan, or resize. */ + const updateOcrBoxes = (showOverlay: boolean, ocrData: OcrBoundingBox[], updateOnly = false) => { + if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) { + return; + } + const markersPlugin = viewer.getPlugin(MarkersPlugin); + if (!showOverlay) { + markersPlugin.clearMarkers(); + return; + } + if (!updateOnly) { + markersPlugin.clearMarkers(); + } + + const boxes = getOcrBoundingBoxesAtSize(ocrData, { + width: viewer.state.textureData.panoData.croppedWidth, + height: viewer.state.textureData.panoData.croppedHeight, + }); + + for (const [index, box] of boxes.entries()) { + const points = box.points.map((p) => texturePointToViewerPoint(viewer, p)); + const { matrix, width, height } = calculateBoundingBoxMatrix(points); + + const fontSize = (1.4 * width) / box.text.length; // fits almost all strings within the box, depends on font family + const transform = `matrix3d(${matrix.join(',')})`; + const content = `
${box.text}
`; + + if (updateOnly) { + markersPlugin.updateMarker({ + id: `box_${index}`, + polygonPixels: box.points.map((b) => [b.x, b.y]), + tooltip: { content }, + }); + } else { + markersPlugin.addMarker({ + id: `box_${index}`, + polygonPixels: box.points.map((b) => [b.x, b.y]), + svgStyle: OCR_BOX_SVG_STYLE, + tooltip: { content, trigger: 'click' }, + }); + } + } + }; + + const texturePointToViewerPoint = (viewer: Viewer, point: Point) => { + const spherical = viewer.dataHelper.textureCoordsToSphericalCoords({ textureX: point.x, textureY: point.y }); + return viewer.dataHelper.sphericalCoordsToViewerCoords(spherical); + }; + const onZoom = () => { viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 }); }; @@ -160,7 +226,20 @@ viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true }); } - return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + const onReadyHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, false); + const updateHandler = () => updateOcrBoxes(ocrManager.showOverlay, ocrManager.data, true); + viewer.addEventListener(events.ReadyEvent.type, onReadyHandler); + viewer.addEventListener(events.PositionUpdatedEvent.type, updateHandler); + viewer.addEventListener(events.SizeUpdatedEvent.type, updateHandler); + viewer.addEventListener(events.ZoomUpdatedEvent.type, updateHandler, { passive: true }); + + return () => { + viewer.removeEventListener(events.ReadyEvent.type, onReadyHandler); + viewer.removeEventListener(events.PositionUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.SizeUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.ZoomUpdatedEvent.type, updateHandler); + viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); + }; }); onDestroy(() => { @@ -176,3 +255,25 @@
+ + diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index efa425dd30..49fb7fa6b9 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -10,7 +10,6 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; @@ -25,6 +24,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte'; @@ -34,7 +34,7 @@ import { cancelMultiselect } from '$lib/utils/asset-utils'; import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk'; - import { IconButton, toastManager } from '@immich/ui'; + import { ActionButton, IconButton, toastManager } from '@immich/ui'; import { mdiCardsOutline, mdiChevronDown, @@ -48,7 +48,6 @@ mdiImageSearch, mdiPause, mdiPlay, - mdiPlus, mdiSelectAll, mdiVolumeHigh, mdiVolumeOff, @@ -329,6 +328,7 @@ assets={assetInteraction.selectedAssets} clearSelect={() => cancelMultiselect(assetInteraction)} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} - - - - + diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts index a078e55762..9257c4585a 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -28,15 +28,15 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({ }); describe('Album Modal', () => { - it('non-shared with no albums configured yet shows message and new', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('no albums configured yet shows message and new', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const modalRows = converter.toModalRows('', [], [], -1, []); expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]); }); - it('non-shared with no matching albums shows message and new', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('no matching albums shows message and new', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const modalRows = converter.toModalRows( 'matches_nothing', [], @@ -48,8 +48,8 @@ describe('Album Modal', () => { expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]); }); - it('non-shared displays single albums', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('displays single albums', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []); @@ -60,8 +60,8 @@ describe('Album Modal', () => { ]); }); - it('non-shared displays multiple albums and recents', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + it('displays multiple albums and recents', () => { + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); @@ -87,31 +87,8 @@ describe('Album Modal', () => { ]); }); - it('shared only displays albums and no recents', () => { - const converter = new AlbumModalRowConverter(true, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); - const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); - const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); - const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); - const christmasAlbum = albumFactory.build({ albumName: 'Christmas' }); - const modalRows = converter.toModalRows( - '', - [holidayAlbum, constructionAlbum], - [holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum], - -1, - [], - ); - - expect(modalRows).toStrictEqual([ - createNewAlbumRow(false), - createAlbumRow(holidayAlbum, false), - createAlbumRow(constructionAlbum, false), - createAlbumRow(birthdayAlbum, false), - createAlbumRow(christmasAlbum, false), - ]); - }); - it('search changes messaging and removes recent and non-matching albums', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const birthdayAlbum = albumFactory.build({ albumName: 'Birthday' }); @@ -132,7 +109,7 @@ describe('Album Modal', () => { }); it('selection can select new album row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []); @@ -148,7 +125,7 @@ describe('Album Modal', () => { }); it('selection can select recent row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []); @@ -164,7 +141,7 @@ describe('Album Modal', () => { }); it('selection can select last row', () => { - const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc); + const converter = new AlbumModalRowConverter(AlbumSortBy.MostRecentPhoto, SortOrder.Desc); const holidayAlbum = albumFactory.build({ albumName: 'Holidays' }); const constructionAlbum = albumFactory.build({ albumName: 'Construction' }); const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []); diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts index e65d42b183..56246ac6c4 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -27,12 +27,10 @@ export const isSelectableRowType = (type: AlbumModalRowType) => const $t = get(t); export class AlbumModalRowConverter { - private readonly shared: boolean; private readonly sortBy: string; private readonly orderBy: string; - constructor(shared: boolean, sortBy: string, orderBy: string) { - this.shared = shared; + constructor(sortBy: string, orderBy: string) { this.sortBy = sortBy; this.orderBy = orderBy; } @@ -44,8 +42,8 @@ export class AlbumModalRowConverter { selectedRowIndex: number, multiSelectedAlbumIds: string[], ): AlbumModalRow[] { - // only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal. - const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : []; + // only show recent albums if no search was entered + const recentAlbumsToShow = search.length === 0 ? recentAlbums : []; const rows: AlbumModalRow[] = [{ type: AlbumModalRowType.NEW_ALBUM, selected: selectedRowIndex === 0 }]; const filteredAlbums = sortAlbums( @@ -71,12 +69,10 @@ export class AlbumModalRowConverter { } } - if (!this.shared) { - rows.push({ - type: AlbumModalRowType.SECTION, - text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), - }); - } + rows.push({ + type: AlbumModalRowType.SECTION, + text: (search.length === 0 ? $t('all_albums') : $t('albums')).toUpperCase(), + }); const selectedOffsetDueToNewAndRecents = 1 + recentAlbumsToShow.length; for (const [i, album] of filteredAlbums.entries()) { 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 c4d94bdf56..7be4a58131 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 @@ -5,7 +5,6 @@ - - diff --git a/web/src/lib/components/workflows/WorkflowPickerField.svelte b/web/src/lib/components/workflows/WorkflowPickerField.svelte index 6ad4fdbeb2..0ba85904a2 100644 --- a/web/src/lib/components/workflows/WorkflowPickerField.svelte +++ b/web/src/lib/components/workflows/WorkflowPickerField.svelte @@ -42,7 +42,7 @@ const handlePicker = async () => { if (isAlbum) { - const albums = await modalManager.show(AlbumPickerModal, { shared: false }); + const albums = await modalManager.show(AlbumPickerModal); if (albums && albums.length > 0) { const newValue = multiple ? albums.map((album) => album.id) : albums[0].id; onchange(newValue); diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 53a73d471d..389ebbefab 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -6,11 +6,8 @@ export enum AssetAction { TRASH = 'trash', DELETE = 'delete', RESTORE = 'restore', - ADD = 'add', - ADD_TO_ALBUM = 'add-to-album', STACK = 'stack', UNSTACK = 'unstack', - KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack', SET_VISIBILITY_LOCKED = 'set-visibility-locked', diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index ead2ffe4b0..b161356a68 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -39,7 +39,7 @@ export type Events = { AssetEditsApplied: [string]; AssetsTag: [string[]]; - AlbumAddAssets: []; + AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }]; AlbumUpdate: [AlbumResponseDto]; AlbumDelete: [AlbumResponseDto]; AlbumShare: []; diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 72f80043f5..b2420215bc 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -21,14 +21,13 @@ let selectedRowIndex: number = $state(-1); interface Props { - shared: boolean; onClose: (albums?: AlbumResponseDto[]) => void; } - let { shared, onClose }: Props = $props(); + let { onClose }: Props = $props(); onMount(async () => { - albums = await getAllAlbums({ shared: shared || undefined }); + albums = await getAllAlbums({}); recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3); loading = false; }); @@ -36,7 +35,7 @@ const multiSelectedAlbumIds: string[] = $state([]); const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0); - const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder); + const rowConverter = new AlbumModalRowConverter($albumViewSettings.sortBy, $albumViewSettings.sortOrder); const albumModalRows = $derived( rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds), ); @@ -146,7 +145,7 @@ }; - +
{#if loading} diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte index c78c45c324..9041def7be 100644 --- a/web/src/lib/modals/AppDownloadModal.svelte +++ b/web/src/lib/modals/AppDownloadModal.svelte @@ -1,5 +1,5 @@ - - -
-
- Google Play - - Get it on Google Play - -
+ +
+ + Get it on Google Play + -
- App Store - - Download on the App Store - -
+ + Download on the App Store + -
- F-Droid - - Get it on F-Droid - -
-
- - + + Get it on F-Droid + +
+ diff --git a/web/src/lib/modals/AssetAddToAlbumModal.svelte b/web/src/lib/modals/AssetAddToAlbumModal.svelte new file mode 100644 index 0000000000..b35c125d08 --- /dev/null +++ b/web/src/lib/modals/AssetAddToAlbumModal.svelte @@ -0,0 +1,27 @@ + + + diff --git a/web/src/lib/modals/PinCodeResetModal.svelte b/web/src/lib/modals/PinCodeResetModal.svelte index 024f0c8528..a51e0c3583 100644 --- a/web/src/lib/modals/PinCodeResetModal.svelte +++ b/web/src/lib/modals/PinCodeResetModal.svelte @@ -1,7 +1,7 @@ -{#if featureFlagsManager.value.passwordLogin === false} +{#if featureFlagsManager.value.passwordLogin}
{$t('reset_pin_code_description')}
@@ -37,9 +37,7 @@
{:else} - - -
{$t('reset_pin_code_description')}
-
-
+ +
{$t('reset_pin_code_description')}
+
{/if} diff --git a/web/src/lib/modals/ShortcutsModal.svelte b/web/src/lib/modals/ShortcutsModal.svelte index c5b09ffa1a..c233548878 100644 --- a/web/src/lib/modals/ShortcutsModal.svelte +++ b/web/src/lib/modals/ShortcutsModal.svelte @@ -40,7 +40,6 @@ { key: ['s'], action: $t('stack_selected_photos') }, { key: ['l'], action: $t('add_to_album') }, { key: ['t'], action: $t('tag_assets') }, - { key: ['⇧', 'l'], action: $t('add_to_shared_album') }, { key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') }, { key: ['⇧', 'd'], action: $t('download') }, { key: ['Space'], action: $t('play_or_pause_video') }, diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index ac0a1045b3..05e0fdb78d 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -1,6 +1,7 @@ import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; import { AlbumPageViewMode } from '$lib/constants'; +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte'; @@ -11,17 +12,22 @@ import { user } from '$lib/stores/user.store'; import { createAlbumAndRedirect } from '$lib/utils/album-utils'; import { downloadArchive } from '$lib/utils/asset-utils'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; + import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; import { - addAssetsToAlbum, + addAssetsToAlbum as addToAlbum, + addAssetsToAlbums as addToAlbums, addUsersToAlbum, AlbumUserRole, + BulkIdErrorReason, deleteAlbum, removeUserFromAlbum, updateAlbumInfo, updateAlbumUser, type AlbumResponseDto, + type AlbumsAddAssetsResponseDto, + type BulkIdResponseDto, type UpdateAlbumDto, type UserResponseDto, } from '@immich/sdk'; @@ -86,7 +92,12 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse color: 'primary', icon: mdiPlusBoxOutline, $if: () => assets.length > 0, - onAction: () => addAssets(album, assets), + onAction: () => + addAssetsToAlbums( + [album.id], + assets.map(({ id }) => id), + { notify: true }, + ).then(() => undefined), }; const Upload: ActionItem = { @@ -100,18 +111,73 @@ export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponse return { AddAssets, Upload }; }; -const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => { +export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], { notify }: { notify: boolean }) => { const $t = await getFormatter(); - const assetIds = assets.map(({ id }) => id); try { - const results = await addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: assetIds } }); + if (albumIds.length === 1) { + const albumId = albumIds[0]; + const results = await addToAlbum({ ...authManager.params, id: albumId, bulkIdsDto: { ids: assetIds } }); + if (notify) { + notifyAddToAlbum($t, albumId, assetIds, results); + } + } - const count = results.filter(({ success }) => success).length; - toastManager.success($t('assets_added_count', { values: { count } })); - eventManager.emit('AlbumAddAssets'); + if (albumIds.length > 1) { + const results = await addToAlbums({ ...authManager.params, albumsAddAssetsDto: { albumIds, assetIds } }); + if (notify) { + notifyAddToAlbums($t, albumIds, assetIds, results); + } + } + + eventManager.emit('AlbumAddAssets', { assetIds, albumIds }); + return true; } catch (error) { handleError(error, $t('errors.error_adding_assets_to_album')); + return false; + } +}; + +const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: string[], results: BulkIdResponseDto[]) => { + const successCount = results.filter(({ success }) => success).length; + const duplicateCount = results.filter(({ error }) => error === 'duplicate').length; + let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); + if (successCount > 0) { + description = $t('assets_added_to_album_count', { values: { count: successCount } }); + } else if (duplicateCount > 0) { + description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } }); + } + + toastManager.custom( + { + component: ToastAction, + props: { + title: $t('info'), + color: 'primary', + description, + button: { text: $t('view_album'), color: 'primary', onClick: () => goto(Route.viewAlbum({ id: albumId })) }, + }, + }, + { timeout: 5000 }, + ); +}; + +const notifyAddToAlbums = ( + $t: MessageFormatter, + albumIds: string[], + assetIds: string[], + results: AlbumsAddAssetsResponseDto, +) => { + if (results.error === BulkIdErrorReason.Duplicate) { + toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } })); + } else if (results.error) { + toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } })); + } else { + toastManager.success( + $t('assets_added_to_albums_count', { + values: { albumTotal: albumIds.length, assetTotal: assetIds.length }, + }), + ); } }; diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts index f9b33d5687..bbe4d9301b 100644 --- a/web/src/lib/services/asset.service.ts +++ b/web/src/lib/services/asset.service.ts @@ -2,6 +2,7 @@ import { ProjectionType } from '$lib/constants'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; +import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte'; import AssetTagModal from '$lib/modals/AssetTagModal.svelte'; import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte'; import { user as authUser, preferences } from '$lib/stores/user.store'; @@ -42,6 +43,7 @@ import { mdiMagnifyPlusOutline, mdiMotionPauseOutline, mdiMotionPlayOutline, + mdiPlus, mdiShareVariantOutline, mdiTagPlusOutline, mdiTune, @@ -59,6 +61,13 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte ctx.clearSelect(); }; + const AddToAlbum: ActionItem = { + title: $t('add_to_album'), + icon: mdiPlus, + shortcuts: [{ key: 'l' }], + onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds }), + }; + const RefreshFacesJob: ActionItem = { title: $t('refresh_faces'), icon: mdiHeadSyncOutline, @@ -84,7 +93,7 @@ export const getAssetBulkActions = ($t: MessageFormatter, ctx: AssetControlConte $if: () => isAllVideos, }; - return { RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob }; + return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob }; }; export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => { @@ -161,6 +170,14 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = shortcuts: [{ key: 'f' }], }; + const AddToAlbum: ActionItem = { + title: $t('add_to_album'), + icon: mdiPlus, + shortcuts: [{ key: 'l' }], + $if: () => asset.visibility !== AssetVisibility.Locked && !asset.isTrashed, + onAction: () => modalManager.show(AssetAddToAlbumModal, { assetIds: [asset.id] }), + }; + const Offline: ActionItem = { title: $t('asset_offline'), icon: mdiAlertOutline, @@ -260,6 +277,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) = Unfavorite, PlayMotionPhoto, StopMotionPhoto, + AddToAlbum, ZoomIn, ZoomOut, Copy, diff --git a/web/src/lib/stores/asset-interaction.svelte.ts b/web/src/lib/stores/asset-interaction.svelte.ts index 817354e619..48c8080269 100644 --- a/web/src/lib/stores/asset-interaction.svelte.ts +++ b/web/src/lib/stores/asset-interaction.svelte.ts @@ -1,5 +1,6 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { user } from '$lib/stores/user.store'; +import type { AssetControlContext } from '$lib/types'; import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk'; import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { fromStore } from 'svelte/store'; @@ -22,6 +23,14 @@ export class AssetInteraction { private user = fromStore(user); private userId = $derived(this.user.current?.id); + asControlContext(): AssetControlContext { + return { + getOwnedAssets: () => this.selectedAssets.filter((asset) => asset.ownerId === this.userId), + getAssets: () => this.selectedAssets, + clearSelect: () => this.clearMultiselect(), + }; + } + isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed)); isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive)); isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 47df967844..73a6965dd9 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,10 +1,8 @@ -import { goto } from '$app/navigation'; import ToastAction from '$lib/components/ToastAction.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { Route } from '$lib/route'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, withError } from '$lib/utils'; @@ -13,10 +11,7 @@ import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; import { asQueryString } from '$lib/utils/shared-links'; import { - addAssetsToAlbum as addAssets, - addAssetsToAlbums as addToAlbums, AssetVisibility, - BulkIdErrorReason, bulkTagAssets, createStack, deleteAssets, @@ -41,77 +36,6 @@ import { t } from 'svelte-i18n'; import { get } from 'svelte/store'; import { handleError } from './handle-error'; -export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => { - const result = await addAssets({ - ...authManager.params, - id: albumId, - bulkIdsDto: { - ids: assetIds, - }, - }); - const count = result.filter(({ success }) => success).length; - const duplicateErrorCount = result.filter(({ error }) => error === 'duplicate').length; - const $t = get(t); - - if (showNotification) { - let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); - if (count > 0) { - description = $t('assets_added_to_album_count', { values: { count } }); - } else if (duplicateErrorCount > 0) { - description = $t('assets_were_part_of_album_count', { values: { count: duplicateErrorCount } }); - } - toastManager.custom( - { - component: ToastAction, - props: { - title: $t('info'), - color: 'primary', - description, - button: { - text: $t('view_album'), - color: 'primary', - onClick() { - return goto(Route.viewAlbum({ id: albumId })); - }, - }, - }, - }, - { timeout: 5000 }, - ); - } -}; - -export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => { - const result = await addToAlbums({ - ...authManager.params, - albumsAddAssetsDto: { - albumIds, - assetIds, - }, - }); - - if (!showNotification) { - return result; - } - - if (showNotification) { - const $t = get(t); - - if (result.error === BulkIdErrorReason.Duplicate) { - toastManager.info($t('assets_were_part_of_albums_count', { values: { count: assetIds.length } })); - return result; - } - if (result.error) { - toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } })); - return result; - } - toastManager.success( - $t('assets_added_to_albums_count', { values: { albumTotal: albumIds.length, assetTotal: assetIds.length } }), - ); - return result; - } -}; - export const tagAssets = async ({ assetIds, tagIds, @@ -212,7 +136,7 @@ export const downloadArchive = async (fileName: string, options: Omit downloadManager.update(downloadKey, event.loaded), }); diff --git a/web/src/lib/utils/cast/gcast-destination.svelte.ts b/web/src/lib/utils/cast/gcast-destination.svelte.ts index 8e72c71e0b..d85d1f513b 100644 --- a/web/src/lib/utils/cast/gcast-destination.svelte.ts +++ b/web/src/lib/utils/cast/gcast-destination.svelte.ts @@ -115,12 +115,17 @@ export class GCastDestination implements ICastDestination { // build the authenticated media request and send it to the cast device const authenticatedUrl = `${mediaUrl}&sessionKey=${sessionKey}`; const mediaInfo = new chrome.cast.media.MediaInfo(authenticatedUrl, contentType); - const request = new chrome.cast.media.LoadRequest(mediaInfo); + + // Create a queue with a single item and set it to repeat + const queueItem = new chrome.cast.media.QueueItem(mediaInfo); + const queueLoadRequest = new chrome.cast.media.QueueLoadRequest([queueItem]); + queueLoadRequest.repeatMode = chrome.cast.media.RepeatMode.SINGLE; + const successCallback = this.onMediaDiscovered.bind(this, SESSION_DISCOVERY_CAUSE.LOAD_MEDIA); this.currentUrl = mediaUrl; - return this.session.loadMedia(request, successCallback, this.onError.bind(this)); + return this.session.queueLoad(queueLoadRequest, successCallback, this.onError.bind(this)); } /// diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 8558244cfb..e33022eb37 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -1,10 +1,10 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { uploadManager } from '$lib/managers/upload-manager.svelte'; +import { addAssetsToAlbums } from '$lib/services/album.service'; import { uploadAssetsStore } from '$lib/stores/upload'; import { user } from '$lib/stores/user.store'; import { UploadState } from '$lib/types'; import { uploadRequest } from '$lib/utils'; -import { addAssetsToAlbum } from '$lib/utils/asset-utils'; import { ExecutorQueue } from '$lib/utils/executor-queue'; import { asQueryString } from '$lib/utils/shared-links'; import { @@ -213,7 +213,7 @@ async function fileUploader({ if (albumId) { uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_adding_to_album') }); - await addAssetsToAlbum(albumId, [responseData.id], false); + await addAssetsToAlbums([albumId], [responseData.id], { notify: false }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_added_to_album') }); } diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 97364d06f5..01f118a4e5 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -12,70 +12,58 @@ const getContainedSize = (img: HTMLImageElement): { width: number; height: numbe return { width, height }; }; +export type Point = { + x: number; + y: number; +}; + export interface OcrBox { id: string; - points: { x: number; y: number }[]; + points: Point[]; text: string; confidence: number; } -export interface BoundingBoxDimensions { - minX: number; - maxX: number; - minY: number; - maxY: number; - width: number; - height: number; - centerX: number; - centerY: number; - rotation: number; - skewX: number; - skewY: number; -} - /** - * Calculate bounding box dimensions and properties from OCR points + * Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d. * @param points - Array of 4 corner points of the bounding box - * @returns Dimensions, rotation, and skew values for the bounding box + * @returns 4x4 matrix to transform the div with text onto the polygon defined by the corner points, and size to set on the source div. */ -export const calculateBoundingBoxDimensions = (points: { x: number; y: number }[]): BoundingBoxDimensions => { +export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => { const [topLeft, topRight, bottomRight, bottomLeft] = points; - const minX = Math.min(...points.map(({ x }) => x)); - const maxX = Math.max(...points.map(({ x }) => x)); - const minY = Math.min(...points.map(({ y }) => y)); - const maxY = Math.max(...points.map(({ y }) => y)); - const width = maxX - minX; - const height = maxY - minY; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - // Calculate rotation angle from the bottom edge (bottomLeft to bottomRight) - const rotation = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x) * (180 / Math.PI); + // Approximate width and height to prevent text distortion as much as possible + const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight)); + const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight)); - // Calculate skew angles to handle perspective distortion - // SkewX: compare left and right edges - const leftEdgeAngle = Math.atan2(bottomLeft.y - topLeft.y, bottomLeft.x - topLeft.x); - const rightEdgeAngle = Math.atan2(bottomRight.y - topRight.y, bottomRight.x - topRight.x); - const skewX = (rightEdgeAngle - leftEdgeAngle) * (180 / Math.PI); + const dx1 = topRight.x - bottomRight.x; + const dx2 = bottomLeft.x - bottomRight.x; + const dx3 = topLeft.x - topRight.x + bottomRight.x - bottomLeft.x; - // SkewY: compare top and bottom edges - const topEdgeAngle = Math.atan2(topRight.y - topLeft.y, topRight.x - topLeft.x); - const bottomEdgeAngle = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x); - const skewY = (bottomEdgeAngle - topEdgeAngle) * (180 / Math.PI); + const dy1 = topRight.y - bottomRight.y; + const dy2 = bottomLeft.y - bottomRight.y; + const dy3 = topLeft.y - topRight.y + bottomRight.y - bottomLeft.y; - return { - minX, - maxX, - minY, - maxY, - width, - height, - centerX, - centerY, - rotation, - skewX, - skewY, - }; + const det = dx1 * dy2 - dx2 * dy1; + const a13 = (dx3 * dy2 - dx2 * dy3) / det; + const a23 = (dx1 * dy3 - dx3 * dy1) / det; + + const a11 = (1 + a13) * topRight.x - topLeft.x; + const a21 = (1 + a23) * bottomLeft.x - topLeft.x; + + const a12 = (1 + a13) * topRight.y - topLeft.y; + const a22 = (1 + a23) * bottomLeft.y - topLeft.y; + + // prettier-ignore + const matrix = [ + a11 / width, a12 / width, 0, a13 / width, + a21 / height, a22 / height, 0, a23 / height, + 0, 0, 1, 0, + topLeft.x, topLeft.y, 0, 1, + ]; + + return { matrix, width, height }; }; /** @@ -87,18 +75,32 @@ export const getOcrBoundingBoxes = ( zoom: ZoomImageWheelState, photoViewer: HTMLImageElement | null, ): OcrBox[] => { - const boxes: OcrBox[] = []; - if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) { - return boxes; + return []; } const clientHeight = photoViewer.clientHeight; const clientWidth = photoViewer.clientWidth; const { width, height } = getContainedSize(photoViewer); - const imageWidth = photoViewer.naturalWidth; - const imageHeight = photoViewer.naturalHeight; + const offset = { + x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX, + y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY, + }; + + return getOcrBoundingBoxesAtSize( + ocrData, + { width: width * zoom.currentZoom, height: height * zoom.currentZoom }, + offset, + ); +}; + +export const getOcrBoundingBoxesAtSize = ( + ocrData: OcrBoundingBox[], + targetSize: { width: number; height: number }, + offset?: Point, +) => { + const boxes: OcrBox[] = []; for (const ocr of ocrData) { // Convert normalized coordinates (0-1) to actual pixel positions @@ -109,14 +111,8 @@ export const getOcrBoundingBoxes = ( { x: ocr.x3, y: ocr.y3 }, { x: ocr.x4, y: ocr.y4 }, ].map((point) => ({ - x: - (width / imageWidth) * zoom.currentZoom * point.x * imageWidth + - ((clientWidth - width) / 2) * zoom.currentZoom + - zoom.currentPositionX, - y: - (height / imageHeight) * zoom.currentZoom * point.y * imageHeight + - ((clientHeight - height) / 2) * zoom.currentZoom + - zoom.currentPositionY, + x: targetSize.width * point.x + (offset?.x ?? 0), + y: targetSize.height * point.y + (offset?.y ?? 0), })); boxes.push({ diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 88baa416b8..97e101f728 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,7 +1,6 @@