From 156e3479fa2694317c60101606c0fbeaf215f3f3 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 16 Feb 2026 08:20:01 -0600 Subject: [PATCH 01/78] chore: styling tweak profile panel (#26248) --- .../common/app_bar_dialog/app_bar_dialog.dart | 18 +++++++++--------- .../app_bar_dialog/app_bar_profile_info.dart | 2 +- .../app_bar_dialog/app_bar_server_info.dart | 4 ++-- .../widgets/common/immich_sliver_app_bar.dart | 2 +- .../lib/widgets/common/user_circle_avatar.dart | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 527aae0e6e..c330fb4649 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -52,7 +52,10 @@ class ImmichAppBarDialog extends HookConsumerWidget { child: Stack( alignment: Alignment.centerLeft, children: [ - IconButton(onPressed: () => context.pop(), icon: const Icon(Icons.close, size: 20)), + IconButton( + onPressed: () => context.pop(), + icon: Icon(Icons.close, size: 20, color: context.colorScheme.onSurfaceVariant), + ), Align( alignment: Alignment.center, child: Padding( @@ -154,15 +157,12 @@ class ImmichAppBarDialog extends HookConsumerWidget { } return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 12, children: [ - Text( - "backup_controller_page_server_storage", - style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w500), - ).tr(), + Text("backup_controller_page_server_storage".tr(), style: context.textTheme.labelLarge), LinearProgressIndicator( minHeight: 10.0, value: percentage, @@ -264,13 +264,13 @@ class ImmichAppBarDialog extends HookConsumerWidget { color: context.colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(10)), ), - margin: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + margin: const EdgeInsets.only(left: 12, right: 12, bottom: 8), child: Column( children: [ const AppBarProfileInfoBox(), - const Divider(height: 3), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), buildStorageInformation(), - const Divider(height: 3), + Divider(thickness: 4, color: context.colorScheme.surfaceContainer), const AppBarServerInfo(), ], ), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index 12273849f2..a9fdb9a43f 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); } - final userImage = UserCircleAvatar(size: 44, user: user); + final userImage = UserCircleAvatar(size: 44, user: user, hasBorder: true); if (uploadProfileImageStatus == UploadProfileStatus.loading) { return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20)); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart index 3203b18df7..2809505c58 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_server_info.dart @@ -38,7 +38,7 @@ class AppBarServerInfo extends HookConsumerWidget { const divider = Divider(thickness: 1); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -109,7 +109,7 @@ class _ServerInfoItem extends StatelessWidget { style: TextStyle( fontSize: contentFontSize, color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, overflow: TextOverflow.ellipsis, ), textAlign: TextAlign.end, diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 939e9e27aa..141f7e5e8b 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -162,7 +162,7 @@ class _ProfileIndicator extends ConsumerWidget { child: AbsorbPointer( child: Builder( builder: (context) => UserCircleAvatar( - size: 32, + size: 34, user: user, opacity: IconTheme.of(context).opacity ?? 1, hasBorder: true, diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index fe39c5da3f..c6e4f4719e 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -38,7 +38,7 @@ class UserCircleAvatar extends ConsumerWidget { decoration: BoxDecoration( color: userAvatarColor, shape: BoxShape.circle, - border: hasBorder ? Border.all(color: Colors.grey[500]!.withValues(alpha: opacity), width: 1) : null, + border: hasBorder ? Border.all(color: userAvatarColor.withValues(alpha: opacity), width: 1.5) : null, ), child: user.hasProfileImage ? ClipRRect( From 921101399634bcf35f61865089473bd98e241503 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 16 Feb 2026 19:50:28 +0530 Subject: [PATCH 02/78] fix: bring back timeline args auto-scoping (#26219) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../widgets/timeline/timeline.widget.dart | 129 +++++++++++++----- 1 file changed, 97 insertions(+), 32 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index da0497539b..9f7c695c8b 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,7 +29,38 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; -class Timeline extends ConsumerWidget { +class _TimelineRestorationState extends ChangeNotifier { + int? _restoreAssetIndex; + bool _shouldRestoreAssetPosition = false; + + int? get restoreAssetIndex => _restoreAssetIndex; + bool get shouldRestoreAssetPosition => _shouldRestoreAssetPosition; + + void setRestoreAssetIndex(int? index) { + _restoreAssetIndex = index; + notifyListeners(); + } + + void setShouldRestoreAssetPosition(bool should) { + _shouldRestoreAssetPosition = should; + notifyListeners(); + } + + void clearRestoreAssetIndex() { + _restoreAssetIndex = null; + notifyListeners(); + } +} + +class _TimelineRestorationProvider extends InheritedNotifier<_TimelineRestorationState> { + const _TimelineRestorationProvider({required super.notifier, required super.child}); + + static _TimelineRestorationState of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_TimelineRestorationProvider>()!.notifier!; + } +} + +class Timeline extends StatefulWidget { const Timeline({ super.key, this.topSliverWidget, @@ -58,36 +89,66 @@ class Timeline extends ConsumerWidget { final bool readOnly; @override - Widget build(BuildContext context, WidgetRef ref) { + State createState() => _TimelineState(); +} + +class _TimelineState extends State { + double? _lastWidth; + late final _TimelineRestorationState _restorationState; + + @override + void initState() { + super.initState(); + _restorationState = _TimelineRestorationState(); + } + + @override + void dispose() { + _restorationState.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: const DownloadStatusFloatingButton(), body: LayoutBuilder( - builder: (_, constraints) => ProviderScope( - overrides: [ - timelineArgsProvider.overrideWithValue( - TimelineArgs( - maxWidth: constraints.maxWidth, - maxHeight: constraints.maxHeight, - columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), - showStorageIndicator: showStorageIndicator, - withStack: withStack, - groupBy: groupBy, + builder: (_, constraints) { + if (_lastWidth != null && _lastWidth != constraints.maxWidth) { + _restorationState.setShouldRestoreAssetPosition(true); + } + _lastWidth = constraints.maxWidth; + return _TimelineRestorationProvider( + notifier: _restorationState, + child: ProviderScope( + key: ValueKey(_lastWidth), + overrides: [ + timelineArgsProvider.overrideWith( + (ref) => TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), + showStorageIndicator: widget.showStorageIndicator, + withStack: widget.withStack, + groupBy: widget.groupBy, + ), + ), + if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), + ], + child: _SliverTimeline( + key: const ValueKey('_sliver_timeline'), + topSliverWidget: widget.topSliverWidget, + topSliverWidgetHeight: widget.topSliverWidgetHeight, + appBar: widget.appBar, + bottomSheet: widget.bottomSheet, + withScrubber: widget.withScrubber, + snapToMonth: widget.snapToMonth, + initialScrollOffset: widget.initialScrollOffset, ), ), - if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), - ], - child: _SliverTimeline( - key: const ValueKey('_sliver_timeline'), - topSliverWidget: topSliverWidget, - topSliverWidgetHeight: topSliverWidgetHeight, - appBar: appBar, - bottomSheet: bottomSheet, - withScrubber: withScrubber, - snapToMonth: snapToMonth, - initialScrollOffset: initialScrollOffset, - ), - ), + ); + }, ), ); } @@ -141,7 +202,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { int _perRow = 4; double _scaleFactor = 3.0; double _baseScaleFactor = 3.0; - int? _restoreAssetIndex; @override void initState() { @@ -182,13 +242,16 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } void _restoreAssetPosition(_) { - if (_restoreAssetIndex == null) return; + final restorationState = _TimelineRestorationProvider.of(context); + if (!restorationState.shouldRestoreAssetPosition || restorationState.restoreAssetIndex == null) return; final asyncSegments = ref.read(timelineSegmentProvider); asyncSegments.whenData((segments) { - final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!); + final targetSegment = segments.lastWhereOrNull( + (segment) => segment.firstAssetIndex <= restorationState.restoreAssetIndex!, + ); if (targetSegment != null) { - final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex; + final assetIndexInSegment = restorationState.restoreAssetIndex! - targetSegment.firstAssetIndex; final newColumnCount = ref.read(timelineArgsProvider).columnCount; final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor(); final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment; @@ -200,7 +263,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }); } }); - _restoreAssetIndex = null; + restorationState.clearRestoreAssetIndex(); } int? _getCurrentAssetIndex(List segments) { @@ -411,7 +474,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { onNotification: (notification) { final currentIndex = _getCurrentAssetIndex(segments); if (currentIndex != null && mounted) { - _restoreAssetIndex = currentIndex; + _TimelineRestorationProvider.of(context).setRestoreAssetIndex(currentIndex); } return false; }, @@ -430,12 +493,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final targetAssetIndex = _getCurrentAssetIndex(segments); if (newPerRow != _perRow) { + final restorationState = _TimelineRestorationProvider.of(context); setState(() { _scaleFactor = newScaleFactor; _perRow = newPerRow; - _restoreAssetIndex = targetAssetIndex; }); + restorationState.setRestoreAssetIndex(targetAssetIndex); + restorationState.setShouldRestoreAssetPosition(true); ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); } }; From 4dccc2082bb8b933249afee68eb2978472df50b7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:30:41 +0100 Subject: [PATCH 03/78] fix(web): focus tag input when modal opens (#26256) --- pnpm-lock.yaml | 10 +++++----- web/package.json | 2 +- web/src/lib/modals/AssetTagModal.svelte | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9af490b82a..c139181d8b 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.62.1 - version: 0.62.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.0) + 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.0)(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.0)(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.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3018,8 +3018,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.62.1': - resolution: {integrity: sha512-+rZAjw24pAIJ1hmCtYF16BECh+7M09UudTPc28z6U2J3CZzSOs0+Nsz5fTs8SE5wyC45QKdPWJCS//xFMrrRUg==} + '@immich/ui@0.63.0': + resolution: {integrity: sha512-WTdEZi1XEvhcdQymFCIb8Us2DJv+Vp4wTytYwIgQUeXMFSQ8aUT7m76Wsa6uphmuFqyyJioFU+g4rIfJ+w2R5w==} peerDependencies: svelte: ^5.0.0 @@ -14961,7 +14961,7 @@ snapshots: node-emoji: 2.2.0 svelte: 5.50.0 - '@immich/ui@0.62.1(@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.0)': + '@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.0)(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.0)(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.0)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.0) '@internationalized/date': 3.10.0 diff --git a/web/package.json b/web/package.json index 5b66c75029..bfe9eb112f 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.62.1", + "@immich/ui": "^0.63.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/modals/AssetTagModal.svelte b/web/src/lib/modals/AssetTagModal.svelte index 74daf75659..dbd5bdb118 100644 --- a/web/src/lib/modals/AssetTagModal.svelte +++ b/web/src/lib/modals/AssetTagModal.svelte @@ -62,6 +62,7 @@ {onClose} {onSubmit} submitText={$t('tag_assets')} + onOpenAutoFocus={(event) => event.preventDefault()} {disabled} >
From cc9c261fd06afc66a4e56d8838cdebd6bac44232 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:52:34 +0100 Subject: [PATCH 04/78] fix(web): clear face boxes when switching assets (#26249) --- web/src/lib/components/asset-viewer/photo-viewer.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 2101107f6e..61181acbc8 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -57,7 +57,10 @@ $effect.pre(() => { void asset.id; - untrack(() => assetViewerManager.resetZoomState()); + untrack(() => { + assetViewerManager.resetZoomState(); + $boundingBoxesArray = []; + }); }); onDestroy(() => { From 0da74569f2d6ca0c3d9554fe7f6514ee5501a05f Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:25:13 +0100 Subject: [PATCH 05/78] fix(web): clear unsaved asset description when changing asset (#26255) * fix(web): clear unsaved asset description when changing asset * remove unneeded $derived --- .../detail-panel-description.spec.ts | 65 +++++++++++++++++++ .../detail-panel-description.svelte | 4 +- 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/detail-panel-description.spec.ts diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts b/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts new file mode 100644 index 0000000000..3175bd8194 --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-description.spec.ts @@ -0,0 +1,65 @@ +import { assetFactory } from '@test-data/factories/asset-factory'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import DetailPanelDescription from './detail-panel-description.svelte'; + +describe('DetailPanelDescription', () => { + it('clears unsaved draft on asset change', async () => { + const user = userEvent.setup(); + + const assetA = assetFactory.build({ + id: 'asset-a', + exifInfo: { description: '' }, + }); + const assetB = assetFactory.build({ + id: 'asset-b', + exifInfo: { description: '' }, + }); + + const { rerender } = render(DetailPanelDescription, { + props: { + asset: assetA, + isOwner: true, + }, + }); + + const textarea = screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement; + await user.type(textarea, 'unsaved draft'); + expect(textarea).toHaveValue('unsaved draft'); + + await rerender({ + asset: assetB, + isOwner: true, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue(''); + }); + + it('updates description on asset switch', async () => { + const assetA = assetFactory.build({ + id: 'asset-a', + exifInfo: { description: 'first description' }, + }); + const assetB = assetFactory.build({ + id: 'asset-b', + exifInfo: { description: 'second description' }, + }); + + const { rerender } = render(DetailPanelDescription, { + props: { + asset: assetA, + isOwner: true, + }, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue('first description'); + + await rerender({ + asset: assetB, + isOwner: true, + }); + + expect(screen.getByTestId('autogrow-textarea')).toHaveValue('second description'); + }); +}); diff --git a/web/src/lib/components/asset-viewer/detail-panel-description.svelte b/web/src/lib/components/asset-viewer/detail-panel-description.svelte index bc3929f3dd..9aeb7855b6 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-description.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-description.svelte @@ -13,10 +13,10 @@ let { asset, isOwner }: Props = $props(); - let currentDescription = $derived(asset.exifInfo?.description ?? ''); - let description = $derived(currentDescription); + let description = $derived(asset.exifInfo?.description ?? ''); const handleFocusOut = async () => { + const currentDescription = asset.exifInfo?.description ?? ''; if (description === currentDescription) { return; } From 75bdd6a6446a50ab76154209f79792856e4c5a08 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 16 Feb 2026 18:34:42 -0500 Subject: [PATCH 06/78] fix: development containers init race conditions (#25876) --- docker/docker-compose.dev.yml | 114 +++++++++++++++++++----------- e2e/docker-compose.dev.yml | 127 ++++++++++++++++------------------ e2e/docker-compose.yml | 27 +++++--- server/Dockerfile.dev | 16 ++--- 4 files changed, 156 insertions(+), 128 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 81fc492001..8c46d3c51f 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -14,33 +14,65 @@ name: immich-dev services: + immich-app-base: + profiles: ['_base'] + tmpfs: + - /tmp + volumes: + - ..:/usr/src/app + - pnpm_cache:/buildcache/pnpm_cache + - server_node_modules:/usr/src/app/server/node_modules + - web_node_modules:/usr/src/app/web/node_modules + - github_node_modules:/usr/src/app/.github/node_modules + - cli_node_modules:/usr/src/app/cli/node_modules + - docs_node_modules:/usr/src/app/docs/node_modules + - e2e_node_modules:/usr/src/app/e2e/node_modules + - sdk_node_modules:/usr/src/app/open-api/typescript-sdk/node_modules + - app_node_modules:/usr/src/app/node_modules + - sveltekit:/usr/src/app/web/.svelte-kit + - coverage:/usr/src/app/web/coverage + + immich-init: + extends: + service: immich-app-base + profiles: !reset [] + container_name: immich_init + image: immich-server-dev:latest + build: + context: ../ + dockerfile: server/Dockerfile.dev + target: dev + command: + - | + pnpm install + touch /tmp/init-complete + exec tail -f /dev/null + volumes: + - pnpm_store_server:/buildcache/pnpm-store + restart: 'no' + healthcheck: + test: ['CMD', 'test', '-f', '/tmp/init-complete'] + interval: 2s + timeout: 3s + retries: 300 + start_period: 300s + immich-server: + extends: + service: immich-app-base + profiles: !reset [] container_name: immich_server command: ['immich-dev'] image: immich-server-dev:latest - # extends: - # file: hwaccel.transcoding.yml - # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding build: context: ../ dockerfile: server/Dockerfile.dev target: dev restart: unless-stopped volumes: - - ..:/usr/src/app - ${UPLOAD_LOCATION}/photos:/data - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_server:/buildcache/pnpm-store - ../plugins:/build/corePlugin env_file: - .env @@ -63,6 +95,8 @@ services: - 9231:9231 - 2283:2283 depends_on: + immich-init: + condition: service_healthy redis: condition: service_started database: @@ -71,6 +105,9 @@ services: disable: false immich-web: + extends: + service: immich-app-base + profiles: !reset [] container_name: immich_web image: immich-web-dev:latest build: @@ -84,20 +121,11 @@ services: - 3000:3000 - 24678:24678 volumes: - - ..:/usr/src/app - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_web:/buildcache/pnpm-store restart: unless-stopped depends_on: + immich-init: + condition: service_healthy immich-server: condition: service_started @@ -116,7 +144,7 @@ services: - 3003:3003 volumes: - ../machine-learning/immich_ml:/usr/src/immich_ml - - model-cache:/cache + - model_cache:/cache env_file: - .env depends_on: @@ -156,7 +184,7 @@ services: # image: prom/prometheus # volumes: # - ./prometheus.yml:/etc/prometheus/prometheus.yml - # - prometheus-data:/prometheus + # - prometheus_data:/prometheus # first login uses admin/admin # add data source for http://immich-prometheus:9090 to get started @@ -167,20 +195,22 @@ services: # - 3000:3000 # image: grafana/grafana:10.3.3-ubuntu # volumes: - # - grafana-data:/var/lib/grafana + # - grafana_data:/var/lib/grafana volumes: - model-cache: - prometheus-data: - grafana-data: - pnpm-store: - server-node_modules: - web-node_modules: - github-node_modules: - cli-node_modules: - docs-node_modules: - e2e-node_modules: - sdk-node_modules: - app-node_modules: + model_cache: + prometheus_data: + grafana_data: + pnpm_cache: + pnpm_store_server: + pnpm_store_web: + server_node_modules: + web_node_modules: + github_node_modules: + cli_node_modules: + docs_node_modules: + e2e_node_modules: + sdk_node_modules: + app_node_modules: sveltekit: coverage: diff --git a/e2e/docker-compose.dev.yml b/e2e/docker-compose.dev.yml index 14e159ed50..b301ef8441 100644 --- a/e2e/docker-compose.dev.yml +++ b/e2e/docker-compose.dev.yml @@ -1,86 +1,77 @@ name: immich-e2e services: + immich-app-base: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-app-base + + immich-init: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-init + container_name: immich-e2e-init + immich-server: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-server container_name: immich-e2e-server - command: ['immich-dev'] - image: immich-server-dev:latest - build: - context: ../ - dockerfile: server/Dockerfile.dev - target: dev + ports: !reset [] + env_file: !reset [] environment: - - DB_HOSTNAME=database - - DB_USERNAME=postgres - - DB_PASSWORD=postgres - - DB_DATABASE_NAME=immich - - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_TELEMETRY_INCLUDE=all - - IMMICH_ENV=testing - - IMMICH_PORT=2285 - - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true + DB_HOSTNAME: database + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE_NAME: immich + IMMICH_MACHINE_LEARNING_ENABLED: 'false' + IMMICH_TELEMETRY_INCLUDE: all + IMMICH_ENV: testing + IMMICH_PORT: '2285' + IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true' volumes: - ./test-assets:/test-assets - - ..:/usr/src/app - - ${UPLOAD_LOCATION}/photos:/data - - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage - - ../plugins:/build/corePlugin depends_on: + immich-init: + condition: service_healthy redis: condition: service_started database: condition: service_healthy immich-web: + extends: + file: ../docker/docker-compose.dev.yml + service: immich-web container_name: immich-e2e-web - image: immich-web-dev:latest - build: - context: ../ - dockerfile: server/Dockerfile.dev - target: dev - command: ['immich-web'] - ports: + ports: !override - 2285:3000 environment: - - IMMICH_SERVER_URL=http://immich-server:2285/ - volumes: - - ..:/usr/src/app - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + IMMICH_SERVER_URL: http://immich-server:2285/ + depends_on: + immich-init: + condition: service_healthy restart: unless-stopped redis: - image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef + extends: + file: ../docker/docker-compose.dev.yml + service: redis + container_name: immich-e2e-redis database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 + extends: + file: ../docker/docker-compose.dev.yml + service: database + container_name: immich-e2e-postgres command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf + env_file: !reset [] + ports: !override + - 5435:5432 environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: immich - ports: - - 5435:5432 healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres -d immich'] interval: 1s @@ -89,17 +80,19 @@ services: start_period: 10s volumes: - model-cache: - prometheus-data: - grafana-data: - pnpm-store: - server-node_modules: - web-node_modules: - github-node_modules: - cli-node_modules: - docs-node_modules: - e2e-node_modules: - sdk-node_modules: - app-node_modules: + model_cache: + prometheus_data: + grafana_data: + pnpm_cache: + pnpm_store_server: + pnpm_store_web: + server_node_modules: + web_node_modules: + github_node_modules: + cli_node_modules: + docs_node_modules: + e2e_node_modules: + sdk_node_modules: + app_node_modules: sveltekit: coverage: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 5a79396aa5..2ef57475b7 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -2,6 +2,7 @@ name: immich-e2e services: e2e-auth-server: + container_name: immich-e2e-auth-server build: context: ../e2e-auth-server ports: @@ -22,15 +23,15 @@ services: - BUILD_SOURCE_REF=e2e - BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee environment: - - DB_HOSTNAME=database - - DB_USERNAME=postgres - - DB_PASSWORD=postgres - - DB_DATABASE_NAME=immich - - IMMICH_MACHINE_LEARNING_ENABLED=false - - IMMICH_TELEMETRY_INCLUDE=all - - IMMICH_ENV=testing - - IMMICH_PORT=2285 - - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true + DB_HOSTNAME: database + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE_NAME: immich + IMMICH_MACHINE_LEARNING_ENABLED: 'false' + IMMICH_TELEMETRY_INCLUDE: all + IMMICH_ENV: testing + IMMICH_PORT: '2285' + IMMICH_IGNORE_MOUNT_CHECK_ERRORS: 'true' volumes: - ./test-assets:/test-assets depends_on: @@ -42,10 +43,14 @@ services: - 2285:2285 redis: - image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef + container_name: immich-e2e-redis + image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + healthcheck: + test: redis-cli ping || exit 1 database: - image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338 + container_name: immich-e2e-postgres + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf environment: POSTGRES_PASSWORD: postgres diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index be752dd862..74757956fc 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -3,19 +3,19 @@ FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e0 ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ - COREPACK_HOME=/tmp + COREPACK_HOME=/tmp \ + PNPM_HOME=/buildcache/pnpm-store RUN npm install --global corepack@latest && \ corepack enable pnpm && \ + echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ - echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc + echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \ + echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \ + echo "fetch-retries=5" >> /usr/local/etc/npmrc && \ + mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \ + chmod -R o+rw /buildcache -COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/ -COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/ -COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/ -COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/ -WORKDIR /tmp/create-dep-cache -RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache WORKDIR /usr/src/app ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \ From de7b42eb2300c90b0f1ba6b3279f0bb00c6e71e9 Mon Sep 17 00:00:00 2001 From: Joren Guillaume Date: Tue, 17 Feb 2026 11:39:43 +0100 Subject: [PATCH 07/78] chore(docs): Update help channel for developers (#26284) Update help channel for developers From ceef65154d3218238fd47dc81314d17226e5c65e Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:43:08 +0100 Subject: [PATCH 08/78] fix(web): clear cache when asset changes (#26257) * fix(web): clear cache when asset changes * formatting --- .../lib/managers/AssetCacheManager.svelte.ts | 47 ++++++++++++------- web/src/lib/stores/ocr.svelte.spec.ts | 1 + web/src/lib/stores/websocket.ts | 1 + 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/web/src/lib/managers/AssetCacheManager.svelte.ts b/web/src/lib/managers/AssetCacheManager.svelte.ts index f3c85acfa5..b90cf565c5 100644 --- a/web/src/lib/managers/AssetCacheManager.svelte.ts +++ b/web/src/lib/managers/AssetCacheManager.svelte.ts @@ -1,25 +1,23 @@ +import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; -import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk'; +import { getAssetInfo, getAssetOcr } from '@immich/sdk'; const defaultSerializer = (params: K) => JSON.stringify(params); -class AsyncCache { +class AsyncCache { #cache = new Map(); - async getOrFetch( - params: K, - fetcher: (params: K) => Promise, - keySerializer: (params: K) => string = defaultSerializer, - updateCache: boolean, - ): Promise { - const cacheKey = keySerializer(params); + constructor(private fetcher: (params: K) => Promise) {} + + async getOrFetch(params: K, updateCache: boolean): Promise { + const cacheKey = defaultSerializer(params); const cached = this.#cache.get(cacheKey); if (cached) { return cached; } - const value = await fetcher(params); + const value = await this.fetcher(params); if (value && updateCache) { this.#cache.set(cacheKey, value); } @@ -27,30 +25,43 @@ class AsyncCache { return value; } + clearKey(params: K) { + const cacheKey = defaultSerializer(params); + this.#cache.delete(cacheKey); + } + clear() { this.#cache.clear(); } } class AssetCacheManager { - #assetCache = new AsyncCache(); - #ocrCache = new AsyncCache(); + #assetCache = new AsyncCache(getAssetInfo); + #ocrCache = new AsyncCache(getAssetOcr); constructor() { eventManager.on({ - AssetEditsApplied: () => { - this.#assetCache.clear(); - this.#ocrCache.clear(); + AssetEditsApplied: (assetId) => { + this.invalidateAsset(assetId); + }, + AssetUpdate: (asset) => { + this.invalidateAsset(asset.id); }, }); } - async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) { - return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache); + async getAsset({ id, key, slug }: { id: string; key?: string; slug?: string }, updateCache = true) { + return this.#assetCache.getOrFetch({ id, key, slug }, updateCache); } async getAssetOcr(id: string) { - return this.#ocrCache.getOrFetch({ id }, getAssetOcr, (params) => params.id, true); + return this.#ocrCache.getOrFetch({ id }, true); + } + + invalidateAsset(id: string) { + const { key, slug } = authManager.params; + this.#assetCache.clearKey({ id, key, slug }); + this.#ocrCache.clearKey({ id }); } clearAssetCache() { diff --git a/web/src/lib/stores/ocr.svelte.spec.ts b/web/src/lib/stores/ocr.svelte.spec.ts index 5220cbb77d..1e2aeecb73 100644 --- a/web/src/lib/stores/ocr.svelte.spec.ts +++ b/web/src/lib/stores/ocr.svelte.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock the SDK vi.mock('@immich/sdk', () => ({ + getAssetInfo: vi.fn(), getAssetOcr: vi.fn(), })); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 335ec188ea..32aa52fccb 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -77,6 +77,7 @@ websocket .on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event)) .on('on_session_delete', () => authManager.logout()) .on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id })) + .on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset)) .on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id })) .on('on_notification', () => notificationManager.refresh()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); From 90ef6c4e282cd8e065e174a5f4f4fc7ff257a01c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:44:21 +0100 Subject: [PATCH 09/78] chore(deps): update docker.io/valkey/valkey:9 docker digest to 930b414 (#26272) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- e2e/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 2ef57475b7..8ae5762a1b 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63 + image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e healthcheck: test: redis-cli ping || exit 1 From b3c37905f7913d5fe01bacaf8ceb963a9f9a5eb6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:44:38 +0100 Subject: [PATCH 10/78] chore(deps): update dependency @types/node to ^24.10.13 (#26273) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package.json | 2 +- e2e/package.json | 2 +- open-api/typescript-sdk/package.json | 2 +- pnpm-lock.yaml | 9 +++++---- server/package.json | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cli/package.json b/cli/package.json index d80efdd74a..50c14949aa 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package.json b/e2e/package.json index abe46a39ca..fc6d9e9c8e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -27,7 +27,7 @@ "@playwright/test": "^1.44.1", "@socket.io/component-emitter": "^3.1.2", "@types/luxon": "^3.4.2", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/pg": "^8.15.1", "@types/pngjs": "^6.0.4", "@types/supertest": "^6.0.2", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 6310316857..d3f64f6a2b 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "typescript": "^5.3.3" }, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c139181d8b..b4927578dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.1 version: 4.13.4 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@vitest/coverage-v8': specifier: ^3.0.0 @@ -220,7 +220,7 @@ importers: specifier: ^3.4.2 version: 3.7.1 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/pg': specifier: ^8.15.1 @@ -320,7 +320,7 @@ importers: version: 1.1.0 devDependencies: '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 typescript: specifier: ^5.3.3 @@ -639,7 +639,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@types/node': - specifier: ^24.10.11 + specifier: ^24.10.13 version: 24.10.13 '@types/nodemailer': specifier: ^7.0.0 @@ -11330,6 +11330,7 @@ packages: tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} diff --git a/server/package.json b/server/package.json index 80427642e5..680d0be8ea 100644 --- a/server/package.json +++ b/server/package.json @@ -135,7 +135,7 @@ "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", "@types/multer": "^2.0.0", - "@types/node": "^24.10.11", + "@types/node": "^24.10.13", "@types/nodemailer": "^7.0.0", "@types/picomatch": "^4.0.0", "@types/pngjs": "^6.0.5", From 18bbb5b4db9a0098e983d3849ea3c54045e2793e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:45:57 +0100 Subject: [PATCH 11/78] chore(deps): update node.js to v24.13.1 (#26275) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/.nvmrc | 2 +- cli/.nvmrc | 2 +- cli/package.json | 2 +- docs/.nvmrc | 2 +- docs/package.json | 2 +- e2e/.nvmrc | 2 +- e2e/package.json | 2 +- mise.toml | 2 +- open-api/typescript-sdk/.nvmrc | 2 +- open-api/typescript-sdk/package.json | 2 +- server/.nvmrc | 2 +- server/package.json | 2 +- web/.nvmrc | 2 +- web/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/.nvmrc b/.github/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/.github/.nvmrc +++ b/.github/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/cli/.nvmrc b/cli/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/cli/package.json b/cli/package.json index 50c14949aa..8e2aec0282 100644 --- a/cli/package.json +++ b/cli/package.json @@ -69,6 +69,6 @@ "micromatch": "^4.0.8" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/docs/.nvmrc b/docs/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/docs/package.json b/docs/package.json index 87b0b3fccd..c22826b3cb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -58,6 +58,6 @@ "node": ">=20" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/e2e/package.json b/e2e/package.json index fc6d9e9c8e..df9687e0a2 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -52,6 +52,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/mise.toml b/mise.toml index 3ca0d353ea..14645eeea3 100644 --- a/mise.toml +++ b/mise.toml @@ -14,7 +14,7 @@ config_roots = [ ] [tools] -node = "24.13.0" +node = "24.13.1" flutter = "3.35.7" pnpm = "10.28.2" terragrunt = "0.98.0" diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index d3f64f6a2b..8f057df6cc 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } diff --git a/server/.nvmrc b/server/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/server/package.json b/server/package.json index 680d0be8ea..814934b1be 100644 --- a/server/package.json +++ b/server/package.json @@ -167,7 +167,7 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" }, "overrides": { "sharp": "^0.34.5" diff --git a/web/.nvmrc b/web/.nvmrc index 3fe3b1570a..32f8c50de0 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -24.13.0 +24.13.1 diff --git a/web/package.json b/web/package.json index bfe9eb112f..db5a05617e 100644 --- a/web/package.json +++ b/web/package.json @@ -108,6 +108,6 @@ "vitest": "^3.0.0" }, "volta": { - "node": "24.13.0" + "node": "24.13.1" } } From 398b750ef76d077ccbac97f7eb2fed1e711ac5aa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:49:14 +0100 Subject: [PATCH 12/78] chore(deps): update dependency github:extism/js-pdk to v1.6.0 (#26279) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- plugins/mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/mise.toml b/plugins/mise.toml index c1001e574b..66a107674d 100644 --- a/plugins/mise.toml +++ b/plugins/mise.toml @@ -1,7 +1,7 @@ [tools] "github:extism/cli" = "1.6.3" "github:webassembly/binaryen" = "version_124" -"github:extism/js-pdk" = "1.5.1" +"github:extism/js-pdk" = "1.6.0" [tasks.install] run = "pnpm install --frozen-lockfile" From a16a00ebd4c9269ee3905019f9051cc0939f53f0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:50:02 +0000 Subject: [PATCH 13/78] fix(deps): update typescript-projects (#26276) * fix(deps): update typescript-projects * chore: downgrade kysely --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- mise.toml | 2 +- package.json | 2 +- pnpm-lock.yaml | 578 +++++++++++++++++++++++------------------------ web/package.json | 2 +- 4 files changed, 292 insertions(+), 292 deletions(-) diff --git a/mise.toml b/mise.toml index 14645eeea3..cf517598c6 100644 --- a/mise.toml +++ b/mise.toml @@ -16,7 +16,7 @@ config_roots = [ [tools] node = "24.13.1" flutter = "3.35.7" -pnpm = "10.28.2" +pnpm = "10.29.3" terragrunt = "0.98.0" opentofu = "1.11.4" java = "21.0.2" diff --git a/package.json b/package.json index 0e4017f928..c50c4e1eb8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "2.5.6", "description": "Monorepo for Immich", "private": true, - "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264", + "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc", "engines": { "pnpm": ">=10.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4927578dc..71dece4861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 24.10.13 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -106,19 +106,19 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.0.0 version: 7.3.1(@types/node@24.10.13)(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-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(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.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(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: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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)) yaml: specifier: ^2.3.1 version: 2.8.2 @@ -127,16 +127,16 @@ importers: dependencies: '@docusaurus/core': specifier: ~3.9.0 - version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/preset-classic': specifier: ~3.9.0 - version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + version: 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/theme-common': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-mermaid': specifier: ~3.9.0 - version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@mdi/js': specifier: ^7.3.67 version: 7.4.47 @@ -145,13 +145,13 @@ importers: version: 1.6.1 '@mdx-js/react': specifier: ^3.0.0 - version: 3.1.1(@types/react@19.2.13)(react@18.3.1) + version: 3.1.1(@types/react@19.2.14)(react@18.3.1) autoprefixer: specifier: ^10.4.17 version: 10.4.24(postcss@8.5.6) docusaurus-lunr-search: specifier: ^3.3.2 - version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lunr: specifier: ^2.3.9 version: 2.3.9 @@ -281,13 +281,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) utimes: specifier: ^5.2.1 version: 5.2.1(encoding@0.1.13) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) e2e-auth-server: devDependencies: @@ -317,7 +317,7 @@ importers: dependencies: '@oazapfts/runtime': specifier: ^1.0.2 - version: 1.1.0 + version: 1.2.0 devDependencies: '@types/node': specifier: ^24.10.13 @@ -345,7 +345,7 @@ importers: version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3) + version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0) '@nestjs/common': specifier: ^11.0.4 version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -426,7 +426,7 @@ importers: version: 2.2.2 bullmq: specifier: ^5.51.0 - version: 5.67.3 + version: 5.68.0 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -516,7 +516,7 @@ importers: version: 7.0.13 openid-client: specifier: ^6.3.3 - version: 6.8.1 + version: 6.8.2 pg: specifier: ^8.11.3 version: 8.18.0 @@ -652,7 +652,7 @@ importers: version: 6.0.5 '@types/react': specifier: ^19.0.0 - version: 19.2.13 + version: 19.2.14 '@types/sanitize-html': specifier: ^2.13.0 version: 2.16.0 @@ -670,7 +670,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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)) eslint: specifier: ^9.14.0 version: 9.39.2(jiti@2.6.1) @@ -718,16 +718,16 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.28.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) unplugin-swc: specifier: ^1.4.5 version: 1.5.9(@swc/core@1.15.11(@swc/helpers@0.5.17))(rollup@4.55.1) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(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.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(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: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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) web: dependencies: @@ -742,7 +742,7 @@ importers: 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.0)(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.0)(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.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) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -775,7 +775,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.50.0) + version: 0.3.9(svelte@5.50.2) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -793,7 +793,7 @@ importers: version: 4.7.8 happy-dom: specifier: ^20.0.0 - version: 20.5.0 + version: 20.6.1 intl-messageformat: specifier: ^11.0.0 version: 11.1.2 @@ -808,7 +808,7 @@ importers: version: 3.7.2 maplibre-gl: specifier: ^5.6.2 - version: 5.17.0 + version: 5.18.0 pmtiles: specifier: ^4.3.0 version: 4.4.0 @@ -826,16 +826,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.50.0) + version: 4.0.1(svelte@5.50.2) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.11.0(svelte@5.50.0) + version: 3.11.0(svelte@5.50.2) svelte-maplibre: specifier: ^1.2.5 - version: 1.2.6(svelte@5.50.0) + version: 1.2.6(svelte@5.50.2) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.50.0) + version: 0.12.0(svelte@5.50.2) 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.0)(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.0)(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.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/enhanced-img': specifier: ^0.10.0 - version: 0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.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/kit': specifier: ^2.27.1 - version: 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.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/vite-plugin-svelte': specifier: 6.2.4 - version: 6.2.4(svelte@5.50.0)(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.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)) '@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.0)(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.5.0)(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.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/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.1) @@ -905,7 +905,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(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: 3.2.4(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)) dotenv: specifier: ^17.0.0 version: 17.2.4 @@ -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.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.0) + version: 3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2) 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.0) + version: 3.4.1(prettier@3.8.1)(svelte@5.50.2) rollup-plugin-visualizer: specifier: ^6.0.0 version: 6.0.5(rollup@4.55.1) svelte: - specifier: 5.50.0 - version: 5.50.0 + specifier: 5.50.2 + version: 5.50.2 svelte-check: specifier: ^4.1.5 - version: 4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3) + version: 4.3.6(picomatch@4.0.3)(svelte@5.50.2)(typescript@5.9.3) svelte-eslint-parser: specifier: ^1.3.3 - version: 1.4.1(svelte@5.50.0) + version: 1.4.1(svelte@5.50.2) tailwindcss: specifier: ^4.1.7 version: 4.1.18 @@ -962,13 +962,13 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.45.0 - version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.1.2 version: 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: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(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: 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) packages: @@ -3600,8 +3600,8 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true - '@oazapfts/runtime@1.1.0': - resolution: {integrity: sha512-PwCn69pexqg/uhc0bpEHSlRFdfTtSnq3icXHd0wf4BQwZSMKsCerTnydzegVScEegYkokzIxMcl9li7on86A2w==} + '@oazapfts/runtime@1.2.0': + resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==} '@opentelemetry/api-logs@0.211.0': resolution: {integrity: sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==} @@ -5056,8 +5056,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@19.2.13': - resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -5140,63 +5140,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.54.0': - resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.54.0 + '@typescript-eslint/parser': ^8.55.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.54.0': - resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.54.0': - resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.54.0': - resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.54.0': - resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.54.0': - resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.54.0': - resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.54.0': - resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.54.0': - resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.54.0': - resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -5744,8 +5744,8 @@ packages: resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} engines: {node: '>=18.20'} - bullmq@5.67.3: - resolution: {integrity: sha512-eeQobOJn8M0Rj8tcZCVFLrimZgJQallJH1JpclOoyut2nDNkDwTEPMVcZzLeSR2fGeIVbfJTjU96F563Qkge5A==} + bullmq@5.68.0: + resolution: {integrity: sha512-PywC7eTcPrKVQN5iEfhs5ats90nSLr8dzsyIhgviO8qQRTHnTq/SnETq2E8Do1RLg7Qw1Q0p5htBPI/cUGAlHg==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -7034,11 +7034,11 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-svelte@3.14.0: - resolution: {integrity: sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==} + eslint-plugin-svelte@3.15.0: + resolution: {integrity: sha512-QKB7zqfuB8aChOfBTComgDptMf2yxiJx7FE04nneCmtQzgTHvY8UJkuh8J2Rz7KB9FFV9aTHX6r7rdYGvG8T9Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.1 || ^9.0.0 + eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: svelte: @@ -7619,8 +7619,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.5.0: - resolution: {integrity: sha512-VQe+Q5CYiGOgcCERXhcfNsbnrN92FDEKciMH/x6LppU9dd0j4aTjCTlqONFOIMcAm/5JxS3+utowbXV1OoFr+g==} + happy-dom@20.6.1: + resolution: {integrity: sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -8667,8 +8667,8 @@ packages: resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} engines: {node: '>=6.4.0'} - maplibre-gl@5.17.0: - resolution: {integrity: sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==} + maplibre-gl@5.18.0: + resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} mark.js@8.11.1: @@ -9316,8 +9316,8 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true - oauth4webapi@3.8.3: - resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -9382,8 +9382,8 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true - openid-client@6.8.1: - resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} + openid-client@6.8.2: + resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -11227,8 +11227,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.50.0: - resolution: {integrity: sha512-FR9kTLmX5i0oyeQ5j/+w8DuagIkQ7MWMuPpPVioW2zx9Dw77q+1ufLzF1IqNtcTXPRnIIio4PlasliVn43OnbQ==} + svelte@5.50.2: + resolution: {integrity: sha512-WCxzm3BBf+Ase6RwiDPR4G36cM4Kb0NuhmLK6x44I+D6reaxizDDg8kBkk4jT/19+Rgmc44eZkOvMO6daoSFIw==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -11593,8 +11593,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.54.0: - resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + typescript-eslint@8.55.0: + resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -11849,8 +11849,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-tsconfig-paths@6.1.0: - resolution: {integrity: sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg==} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} peerDependencies: vite: '*' @@ -13637,26 +13637,26 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@docsearch/core@4.3.1(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docsearch/core@4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': optionalDependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) '@docsearch/css@4.3.2': {} - '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + '@docsearch/react@4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': dependencies: '@ai-sdk/react': 2.0.115(react@18.3.1)(zod@4.2.1) '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.46.0)(algoliasearch@5.46.0)(search-insights@2.17.3) - '@docsearch/core': 4.3.1(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docsearch/core': 4.3.1(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docsearch/css': 4.3.2 ai: 5.0.113(zod@4.2.1) algoliasearch: 5.46.0 marked: 16.4.2 zod: 4.2.1 optionalDependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) search-insights: 2.17.3 @@ -13730,7 +13730,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: '@docusaurus/babel': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/bundler': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) @@ -13739,7 +13739,7 @@ snapshots: '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) boxen: 6.2.1 chalk: 4.1.2 chokidar: 3.6.0 @@ -13845,7 +13845,7 @@ snapshots: dependencies: '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 '@types/react-router-dom': 5.3.3 react: 18.3.1 @@ -13859,13 +13859,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13900,13 +13900,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13940,9 +13940,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13970,9 +13970,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -13997,9 +13997,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fs-extra: 11.3.2 @@ -14025,9 +14025,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14051,9 +14051,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/gtag.js': 0.0.12 @@ -14078,9 +14078,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 @@ -14104,9 +14104,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14135,9 +14135,9 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14165,22 +14165,22 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -14207,25 +14207,25 @@ snapshots: '@docusaurus/react-loadable@6.0.0(react@18.3.1)': dependencies: - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 - '@docusaurus/theme-classic@3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-classic@3.9.2(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@18.3.1) clsx: 2.1.1 infima: 0.2.0-alpha.45 lodash: 4.17.23 @@ -14257,15 +14257,15 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router-config': 5.0.11 clsx: 2.1.1 parse-numeric-range: 1.3.0 @@ -14281,11 +14281,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + '@docusaurus/theme-mermaid@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) mermaid: 11.12.2 @@ -14311,13 +14311,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.46.0)(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': dependencies: - '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docsearch/react': 4.3.2(@algolia/client-search@5.46.0)(@types/react@19.2.14)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) '@docusaurus/logger': 3.9.2 - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/theme-translations': 3.9.2 '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -14364,7 +14364,7 @@ snapshots: '@mdx-js/mdx': 3.1.1 '@types/history': 4.7.11 '@types/mdast': 4.0.4 - '@types/react': 19.2.13 + '@types/react': 19.2.14 commander: 5.1.0 joi: 17.13.3 react: 18.3.1 @@ -14955,22 +14955,22 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.0)': + '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.2)': dependencies: front-matter: 4.0.2 marked: 17.0.1 node-emoji: 2.2.0 - svelte: 5.50.0 + svelte: 5.50.2 - '@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.0)(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.0)(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.0)': + '@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)': dependencies: - '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.0) + '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.2) '@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.0)(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.0)(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.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) luxon: 3.7.2 simple-icons: 16.4.0 - svelte: 5.50.0 + svelte: 5.50.2 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) @@ -15247,8 +15247,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) browserslist: 4.28.1 transitivePeerDependencies: - eslint @@ -15415,10 +15415,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1)': + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1)': dependencies: '@types/mdx': 2.0.13 - '@types/react': 19.2.13 + '@types/react': 19.2.14 react: 18.3.1 '@mermaid-js/parser@0.6.3': @@ -15453,12 +15453,12 @@ snapshots: '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.67.3)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0)': dependencies: '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13) '@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(@nestjs/websockets@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) - bullmq: 5.67.3 + bullmq: 5.68.0 tslib: 2.8.1 '@nestjs/cli@11.0.16(@swc/core@1.15.11(@swc/helpers@0.5.17))(@types/node@24.10.13)': @@ -15635,7 +15635,7 @@ snapshots: dependencies: consola: 3.4.2 - '@oazapfts/runtime@1.1.0': {} + '@oazapfts/runtime@1.2.0': {} '@opentelemetry/api-logs@0.211.0': dependencies: @@ -16321,17 +16321,17 @@ snapshots: dependencies: acorn: 8.15.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.0)(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.0)(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.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)))': dependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.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/enhanced-img@0.10.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(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.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)) magic-string: 0.30.21 sharp: 0.34.5 - svelte: 5.50.0 - svelte-parse-markup: 0.1.5(svelte@5.50.0) + svelte: 5.50.2 + svelte-parse-markup: 0.1.5(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) vite-imagetools: 9.0.2(rollup@4.55.1) zimmerframe: 1.1.4 @@ -16339,11 +16339,11 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.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))': 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.0)(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.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)) '@types/cookie': 0.6.0 acorn: 8.15.0 cookie: 0.6.0 @@ -16355,28 +16355,28 @@ snapshots: sade: 1.8.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.50.0 + 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) 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.0)(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.0)(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.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))': dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.50.0)(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.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)) debug: 4.4.3 - svelte: 5.50.0 + 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) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.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))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.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)) deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 - svelte: 5.50.0 + 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) 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: @@ -16624,18 +16624,18 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte-core@1.0.0(svelte@5.50.0)': + '@testing-library/svelte-core@1.0.0(svelte@5.50.2)': dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - '@testing-library/svelte@5.3.1(svelte@5.50.0)(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.5.0)(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.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))': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.0.0(svelte@5.50.0) - svelte: 5.50.0 + '@testing-library/svelte-core': 1.0.0(svelte@5.50.2) + svelte: 5.50.2 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.5.0)(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) + 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@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17118,21 +17118,21 @@ snapshots: '@types/react-router-config@5.0.11': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 19.2.13 + '@types/react': 19.2.14 - '@types/react@19.2.13': + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -17236,14 +17236,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -17252,41 +17252,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.54.0': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -17294,14 +17294,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.54.0': {} + '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/visitor-keys': 8.54.0 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.4 @@ -17311,27 +17311,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.54.0 - '@typescript-eslint/types': 8.54.0 - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.54.0': + '@typescript-eslint/visitor-keys@8.55.0': dependencies: - '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17346,11 +17346,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(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))': + '@vitest/coverage-v8@3.2.4(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: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17365,7 +17365,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(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) + 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) transitivePeerDependencies: - supports-color @@ -17503,10 +17503,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.50.0)': + '@zoom-image/svelte@0.3.9(svelte@5.50.2)': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.50.0 + svelte: 5.50.2 abab@2.0.6: optional: true @@ -17867,15 +17867,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.0)(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.0)(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.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): 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.0)(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.0)(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.0) - svelte: 5.50.0 - 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.0)(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.0)(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.0) + 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) tabbable: 6.4.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -17988,7 +17988,7 @@ snapshots: builtin-modules@5.0.0: {} - bullmq@5.67.3: + bullmq@5.68.0: dependencies: cron-parser: 4.9.0 ioredis: 5.9.2 @@ -19036,9 +19036,9 @@ snapshots: transitivePeerDependencies: - supports-color - docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + docusaurus-lunr-search@3.6.0(@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) autocomplete.js: 0.37.1 clsx: 2.1.1 gauge: 3.0.2 @@ -19403,7 +19403,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.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.0): + eslint-plugin-svelte@3.15.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.50.2): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@jridgewell/sourcemap-codec': 1.5.5 @@ -19415,9 +19415,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.0) + svelte-eslint-parser: 1.4.1(svelte@5.50.2) optionalDependencies: - svelte: 5.50.0 + svelte: 5.50.2 transitivePeerDependencies: - ts-node @@ -20155,12 +20155,12 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.5.0: + happy-dom@20.6.1: dependencies: '@types/node': 24.10.13 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 - entities: 4.5.0 + entities: 6.0.1 whatwg-mimetype: 3.0.0 ws: 8.19.0 transitivePeerDependencies: @@ -21355,7 +21355,7 @@ snapshots: tinyqueue: 2.0.3 vt-pbf: 3.1.3 - maplibre-gl@5.17.0: + maplibre-gl@5.18.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 '@mapbox/jsonlint-lines-primitives': 2.0.2 @@ -22317,7 +22317,7 @@ snapshots: pkg-types: 2.3.0 tinyexec: 0.3.2 - oauth4webapi@3.8.3: {} + oauth4webapi@3.8.5: {} object-assign@4.1.1: {} @@ -22390,10 +22390,10 @@ snapshots: opener@1.5.2: {} - openid-client@6.8.1: + openid-client@6.8.2: dependencies: jose: 6.1.3 - oauth4webapi: 3.8.3 + oauth4webapi: 3.8.5 optionator@0.9.4: dependencies: @@ -23207,10 +23207,10 @@ snapshots: dependencies: prettier: 3.8.1 - prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.0): + prettier-plugin-svelte@3.4.1(prettier@3.8.1)(svelte@5.50.2): dependencies: prettier: 3.8.1 - svelte: 5.50.0 + svelte: 5.50.2 prettier@3.8.1: {} @@ -23837,14 +23837,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.0)(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.0)(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.0): + 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): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.50.0 + svelte: 5.50.2 optionalDependencies: - '@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.50.0)(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.0)(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.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)) rw@1.3.3: {} @@ -24474,23 +24474,23 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.50.0): + svelte-awesome@3.3.5(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.0)(typescript@5.9.3): + svelte-check@4.3.6(picomatch@4.0.3)(svelte@5.50.2)(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.0 + svelte: 5.50.2 typescript: 5.9.3 transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.4.1(svelte@5.50.0): + svelte-eslint-parser@1.4.1(svelte@5.50.2): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -24499,7 +24499,7 @@ snapshots: postcss-scss: 4.0.9(postcss@8.5.6) postcss-selector-parser: 7.1.1 optionalDependencies: - svelte: 5.50.0 + svelte: 5.50.2 svelte-floating-ui@1.5.8: dependencies: @@ -24512,7 +24512,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.50.0): + svelte-i18n@4.0.1(svelte@5.50.2): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -24520,10 +24520,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.50.0 + svelte: 5.50.2 tiny-glob: 0.2.9 - svelte-jsoneditor@3.11.0(svelte@5.50.0): + svelte-jsoneditor@3.11.0(svelte@5.50.2): dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/commands': 6.10.1 @@ -24550,42 +24550,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.97.1 - svelte: 5.50.0 - svelte-awesome: 3.3.5(svelte@5.50.0) + svelte: 5.50.2 + svelte-awesome: 3.3.5(svelte@5.50.2) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.2.6(svelte@5.50.0): + svelte-maplibre@1.2.6(svelte@5.50.2): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 - maplibre-gl: 5.17.0 + maplibre-gl: 5.18.0 pmtiles: 3.2.1 - svelte: 5.50.0 + svelte: 5.50.2 - svelte-parse-markup@0.1.5(svelte@5.50.0): + svelte-parse-markup@0.1.5(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 - svelte-persisted-store@0.12.0(svelte@5.50.0): + svelte-persisted-store@0.12.0(svelte@5.50.2): dependencies: - svelte: 5.50.0 + svelte: 5.50.2 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.0)(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.0)(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.0): + 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): 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.0)(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.0)(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.0) + 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) style-to-object: 1.0.14 - svelte: 5.50.0 + svelte: 5.50.2 transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.50.0: + svelte@5.50.2: dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -25005,12 +25005,12 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -25330,7 +25330,7 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(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-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(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: debug: 4.4.3 globrex: 0.1.2 @@ -25380,11 +25380,11 @@ snapshots: 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-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(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@24.10.13)(happy-dom@20.6.1)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25412,7 +25412,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti @@ -25428,7 +25428,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(happy-dom@20.5.0)(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): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(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: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25456,7 +25456,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.10.13 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti @@ -25472,7 +25472,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(happy-dom@20.5.0)(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): + 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: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -25500,7 +25500,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.2.3 - happy-dom: 20.5.0 + happy-dom: 20.6.1 jsdom: 26.1.0(canvas@2.11.2) transitivePeerDependencies: - jiti diff --git a/web/package.json b/web/package.json index db5a05617e..507b01f6bb 100644 --- a/web/package.json +++ b/web/package.json @@ -98,7 +98,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.50.0", + "svelte": "5.50.2", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", From 0767ae0c8a8ffb5a99eadb85f8a1f6ff4af0070b Mon Sep 17 00:00:00 2001 From: ewinnd <82260303+ewinnd@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:50:11 +0400 Subject: [PATCH 14/78] fix(docs): remove truenas link from synology community guide (#26277) * Update synology.md to remove Truenas link Removed link to Truenas github community repo. * remove blank line --------- Co-authored-by: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> --- docs/docs/install/synology.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md index 3e5b780db2..b86561dbbf 100644 --- a/docs/docs/install/synology.md +++ b/docs/docs/install/synology.md @@ -8,8 +8,6 @@ sidebar_position: 85 This is a community contribution and not officially supported by the Immich team, but included here for convenience. Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/). - -**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).** ::: Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager. From 455afbb11944275a9c1750020aa3a707f8c7e718 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Tue, 17 Feb 2026 06:51:15 -0500 Subject: [PATCH 15/78] ci: fix formatting task (#26274) --- .github/workflows/fix-format.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 11a9ef06e4..2d4cc1e5f9 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -39,7 +39,7 @@ jobs: cache-dependency-path: '**/pnpm-lock.yaml' - name: Fix formatting - run: pnpm --recursive install && pnpm run --recursive --parallel fix:format + run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix - name: Commit and push uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4 From 06d487782e902e896ac14271ae05785a4310694b Mon Sep 17 00:00:00 2001 From: Damien Nozay <205466+dnozay@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:55:34 +0100 Subject: [PATCH 16/78] fix(release): add docker-compose.rootless.yml to released assets (#26261) * fix(release): add docker-compose files to released assets Since there is a warning: "Make sure to use the docker-compose.yml of the current release" This should apply to other docker-compose files, so it would make sense to release them. It also makes it slightly easier to get the asset for rootless (e.g., PR 2750). * release docker-compose.rootless.yml --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30783f5e9b..3376e42d9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,7 @@ jobs: draft: true files: | docker/docker-compose.yml + docker/docker-compose.rootless.yml docker/example.env docker/hwaccel.ml.yml docker/hwaccel.transcoding.yml From 5c6433b4ca12d6c97f7addfd1034f76b0ea408e9 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:24:34 +0000 Subject: [PATCH 17/78] feat(mobile): inline asset details (#25952) The existing implementation for showing asset details uses a bottom sheet, and is not in sync with the preview or scroll intent. Other apps use inline details, which is much cleaner and feels better to use. --- mobile/lib/domain/models/events.model.dart | 5 +- .../lib/domain/services/timeline.service.dart | 4 +- mobile/lib/extensions/scroll_extensions.dart | 122 ++++ .../pages/drift_activities.page.dart | 18 +- .../presentation/pages/drift_memory.page.dart | 2 +- .../add_action_button.widget.dart | 2 +- .../edit_image_action_button.widget.dart | 2 +- .../like_activity_action_button.widget.dart | 2 +- .../widgets/album/album_selector.widget.dart | 2 +- .../activities_bottom_sheet.widget.dart | 85 --- .../asset_viewer/asset_details.widget.dart | 45 ++ .../appears_in_details.widget.dart | 78 +++ .../date_time_details.widget.dart | 142 ++++ .../asset_details/drag_handle.widget.dart | 21 + .../location_details.widget.dart} | 11 +- .../people_details.widget.dart} | 16 +- .../asset_details/rating_details.widget.dart | 52 ++ .../technical_details.widget.dart | 129 ++++ .../asset_viewer/asset_page.widget.dart | 454 +++++++++++++ .../widgets/asset_viewer/asset_preloader.dart | 46 ++ .../asset_viewer/asset_stack.widget.dart | 18 +- .../asset_viewer/asset_viewer.page.dart | 621 +++--------------- .../asset_viewer/asset_viewer.state.dart | 43 +- .../asset_viewer/bottom_bar.widget.dart | 63 +- .../asset_viewer/bottom_sheet.widget.dart | 409 ------------ .../asset_viewer/video_viewer.widget.dart | 4 +- .../video_viewer_controls.widget.dart | 6 +- .../viewer_bottom_app_bar.widget.dart | 32 + .../viewer_kebab_menu.widget.dart | 2 +- ...et.dart => viewer_top_app_bar.widget.dart} | 35 +- .../infrastructure/action.provider.dart | 2 +- ...sset.provider.dart => asset.provider.dart} | 12 + mobile/lib/routing/router.gr.dart | 29 +- mobile/lib/utils/action_button.utils.dart | 2 +- mobile/lib/widgets/map/asset_market_icon.dart | 107 +++ mobile/lib/widgets/map/map_thumbnail.dart | 36 +- .../map/positioned_asset_marker_icon.dart | 102 +-- mobile/lib/widgets/photo_view/photo_view.dart | 9 +- .../photo_view/photo_view_gallery.dart | 9 +- .../photo_view/src/core/photo_view_core.dart | 4 + .../src/core/photo_view_gesture_detector.dart | 5 +- .../photo_view/src/photo_view_wrappers.dart | 7 + 42 files changed, 1518 insertions(+), 1277 deletions(-) delete mode 100644 mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart rename mobile/lib/presentation/widgets/asset_viewer/{bottom_sheet/sheet_location_details.widget.dart => asset_details/location_details.widget.dart} (93%) rename mobile/lib/presentation/widgets/asset_viewer/{bottom_sheet/sheet_people_details.widget.dart => asset_details/people_details.widget.dart} (93%) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart delete mode 100644 mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart rename mobile/lib/presentation/widgets/asset_viewer/{top_app_bar.widget.dart => viewer_top_app_bar.widget.dart} (80%) rename mobile/lib/providers/infrastructure/asset_viewer/{current_asset.provider.dart => asset.provider.dart} (85%) create mode 100644 mobile/lib/widgets/map/asset_market_icon.dart diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index fc9cebc80f..9bbe00852e 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -16,9 +16,8 @@ class ScrollToDateEvent extends Event { } // Asset Viewer Events -class ViewerOpenBottomSheetEvent extends Event { - final bool activitiesMode; - const ViewerOpenBottomSheetEvent({this.activitiesMode = false}); +class ViewerShowDetailsEvent extends Event { + const ViewerShowDetailsEvent(); } class ViewerReloadAssetEvent extends Event { diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index bd36d0b569..39aeb867a3 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -183,8 +183,8 @@ class TimelineService { return _buffer.slice(start, start + count); } - // Pre-cache assets around the given index for asset viewer - Future preCacheAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); + // Preload assets around the given index for asset viewer + Future preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length)); diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart index 169032ff5d..5917e127bc 100644 --- a/mobile/lib/extensions/scroll_extensions.dart +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -32,3 +32,125 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics { damping: 80, ); } + +class SnapScrollPhysics extends ScrollPhysics { + static const _minFlingVelocity = 700.0; + static const minSnapDistance = 30.0; + + static final _spring = SpringDescription.withDampingRatio(mass: .5, stiffness: 300); + + const SnapScrollPhysics({super.parent}); + + @override + SnapScrollPhysics applyTo(ScrollPhysics? ancestor) { + return SnapScrollPhysics(parent: buildParent(ancestor)); + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + assert( + position is SnapScrollPosition, + 'SnapScrollPhysics can only be used with Scrollables that use a ' + 'controller whose createScrollPosition returns a SnapScrollPosition', + ); + + final snapOffset = (position as SnapScrollPosition).snapOffset; + if (snapOffset <= 0) { + return super.createBallisticSimulation(position, velocity); + } + + if (position.pixels >= snapOffset) { + final simulation = super.createBallisticSimulation(position, velocity); + if (simulation == null || simulation.x(double.infinity) >= snapOffset) { + return simulation; + } + } + + return ScrollSpringSimulation( + _spring, + position.pixels, + target(position, velocity, snapOffset), + velocity, + tolerance: toleranceFor(position), + ); + } + + static double target(ScrollMetrics position, double velocity, double snapOffset) { + if (velocity > _minFlingVelocity) return snapOffset; + if (velocity < -_minFlingVelocity) return position.pixels < snapOffset ? 0.0 : snapOffset; + return position.pixels < minSnapDistance ? 0.0 : snapOffset; + } +} + +class SnapScrollPosition extends ScrollPositionWithSingleContext { + double snapOffset; + + SnapScrollPosition({this.snapOffset = 0.0, required super.physics, required super.context, super.oldPosition}); +} + +class ProxyScrollController extends ScrollController { + final ScrollController scrollController; + + ProxyScrollController({required this.scrollController}); + + SnapScrollPosition get snapPosition => position as SnapScrollPosition; + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { + return ProxyScrollPosition( + scrollController: scrollController, + physics: physics, + context: context, + oldPosition: oldPosition, + ); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } +} + +class ProxyScrollPosition extends SnapScrollPosition { + final ScrollController scrollController; + + ProxyScrollPosition({ + required this.scrollController, + required super.physics, + required super.context, + super.oldPosition, + }); + + @override + double setPixels(double newPixels) { + final overscroll = super.setPixels(newPixels); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + return overscroll; + } + + @override + void forcePixels(double value) { + super.forcePixels(value); + if (scrollController.hasClients && scrollController.position.pixels != pixels) { + scrollController.position.forcePixels(pixels); + } + } + + @override + double get maxScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.maxScrollExtent + : super.maxScrollExtent; + + @override + double get minScrollExtent => scrollController.hasClients && scrollController.position.hasContentDimensions + ? scrollController.position.minScrollExtent + : super.minScrollExtent; + + @override + double get viewportDimension => scrollController.hasClients && scrollController.position.hasViewportDimension + ? scrollController.position.viewportDimension + : super.viewportDimension; +} diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index ac0cd7f309..fa5737443f 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -14,13 +14,15 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da @RoutePage() class DriftActivitiesPage extends HookConsumerWidget { final RemoteAlbum album; + final String? assetId; + final String? assetName; - const DriftActivitiesPage({super.key, required this.album}); + const DriftActivitiesPage({super.key, required this.album, this.assetId, this.assetName}); @override Widget build(BuildContext context, WidgetRef ref) { - final activityNotifier = ref.read(albumActivityProvider(album.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id)); + final activityNotifier = ref.read(albumActivityProvider(album.id, assetId).notifier); + final activities = ref.watch(albumActivityProvider(album.id, assetId)); final listViewScrollController = useScrollController(); void scrollToBottom() { @@ -36,7 +38,13 @@ class DriftActivitiesPage extends HookConsumerWidget { overrides: [currentRemoteAlbumScopedProvider.overrideWithValue(album)], child: Scaffold( appBar: AppBar( - title: Text(album.name), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(album.name), + if (assetName != null) Text(assetName!, style: context.textTheme.bodySmall), + ], + ), actions: [const LikeActivityActionButton(iconOnly: true)], actionsPadding: const EdgeInsets.only(right: 8), ), @@ -47,7 +55,7 @@ class DriftActivitiesPage extends HookConsumerWidget { activityWidgets.add( Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: CommentBubble(activity: activity), + child: CommentBubble(activity: activity, isAssetActivity: assetId != null), ), ); } diff --git a/mobile/lib/presentation/pages/drift_memory.page.dart b/mobile/lib/presentation/pages/drift_memory.page.dart index 9042f2f1f5..147165f2a3 100644 --- a/mobile/lib/presentation/pages/drift_memory.page.dart +++ b/mobile/lib/presentation/pages/drift_memory.page.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.wid import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_epilogue.dart'; import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 23cd19f363..4162f43a24 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 4c7b6ffbdc..440985a0bb 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; class EditImageActionButton extends ConsumerWidget { diff --git a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart index 8c326974a7..a44b0b5815 100644 --- a/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/like_activity_action_button.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 8f3cee9215..15749fb9af 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart deleted file mode 100644 index 3b46b69958..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/activities/comment_bubble.dart'; -import 'package:immich_mobile/presentation/widgets/album/drift_activity_text_field.dart'; -import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; - -class ActivitiesBottomSheet extends HookConsumerWidget { - final DraggableScrollableController controller; - final double initialChildSize; - final bool scrollToBottomInitially; - - const ActivitiesBottomSheet({ - required this.controller, - this.initialChildSize = 0.35, - this.scrollToBottomInitially = true, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final album = ref.watch(currentRemoteAlbumProvider)!; - final asset = ref.watch(currentAssetNotifier) as RemoteAsset?; - - final activityNotifier = ref.read(albumActivityProvider(album.id, asset?.id).notifier); - final activities = ref.watch(albumActivityProvider(album.id, asset?.id)); - - Future onAddComment(String comment) async { - await activityNotifier.addComment(comment); - } - - Widget buildActivitiesSliver() { - return activities.widgetWhen( - onLoading: () => const SliverToBoxAdapter(child: SizedBox.shrink()), - onData: (data) { - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - if (index == data.length) { - return const SizedBox.shrink(); - } - final activity = data[data.length - 1 - index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: CommentBubble(activity: activity, isAssetActivity: true), - ); - }, childCount: data.length + 1), - ); - }, - ); - } - - return BaseBottomSheet( - actions: [], - slivers: [buildActivitiesSliver()], - footer: Padding( - // TODO: avoid fixed padding, use context.padding.bottom - padding: const EdgeInsets.only(bottom: 32), - child: Column( - children: [ - const Divider(indent: 16, endIndent: 16), - DriftActivityTextField( - isEnabled: album.isActivityEnabled, - isBottomSheet: true, - // likeId: likedId, - onSubmit: onAddComment, - ), - ], - ), - ), - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart new file mode 100644 index 0000000000..949a6917e9 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; + +class AssetDetails extends ConsumerWidget { + final double minHeight; + + const AssetDetails({required this.minHeight, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) { + return const SizedBox.shrink(); + } + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + color: context.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DragHandle(), + const DateTimeDetails(), + const PeopleDetails(), + const LocationDetails(), + const TechnicalDetails(), + const RatingDetails(), + const AppearsInDetails(), + SizedBox(height: context.padding.bottom + 48), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart new file mode 100644 index 0000000000..a3d6bdb8ab --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class AppearsInDetails extends ConsumerWidget { + const AppearsInDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null || !asset.hasRemote) return const SizedBox.shrink(); + + String? remoteAssetId; + if (asset is RemoteAsset) { + remoteAssetId = asset.id; + } else if (asset is LocalAsset) { + remoteAssetId = asset.remoteAssetId; + } + + if (remoteAssetId == null) return const SizedBox.shrink(); + + final userId = ref.watch(currentUserProvider)?.id; + final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); + + return assetAlbums.when( + data: (albums) { + if (albums.isEmpty) return const SizedBox.shrink(); + + albums.sortBy((a) => a.name); + + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + spacing: 12, + children: [ + SheetTile( + title: 'appears_in'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + Padding( + padding: const EdgeInsets.only(left: 12), + child: Column( + spacing: 12, + children: albums.map((album) { + final isOwner = album.ownerId == userId; + return AlbumTile( + album: album, + isOwner: isOwner, + onAlbumSelected: (album) async { + ref.invalidate(assetViewerProvider); + unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); + }, + ); + }).toList(), + ), + ), + ], + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart new file mode 100644 index 0000000000..4872bf9e75 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/duration_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/utils/timezone.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +const _kSeparator = ' • '; + +class DateTimeDetails extends ConsumerWidget { + const DateTimeDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); + + return Column( + children: [ + SheetTile( + title: _getDateTime(context, asset, exifInfo), + titleStyle: context.textTheme.labelLarge, + trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, + onTap: asset.hasRemote && isOwner + ? () async => await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context) + : null, + ), + if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), + ], + ); + } + + static String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { + DateTime dateTime = asset.createdAt.toLocal(); + Duration timeZoneOffset = dateTime.timeZoneOffset; + + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, timeZoneOffset) = applyTimezoneOffset( + dateTime: exifInfo!.dateTimeOriginal!, + timeZone: exifInfo.timeZone, + ); + } + + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); + final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); + final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; + return '$date$_kSeparator$time $timezone'; + } +} + +class _SheetAssetDescription extends ConsumerStatefulWidget { + final ExifInfo exif; + final bool isEditable; + + const _SheetAssetDescription({required this.exif, this.isEditable = true}); + + @override + ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); +} + +class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { + late TextEditingController _controller; + final _descriptionFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.exif.description ?? ''); + } + + Future saveDescription(String? previousDescription) async { + final newDescription = _controller.text.trim(); + + if (newDescription == previousDescription) { + _descriptionFocus.unfocus(); + return; + } + + final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); + + if (!editAction.success) { + _controller.text = previousDescription ?? ''; + + ImmichToast.show( + context: context, + msg: 'exif_bottom_sheet_description_error'.t(context: context), + toastType: ToastType.error, + ); + } + + _descriptionFocus.unfocus(); + } + + @override + Widget build(BuildContext context) { + final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + final currentDescription = currentExifInfo?.description ?? ''; + final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( + context: context, + ); + if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { + _controller.text = currentDescription; + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: IgnorePointer( + ignoring: !widget.isEditable, + child: TextField( + controller: _controller, + keyboardType: TextInputType.multiline, + maxLines: null, + focusNode: _descriptionFocus, + decoration: InputDecoration( + hintText: hintText, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + ), + onTapOutside: (_) => saveDescription(currentExifInfo?.description), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart new file mode 100644 index 0000000000..8c24c5004c --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class DragHandle extends StatelessWidget { + const DragHandle({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(2)), + color: context.colorScheme.onSurfaceVariant, + ), + ), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart index ce561c4016..0665f4d46c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart @@ -8,18 +8,18 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class SheetLocationDetails extends ConsumerStatefulWidget { - const SheetLocationDetails({super.key}); +class LocationDetails extends ConsumerStatefulWidget { + const LocationDetails({super.key}); @override - ConsumerState createState() => _SheetLocationDetailsState(); + ConsumerState createState() => _LocationDetailsState(); } -class _SheetLocationDetailsState extends ConsumerState { +class _LocationDetailsState extends ConsumerState { MapLibreMapController? _mapController; String? _getLocationName(ExifInfo? exifInfo) { @@ -42,7 +42,6 @@ class _SheetLocationDetailsState extends ConsumerState { void _onExifChanged(AsyncValue? previous, AsyncValue current) { final currentExif = current.valueOrNull; - if (currentExif != null && currentExif.hasCoordinates) { _mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!))); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart similarity index 93% rename from mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 7eb9e578ff..5074c63c9c 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; @@ -15,14 +15,14 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/people.utils.dart'; -class SheetPeopleDetails extends ConsumerStatefulWidget { - const SheetPeopleDetails({super.key}); +class PeopleDetails extends ConsumerStatefulWidget { + const PeopleDetails({super.key}); @override - ConsumerState createState() => _SheetPeopleDetailsState(); + ConsumerState createState() => _PeopleDetailsState(); } -class _SheetPeopleDetailsState extends ConsumerState { +class _PeopleDetailsState extends ConsumerState { @override Widget build(BuildContext context) { final asset = ref.watch(currentAssetNotifier); @@ -65,7 +65,7 @@ class _SheetPeopleDetailsState extends ConsumerState { scrollDirection: Axis.horizontal, children: [ for (final person in people) - _PeopleAvatar( + _Avatar( person: person, assetFileCreatedAt: asset.createdAt, onTap: () { @@ -97,14 +97,14 @@ class _SheetPeopleDetailsState extends ConsumerState { } } -class _PeopleAvatar extends StatelessWidget { +class _Avatar extends StatelessWidget { final DriftPerson person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; final double imageSize = 96; - const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); + const _Avatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap}); @override Widget build(BuildContext context) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart new file mode 100644 index 0000000000..982ea67583 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/rating_details.widget.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; + +class RatingDetails extends ConsumerWidget { + const RatingDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isRatingEnabled = ref + .watch(userMetadataPreferencesProvider) + .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); + + if (!isRatingEnabled) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + + return Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + 'rating'.t(context: context), + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + RatingBar( + initialRating: exifInfo?.rating?.toDouble() ?? 0, + filledColor: context.themeData.colorScheme.primary, + unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), + itemSize: 40, + onRatingUpdate: (rating) async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); + }, + onClearRating: () async { + await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); + }, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart new file mode 100644 index 0000000000..d79362b559 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +const _kSeparator = ' • '; + +class TechnicalDetails extends ConsumerWidget { + const TechnicalDetails({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + if (asset == null) return const SizedBox.shrink(); + + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final cameraTitle = _getCameraInfoTitle(exifInfo); + final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; + + return Column( + children: [ + SheetTile( + title: 'details'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + _buildFileInfoTile(context, ref, asset, exifInfo), + if (cameraTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: cameraTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getCameraInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + if (lensTitle != null) ...[ + const SizedBox(height: 16), + SheetTile( + title: lensTitle, + titleStyle: context.textTheme.labelLarge, + leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), + subtitle: _getLensInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ), + ], + ], + ); + } + + Widget _buildFileInfoTile(BuildContext context, WidgetRef ref, BaseAsset asset, ExifInfo? exifInfo) { + final icon = Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 24, + color: context.textTheme.labelLarge?.color, + ); + final subtitle = _getFileInfo(asset, exifInfo); + final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary); + + if (asset is LocalAsset) { + final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + return FutureBuilder( + future: assetMediaRepository.getOriginalFilename(asset.id), + builder: (context, snapshot) { + return SheetTile( + title: snapshot.data ?? asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + }, + ); + } + + return SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge, + leading: icon, + subtitle: subtitle, + subtitleStyle: subtitleStyle, + ); + } + + static String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { + final height = asset.height; + final width = asset.width; + final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; + final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; + + return switch ((fileSize, resolution)) { + (null, null) => '', + (String fileSize, null) => fileSize, + (null, String resolution) => resolution, + (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', + }; + } + + static String? _getCameraInfoTitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + return switch ((exifInfo.make, exifInfo.model)) { + (null, null) => null, + (String make, null) => make, + (null, String model) => model, + (String make, String model) => '$make $model', + }; + } + + static String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; + final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } + + static String? _getLensInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) return null; + final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart new file mode 100644 index 0000000000..a8f5f9d14a --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/gestures.dart' show Drag, kTouchSlop; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/events.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +enum _DragIntent { none, scroll, dismiss } + +class AssetPage extends ConsumerStatefulWidget { + final int index; + final int heroOffset; + + const AssetPage({super.key, required this.index, required this.heroOffset}); + + @override + ConsumerState createState() => _AssetPageState(); +} + +class _AssetPageState extends ConsumerState { + PhotoViewControllerBase? _viewController; + StreamSubscription? _scaleBoundarySub; + StreamSubscription? _eventSubscription; + + AssetViewerStateNotifier get _viewer => ref.read(assetViewerProvider.notifier); + + late PhotoViewControllerValue _initialPhotoViewState; + + bool _blockGestures = false; + bool _showingDetails = false; + bool _isZoomed = false; + + final _scrollController = ScrollController(); + late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController); + + double _snapOffset = 0.0; + double _lastScrollOffset = 0.0; + + DragStartDetails? _dragStart; + _DragIntent _dragIntent = _DragIntent.none; + Drag? _drag; + bool _dragInProgress = false; + bool _shouldPopOnDrag = false; + + @override + void initState() { + super.initState(); + _proxyScrollController.addListener(_onScroll); + _eventSubscription = EventStream.shared.listen(_onEvent); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_proxyScrollController.hasClients) return; + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + if (_showingDetails && _snapOffset > 0) { + _proxyScrollController.jumpTo(_snapOffset); + } + }); + } + + @override + void dispose() { + _proxyScrollController.dispose(); + _scaleBoundarySub?.cancel(); + _eventSubscription?.cancel(); + super.dispose(); + } + + void _onEvent(Event event) { + switch (event) { + case ViewerShowDetailsEvent(): + _showDetails(); + default: + } + } + + void _showDetails() { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; + _lastScrollOffset = _proxyScrollController.offset; + _proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); + } + + bool _willClose(double scrollVelocity) { + if (!_proxyScrollController.hasClients || _snapOffset <= 0) return false; + + final position = _proxyScrollController.position; + return _proxyScrollController.position.pixels < _snapOffset && + SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance; + } + + void _onScroll() { + final offset = _proxyScrollController.offset; + if (offset > SnapScrollPhysics.minSnapDistance && offset > _lastScrollOffset) { + _viewer.setShowingDetails(true); + } else if (offset < SnapScrollPhysics.minSnapDistance - kTouchSlop) { + _viewer.setShowingDetails(false); + } + _lastScrollOffset = offset; + } + + void _beginDrag(DragStartDetails details) { + _dragStart = details; + _shouldPopOnDrag = false; + _lastScrollOffset = _proxyScrollController.hasClients ? _proxyScrollController.offset : 0.0; + + if (_viewController != null) { + _initialPhotoViewState = _viewController!.value; + } + + if (_showingDetails) { + _dragIntent = _DragIntent.scroll; + _startProxyDrag(); + } + } + + void _startProxyDrag() { + if (_proxyScrollController.hasClients && _dragStart != null) { + _drag = _proxyScrollController.position.drag(_dragStart!, () => _drag = null); + } + } + + void _updateDrag(DragUpdateDetails details) { + if (_blockGestures) return; + + _dragInProgress = true; + + if (_dragIntent == _DragIntent.none) { + _dragIntent = switch ((details.globalPosition - _dragStart!.globalPosition).dy) { + < -kTouchSlop => _DragIntent.scroll, + > kTouchSlop => _DragIntent.dismiss, + _ => _DragIntent.none, + }; + } + + switch (_dragIntent) { + case _DragIntent.none: + case _DragIntent.scroll: + if (_drag == null) _startProxyDrag(); + _drag?.update(details); + case _DragIntent.dismiss: + _handleDragDown(context, details.localPosition - _dragStart!.localPosition); + } + } + + void _endDrag(DragEndDetails details) { + _dragInProgress = false; + + if (_blockGestures) { + _blockGestures = false; + return; + } + + final intent = _dragIntent; + _dragIntent = _DragIntent.none; + _dragStart = null; + + switch (intent) { + case _DragIntent.none: + case _DragIntent.scroll: + final scrollVelocity = -(details.primaryVelocity ?? 0.0); + if (_willClose(scrollVelocity)) { + _viewer.setShowingDetails(false); + } + _drag?.end(details); + _drag = null; + case _DragIntent.dismiss: + if (_shouldPopOnDrag) { + context.maybePop(); + return; + } + _viewController?.animateMultiple( + position: _initialPhotoViewState.position, + scale: _viewController?.initialScale ?? _initialPhotoViewState.scale, + rotation: _initialPhotoViewState.rotation, + ); + _viewer.setOpacity(1.0); + } + } + + void _onDragStart( + BuildContext context, + DragStartDetails details, + PhotoViewControllerBase controller, + PhotoViewScaleStateController scaleStateController, + ) { + _viewController = controller; + if (!_showingDetails && _isZoomed) { + _blockGestures = true; + return; + } + _beginDrag(details); + } + + void _onDragUpdate(BuildContext context, DragUpdateDetails details, PhotoViewControllerValue _) => + _updateDrag(details); + + void _onDragEnd(BuildContext context, DragEndDetails details, PhotoViewControllerValue _) => _endDrag(details); + + void _onDragCancel() => _endDrag(DragEndDetails(primaryVelocity: 0.0)); + + void _handleDragDown(BuildContext context, Offset delta) { + const dragRatio = 0.2; + const popThreshold = 75.0; + + _shouldPopOnDrag = delta.dy > popThreshold; + + final distance = delta.dy.abs(); + + final maxScaleDistance = context.height * 0.5; + final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); + final initialScale = _viewController?.initialScale ?? _initialPhotoViewState.scale; + final updatedScale = initialScale != null ? initialScale * (1.0 - scaleReduction) : null; + + final opacity = 1.0 - (scaleReduction / dragRatio); + + _viewController?.updateMultiple(position: _initialPhotoViewState.position + delta, scale: updatedScale); + _viewer.setOpacity(opacity); + } + + void _onTapUp(BuildContext context, TapUpDetails details, PhotoViewControllerValue controllerValue) { + if (!_showingDetails && !_dragInProgress) _viewer.toggleControls(); + } + + void _onLongPress(BuildContext context, LongPressStartDetails details, PhotoViewControllerValue controllerValue) => + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; + + void _onScaleStateChanged(PhotoViewScaleState scaleState) { + _isZoomed = switch (scaleState) { + PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true, + _ => false, + }; + _viewer.setZoomed(_isZoomed); + + if (scaleState != PhotoViewScaleState.initial) { + if (!_dragInProgress) _viewer.setControls(false); + + ref.read(videoPlayerControlsProvider.notifier).pause(); + return; + } + + if (!_showingDetails) _viewer.setControls(true); + } + + void _listenForScaleBoundaries(PhotoViewControllerBase? controller) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (controller == null || controller.scaleBoundaries != null) return; + _scaleBoundarySub = controller.outputStateStream.listen((_) { + if (controller.scaleBoundaries != null) { + _scaleBoundarySub?.cancel(); + _scaleBoundarySub = null; + if (mounted) setState(() {}); + } + }); + } + + double _getImageHeight(double maxWidth, double maxHeight, BaseAsset? asset) { + final sb = _viewController?.scaleBoundaries; + if (sb != null) return sb.childSize.height * sb.initialScale; + + if (asset == null || asset.width == null || asset.height == null) return maxHeight; + + final r = asset.width! / asset.height!; + return math.min(maxWidth / r, maxHeight); + } + + void _onPageBuild(PhotoViewControllerBase controller) { + _viewController = controller; + _listenForScaleBoundaries(controller); + } + + Widget _buildPhotoView( + BaseAsset displayAsset, + BaseAsset asset, { + required bool isCurrentPage, + required bool showingDetails, + required bool isPlayingMotionVideo, + required BoxDecoration backgroundDecoration, + }) { + final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null; + + if (displayAsset.isImage && !isPlayingMotionVideo) { + final size = context.sizeData; + return PhotoView( + key: ValueKey(displayAsset.heroTag), + index: widget.index, + imageProvider: getFullImageProvider(displayAsset, size: size), + heroAttributes: heroAttributes, + loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()), + backgroundDecoration: backgroundDecoration, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + tightMode: true, + enablePanAlways: true, + disableScaleGestures: showingDetails, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null, + errorBuilder: (_, __, ___) => SizedBox( + width: size.width, + height: size.height, + child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain), + ), + ); + } + + return PhotoView.customChild( + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + onDragCancel: _onDragCancel, + onTapUp: _onTapUp, + heroAttributes: heroAttributes, + filterQuality: FilterQuality.high, + maxScale: 1.0, + basePosition: Alignment.center, + disableScaleGestures: true, + scaleStateChangedCallback: _onScaleStateChanged, + onPageBuild: _onPageBuild, + enablePanAlways: true, + backgroundDecoration: backgroundDecoration, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewer( + key: ValueKey(displayAsset.heroTag), + asset: displayAsset, + image: Image( + key: ValueKey(displayAsset), + image: getFullImageProvider(displayAsset, size: context.sizeData), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); + final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); + + final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index); + if (asset == null) { + return const Center(child: ImmichLoadingIndicator()); + } + + BaseAsset displayAsset = asset; + final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + displayAsset = stackChildren.elementAt(stackIndex); + } + + final viewportWidth = MediaQuery.widthOf(context); + 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); + + if (_proxyScrollController.hasClients) { + _proxyScrollController.snapPosition.snapOffset = _snapOffset; + } + + return ProviderScope( + overrides: [ + currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)), + currentAssetExifProvider.overrideWith((ref) { + final a = ref.watch(currentAssetNotifier); + if (a == null) return Future.value(null); + return ref.watch(assetServiceProvider).getExif(a); + }), + ], + child: Stack( + children: [ + Offstage( + child: SingleChildScrollView( + controller: _proxyScrollController, + physics: const SnapScrollPhysics(), + child: const SizedBox.shrink(), + ), + ), + SingleChildScrollView( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + child: Stack( + children: [ + SizedBox( + width: viewportWidth, + height: viewportHeight, + child: _buildPhotoView( + displayAsset, + asset, + isCurrentPage: currentHeroTag == asset.heroTag, + showingDetails: _showingDetails, + isPlayingMotionVideo: isPlayingMotionVideo, + backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent), + ), + ), + IgnorePointer( + ignoring: !_showingDetails, + child: Column( + children: [ + SizedBox(height: overflowBoxHeight), + GestureDetector( + onVerticalDragStart: _beginDrag, + onVerticalDragUpdate: _updateDrag, + onVerticalDragEnd: _endDrag, + onVerticalDragCancel: _onDragCancel, + child: AnimatedOpacity( + opacity: _showingDetails ? 1.0 : 0.0, + duration: Durations.short2, + child: AssetDetails(minHeight: _snapOffset + viewportHeight - overflowBoxHeight), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart new file mode 100644 index 0000000000..ca7498a37f --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; + +class AssetPreloader { + static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); + + final TimelineService timelineService; + final bool Function() mounted; + + Timer? _timer; + ImageStream? _prevStream; + ImageStream? _nextStream; + + AssetPreloader({required this.timelineService, required this.mounted}); + + void preload(int index, Size size) { + unawaited(timelineService.preloadAssets(index)); + _timer?.cancel(); + _timer = Timer(Durations.medium4, () async { + if (!mounted()) return; + final (prev, next) = await ( + timelineService.getAssetAsync(index - 1), + timelineService.getAssetAsync(index + 1), + ).wait; + if (!mounted()) return; + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + _prevStream = prev != null ? _resolveImage(prev, size) : null; + _nextStream = next != null ? _resolveImage(next, size) : null; + }); + } + + ImageStream _resolveImage(BaseAsset asset, Size size) { + return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener); + } + + void dispose() { + _timer?.cancel(); + _prevStream?.removeListener(_dummyListener); + _nextStream?.removeListener(_dummyListener); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 0978b3c9af..2835342b85 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; class AssetStackRow extends ConsumerWidget { const AssetStackRow({super.key}); @@ -21,17 +21,11 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0; - - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: _StackList(stack: stackChildren), - ), - ); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { + return const SizedBox.shrink(); + } + return _StackList(stack: stackChildren); } } 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 ed2ab9d15d..13311fc4b2 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -14,27 +14,19 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/activities_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; -import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; @RoutePage() class AssetViewerPage extends StatelessWidget { @@ -79,10 +71,6 @@ class AssetViewer extends ConsumerStatefulWidget { _setAsset(ref, asset); } - void changeAsset(WidgetRef ref, BaseAsset asset) { - _setAsset(ref, asset); - } - static void _setAsset(WidgetRef ref, BaseAsset asset) { // Always holds the current asset from the timeline ref.read(assetViewerProvider.notifier).setAsset(asset); @@ -94,45 +82,20 @@ class AssetViewer extends ConsumerStatefulWidget { ref.read(videoPlayerControlsProvider.notifier).pause(); } // Hide controls by default for videos - if (asset.isVideo) { - ref.read(assetViewerProvider.notifier).setControls(false); - } + if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false); } } -const double _kBottomSheetMinimumExtent = 0.4; -const double _kBottomSheetSnapExtent = 0.67; - class _AssetViewerState extends ConsumerState { - static final _dummyListener = ImageStreamListener((image, _) => image.dispose()); late PageController pageController; - late DraggableScrollableController bottomSheetController; - PersistentBottomSheetController? sheetCloseController; - // PhotoViewGallery takes care of disposing it's controllers - PhotoViewControllerBase? viewController; - StreamSubscription? reloadSubscription; + + StreamSubscription? _reloadSubscription; late final int heroOffset; - late PhotoViewControllerValue initialPhotoViewState; - bool? hasDraggedDown; - bool isSnapping = false; - bool blockGestures = false; - bool dragInProgress = false; - bool shouldPopOnDrag = false; - bool assetReloadRequested = false; - double previousExtent = _kBottomSheetMinimumExtent; - Offset dragDownPosition = Offset.zero; - int totalAssets = 0; - int stackIndex = 0; - BuildContext? scaffoldContext; - Map videoPlayerKeys = {}; - - // Delayed operations that should be cancelled on disposal - final List _delayedOperations = []; - - ImageStream? _prevPreCacheStream; - ImageStream? _nextPreCacheStream; + bool _assetReloadRequested = false; + int _totalAssets = 0; + late final AssetPreloader _preloader; KeepAliveLink? _stackChildrenKeepAlive; @override @@ -140,94 +103,38 @@ class _AssetViewerState extends ConsumerState { super.initState(); assert(ref.read(currentAssetNotifier) != null, "Current asset should not be null when opening the AssetViewer"); pageController = PageController(initialPage: widget.initialIndex); - totalAssets = ref.read(timelineServiceProvider).totalAssets; - bottomSheetController = DraggableScrollableController(); + final timelineService = ref.read(timelineServiceProvider); + _totalAssets = timelineService.totalAssets; + _preloader = AssetPreloader(timelineService: timelineService, mounted: () => mounted); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); - reloadSubscription = EventStream.shared.listen(_onEvent); + _reloadSubscription = EventStream.shared.listen(_onEvent); heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; final asset = ref.read(currentAssetNotifier); - if (asset != null) { - _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); - } - if (ref.read(assetViewerProvider).showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); } @override void dispose() { pageController.dispose(); - bottomSheetController.dispose(); - _cancelTimers(); - reloadSubscription?.cancel(); - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + _preloader.dispose(); + _reloadSubscription?.cancel(); _stackChildrenKeepAlive?.close(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } - bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); - - Color get backgroundColor { - final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); - return Colors.black.withAlpha(opacity); - } - - void _cancelTimers() { - for (final timer in _delayedOperations) { - timer.cancel(); - } - _delayedOperations.clear(); - } - - double _getVerticalOffsetForBottomSheet(double extent) => - (context.height * extent) - (context.height * _kBottomSheetMinimumExtent); - - ImageStream _precacheImage(BaseAsset asset) { - final provider = getFullImageProvider(asset, size: context.sizeData); - return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener); - } - - void _precacheAssets(int index) { - final timelineService = ref.read(timelineServiceProvider); - unawaited(timelineService.preCacheAssets(index)); - _cancelTimers(); - // This will trigger the pre-caching of adjacent assets ensuring - // that they are ready when the user navigates to them. - final timer = Timer(Durations.medium4, () async { - // Check if widget is still mounted before proceeding - if (!mounted) return; - - final (prevAsset, nextAsset) = await ( - timelineService.getAssetAsync(index - 1), - timelineService.getAssetAsync(index + 1), - ).wait; - if (!mounted) return; - _prevPreCacheStream?.removeListener(_dummyListener); - _nextPreCacheStream?.removeListener(_dummyListener); - _prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null; - _nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null; - }); - _delayedOperations.add(timer); - } - - void _onAssetInit(Duration _) { - _precacheAssets(widget.initialIndex); + void _onAssetInit(Duration timeStamp) { + _preloader.preload(widget.initialIndex, context.sizeData); _handleCasting(); } void _onAssetChanged(int index) async { final timelineService = ref.read(timelineServiceProvider); final asset = await timelineService.getAssetAsync(index); - if (asset == null) { - return; - } + if (asset == null) return; - widget.changeAsset(ref, asset); - _precacheAssets(index); + AssetViewer._setAsset(ref, asset); + _preloader.preload(index, context.sizeData); _handleCasting(); _stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); @@ -238,223 +145,40 @@ class _AssetViewerState extends ConsumerState { final asset = ref.read(currentAssetNotifier); if (asset == null) return; - // hide any casting snackbars if they exist - context.scaffoldMessenger.hideCurrentSnackBar(); - - // send image to casting if the server has it if (asset is RemoteAsset) { + context.scaffoldMessenger.hideCurrentSnackBar(); ref.read(castProvider.notifier).loadMedia(asset, false); - } else { - // casting cannot show local assets - context.scaffoldMessenger.clearSnackBars(); - - if (ref.read(castProvider).isCasting) { - ref.read(castProvider.notifier).stop(); - context.scaffoldMessenger.showSnackBar( - SnackBar( - duration: const Duration(seconds: 2), - content: Text( - "local_asset_cast_failed".tr(), - style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), - ), - ), - ); - } - } - } - - void _onPageBuild(PhotoViewControllerBase controller) { - viewController ??= controller; - if (showingBottomSheet && bottomSheetController.isAttached) { - final verticalOffset = - (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); - controller.position = Offset(0, -verticalOffset); - // Apply the zoom effect when the bottom sheet is showing - controller.scale = (controller.scale ?? 1.0) + 0.01; - } - } - - void _onPageChanged(int index, PhotoViewControllerBase? controller) { - _onAssetChanged(index); - viewController = controller; - } - - void _onDragStart( - _, - DragStartDetails details, - PhotoViewControllerBase controller, - PhotoViewScaleStateController scaleStateController, - ) { - viewController = controller; - dragDownPosition = details.localPosition; - initialPhotoViewState = controller.value; - final isZoomed = - scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || - scaleStateController.scaleState == PhotoViewScaleState.covering; - if (!showingBottomSheet && isZoomed) { - blockGestures = true; - } - } - - void _onDragEnd(BuildContext ctx, _, __) { - dragInProgress = false; - - if (shouldPopOnDrag) { - // Dismiss immediately without state updates to avoid rebuilds - ctx.maybePop(); return; } - // Do not reset the state if the bottom sheet is showing - if (showingBottomSheet) { - _snapBottomSheet(); - return; - } - - // If the gestures are blocked, do not reset the state - if (blockGestures) { - blockGestures = false; - return; - } - - shouldPopOnDrag = false; - hasDraggedDown = null; - viewController?.animateMultiple( - position: initialPhotoViewState.position, - scale: viewController?.initialScale ?? initialPhotoViewState.scale, - rotation: initialPhotoViewState.rotation, + context.scaffoldMessenger.clearSnackBars(); + ref.read(castProvider.notifier).stop(); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "local_asset_cast_failed".tr(), + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + ), ); - ref.read(assetViewerProvider.notifier).setOpacity(255); - } - - void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { - if (blockGestures) { - return; - } - - dragInProgress = true; - final delta = details.localPosition - dragDownPosition; - hasDraggedDown ??= delta.dy > 0; - if (!hasDraggedDown! || showingBottomSheet) { - _handleDragUp(ctx, delta); - return; - } - - _handleDragDown(ctx, delta); - } - - void _handleDragUp(BuildContext ctx, Offset delta) { - const double openThreshold = 50; - - final position = initialPhotoViewState.position + Offset(0, delta.dy); - final distanceToOrigin = position.distance; - - viewController?.updateMultiple(position: position); - // Moves the bottom sheet when the asset is being dragged up - if (showingBottomSheet && bottomSheetController.isAttached) { - final centre = (ctx.height * _kBottomSheetMinimumExtent); - bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); - } - - if (distanceToOrigin > openThreshold && !showingBottomSheet && !ref.read(readonlyModeProvider)) { - _openBottomSheet(ctx); - } - } - - void _handleDragDown(BuildContext ctx, Offset delta) { - const double dragRatio = 0.2; - const double popThreshold = 75; - - final distance = delta.distance; - shouldPopOnDrag = delta.dy > 0 && distance > popThreshold; - - final maxScaleDistance = ctx.height * 0.5; - final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); - double? updatedScale; - double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale; - if (initialScale != null) { - updatedScale = initialScale * (1.0 - scaleReduction); - } - - final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round(); - - viewController?.updateMultiple(position: initialPhotoViewState.position + delta, scale: updatedScale); - ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity); - } - - void _onTapDown(_, __, ___) { - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).toggleControls(); - } - } - - bool _onNotification(Notification delta) { - if (delta is DraggableScrollableNotification) { - _handleDraggableNotification(delta); - } - - // Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after - // the isSnapping guard is to prevent the notification from recursively handling the - // notification, eventually resulting in a heap overflow - if (!isSnapping && delta is ScrollEndNotification) { - _snapBottomSheet(); - } - return false; - } - - void _handleDraggableNotification(DraggableScrollableNotification delta) { - final currentExtent = delta.extent; - final isDraggingDown = currentExtent < previousExtent; - previousExtent = currentExtent; - // Closes the bottom sheet if the user is dragging down - if (isDraggingDown && delta.extent < 0.67) { - if (dragInProgress) { - blockGestures = true; - } - // Jump to a lower position before starting close animation to prevent glitch - if (bottomSheetController.isAttached) { - bottomSheetController.jumpTo(0.67); - } - sheetCloseController?.close(); - } - - // If the asset is being dragged down, we do not want to update the asset position again - if (dragInProgress) { - return; - } - - final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent); - // Moves the asset when the bottom sheet is being dragged - if (verticalOffset > 0) { - viewController?.position = Offset(0, -verticalOffset); - } } void _onEvent(Event event) { - if (event is TimelineReloadEvent) { - _onTimelineReloadEvent(); - return; - } - - if (event is ViewerReloadAssetEvent) { - assetReloadRequested = true; - return; - } - - if (event is ViewerOpenBottomSheetEvent) { - final extent = _kBottomSheetMinimumExtent + 0.3; - _openBottomSheet(scaffoldContext!, extent: extent, activitiesMode: event.activitiesMode); - final offset = _getVerticalOffsetForBottomSheet(extent); - viewController?.position = Offset(0, -offset); - return; + switch (event) { + case TimelineReloadEvent(): + _onTimelineReloadEvent(); + case ViewerReloadAssetEvent(): + _assetReloadRequested = true; + default: } } void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); - totalAssets = timelineService.totalAssets; + _totalAssets = timelineService.totalAssets; - if (totalAssets == 0) { + if (_totalAssets == 0) { context.maybePop(); return; } @@ -469,229 +193,58 @@ class _AssetViewerState extends ConsumerState { } } - if (index >= totalAssets) { - index = totalAssets - 1; + if (index >= _totalAssets) { + index = _totalAssets - 1; pageController.jumpToPage(index); } - if (assetReloadRequested) { - assetReloadRequested = false; + if (_assetReloadRequested) { + _assetReloadRequested = false; _onAssetReloadEvent(index); } } void _onAssetReloadEvent(int index) async { final timelineService = ref.read(timelineServiceProvider); - final newAsset = await timelineService.getAssetAsync(index); - if (newAsset == null) { - return; - } + final newAsset = await timelineService.getAssetAsync(index); + if (newAsset == null) return; final currentAsset = ref.read(currentAssetNotifier); - // Do not reload / close the bottom sheet if the asset has not changed - if (newAsset.heroTag == currentAsset?.heroTag) { - return; - } - setState(() { - _onAssetChanged(pageController.page!.round()); - sheetCloseController?.close(); - }); - } + // Do not reload if the asset has not changed + if (newAsset.heroTag == currentAsset?.heroTag) return; - void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { - ref.read(assetViewerProvider.notifier).setBottomSheet(true); - previousExtent = _kBottomSheetMinimumExtent; - sheetCloseController = showBottomSheet( - context: ctx, - sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2), - constraints: const BoxConstraints(maxWidth: double.infinity), - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))), - backgroundColor: ctx.colorScheme.surfaceContainerLowest, - builder: (_) { - return NotificationListener( - onNotification: _onNotification, - child: activitiesMode - ? ActivitiesBottomSheet(controller: bottomSheetController, initialChildSize: extent) - : AssetDetailBottomSheet(controller: bottomSheetController, initialChildSize: extent), - ); - }, - ); - sheetCloseController?.closed.then((_) => _handleSheetClose()); - } - - void _handleSheetClose() { - viewController?.animateMultiple(position: Offset.zero); - viewController?.updateMultiple(scale: viewController?.initialScale); - ref.read(assetViewerProvider.notifier).setBottomSheet(false); - sheetCloseController = null; - shouldPopOnDrag = false; - hasDraggedDown = null; - } - - void _snapBottomSheet() { - if (!bottomSheetController.isAttached || - bottomSheetController.size > _kBottomSheetSnapExtent || - bottomSheetController.size < 0.4) { - return; - } - isSnapping = true; - bottomSheetController.animateTo(_kBottomSheetSnapExtent, duration: Durations.short3, curve: Curves.easeOut); - } - - Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) { - return const Center(child: ImmichLoadingIndicator()); - } - - void _onScaleStateChanged(PhotoViewScaleState scaleState) { - if (scaleState != PhotoViewScaleState.initial) { - if (!dragInProgress) { - ref.read(assetViewerProvider.notifier).setControls(false); - } - ref.read(videoPlayerControlsProvider.notifier).pause(); - return; - } - - if (!showingBottomSheet) { - ref.read(assetViewerProvider.notifier).setControls(true); - } - } - - void _onLongPress(_, __, ___) { - ref.read(isPlayingMotionVideoProvider.notifier).playing = true; - } - - PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { - scaffoldContext ??= ctx; - final timelineService = ref.read(timelineServiceProvider); - final asset = timelineService.getAssetSafe(index); - - // If asset is not available in buffer, return a placeholder - if (asset == null) { - return PhotoViewGalleryPageOptions.customChild( - heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'), - child: Container( - width: ctx.width, - height: ctx.height, - color: backgroundColor, - child: const Center(child: CircularProgressIndicator()), - ), - ); - } - - BaseAsset displayAsset = asset; - final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; - if (stackChildren != null && stackChildren.isNotEmpty) { - displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex); - } - - final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); - if (displayAsset.isImage && !isPlayingMotionVideo) { - return _imageBuilder(ctx, displayAsset); - } - - return _videoBuilder(ctx, displayAsset); - } - - PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { - final size = ctx.sizeData; - return PhotoViewGalleryPageOptions( - key: ValueKey(asset.heroTag), - imageProvider: getFullImageProvider(asset, size: size), - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - tightMode: true, - disableScaleGestures: showingBottomSheet, - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, __, ___) => Container( - width: size.width, - height: size.height, - color: backgroundColor, - child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), - ), - ); - } - - GlobalKey _getVideoPlayerKey(String id) { - videoPlayerKeys.putIfAbsent(id, () => GlobalKey()); - return videoPlayerKeys[id]!; - } - - PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: _onDragStart, - onDragUpdate: _onDragUpdate, - onDragEnd: _onDragEnd, - onTapDown: _onTapDown, - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - maxScale: 1.0, - basePosition: Alignment.center, - disableScaleGestures: true, - child: SizedBox( - width: ctx.width, - height: ctx.height, - child: NativeVideoViewer( - key: _getVideoPlayerKey(asset.heroTag), - asset: asset, - image: Image( - key: ValueKey(asset), - image: getFullImageProvider(asset, size: ctx.sizeData), - fit: BoxFit.contain, - height: ctx.height, - width: ctx.width, - alignment: Alignment.center, - ), - ), - ), - ); - } - - void _onPop(bool didPop, T? result) { - ref.read(currentAssetNotifier.notifier).dispose(); + _onAssetChanged(index); } @override Widget build(BuildContext context) { - // Rebuild the widget when the asset viewer state changes - // Using multiple selectors to avoid unnecessary rebuilds for other state changes - ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); - ref.watch(assetViewerProvider.select((s) => s.stackIndex)); - ref.watch(isPlayingMotionVideoProvider); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + final isZoomed = ref.watch(assetViewerProvider.select((s) => s.isZoomed)); + final backgroundColor = showingDetails + ? context.colorScheme.surface + : Colors.black.withValues(alpha: ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity))); // Listen for casting changes and send initial asset to the cast provider - ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) async { + ref.listen(castProvider.select((value) => value.isCasting), (_, isCasting) { if (!isCasting) return; - - final asset = ref.read(currentAssetNotifier); - if (asset == null) return; - WidgetsBinding.instance.addPostFrameCallback((_) { _handleCasting(); }); }); - // Listen for control visibility changes and change system UI mode accordingly - ref.listen(assetViewerProvider.select((value) => value.showingControls), (_, showingControls) async { - if (showingControls) { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } else { - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky)); - } + ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) { + final (controls, details) = state; + final mode = !controls || (CurrentPlatform.isIOS && details) + ? SystemUiMode.immersiveSticky + : SystemUiMode.edgeToEdge; + unawaited(SystemChrome.setEnabledSystemUIMode(mode)); }); - // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. - // Issue: https://github.com/flutter/flutter/issues/109037 - // TODO: Add a custom scrum builder once the fix lands on stable return PopScope( - onPopInvokedWithResult: _onPop, + onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(), child: Scaffold( backgroundColor: backgroundColor, appBar: const ViewerTopAppBar(), @@ -705,33 +258,29 @@ class _AssetViewerState extends ConsumerState { child: const DownloadStatusFloatingButton(), ), ), + bottomNavigationBar: const ViewerBottomAppBar(), body: Stack( children: [ - PhotoViewGallery.builder( - gaplessPlayback: true, - loadingBuilder: _placeholderBuilder, - pageController: pageController, - scrollPhysics: CurrentPlatform.isIOS - ? const FastScrollPhysics() // Use bouncing physics for iOS - : const FastClampingScrollPhysics(), // Use heavy physics for Android - itemCount: totalAssets, - onPageChanged: _onPageChanged, - onPageBuild: _onPageBuild, - scaleStateChangedCallback: _onScaleStateChanged, - builder: _assetBuilder, - backgroundDecoration: BoxDecoration(color: backgroundColor), - enablePanAlways: true, + PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: pageController, + physics: isZoomed + ? const NeverScrollableScrollPhysics() + : CurrentPlatform.isIOS + ? const FastScrollPhysics() + : const FastClampingScrollPhysics(), + itemCount: _totalAssets, + onPageChanged: (index) => _onAssetChanged(index), + itemBuilder: (context, index) => AssetPage(index: index, heroOffset: heroOffset), + ), ), - if (!showingBottomSheet) - const Positioned( - bottom: 0, - left: 0, - right: 0, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [AssetStackRow(), ViewerBottomBar()], + if (!CurrentPlatform.isIOS) + IgnorePointer( + child: AnimatedContainer( + duration: Durations.short2, + color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0), + height: context.padding.top, ), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 36e5bf67d9..dc510d6017 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -3,31 +3,35 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:riverpod_annotation/riverpod_annotation.dart'; class AssetViewerState { - final int backgroundOpacity; - final bool showingBottomSheet; + final double backgroundOpacity; + final bool showingDetails; final bool showingControls; + final bool isZoomed; final BaseAsset? currentAsset; final int stackIndex; const AssetViewerState({ - this.backgroundOpacity = 255, - this.showingBottomSheet = false, + this.backgroundOpacity = 1.0, + this.showingDetails = false, this.showingControls = true, + this.isZoomed = false, this.currentAsset, this.stackIndex = 0, }); AssetViewerState copyWith({ - int? backgroundOpacity, - bool? showingBottomSheet, + double? backgroundOpacity, + bool? showingDetails, bool? showingControls, + bool? isZoomed, BaseAsset? currentAsset, int? stackIndex, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, - showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, + showingDetails: showingDetails ?? this.showingDetails, showingControls: showingControls ?? this.showingControls, + isZoomed: isZoomed ?? this.isZoomed, currentAsset: currentAsset ?? this.currentAsset, stackIndex: stackIndex ?? this.stackIndex, ); @@ -35,7 +39,7 @@ class AssetViewerState { @override String toString() { - return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)'; + return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)'; } @override @@ -44,8 +48,9 @@ class AssetViewerState { if (other.runtimeType != runtimeType) return false; return other is AssetViewerState && other.backgroundOpacity == backgroundOpacity && - other.showingBottomSheet == showingBottomSheet && + other.showingDetails == showingDetails && other.showingControls == showingControls && + other.isZoomed == isZoomed && other.currentAsset == currentAsset && other.stackIndex == stackIndex; } @@ -53,8 +58,9 @@ class AssetViewerState { @override int get hashCode => backgroundOpacity.hashCode ^ - showingBottomSheet.hashCode ^ + showingDetails.hashCode ^ showingControls.hashCode ^ + isZoomed.hashCode ^ currentAsset.hashCode ^ stackIndex.hashCode; } @@ -76,18 +82,18 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(currentAsset: asset, stackIndex: 0); } - void setOpacity(int opacity) { + void setOpacity(double opacity) { if (opacity == state.backgroundOpacity) { return; } - state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls); + state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity >= 1.0 ? true : state.showingControls); } - void setBottomSheet(bool showing) { - if (showing == state.showingBottomSheet) { + void setShowingDetails(bool showing) { + if (showing == state.showingDetails) { return; } - state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls); + state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); if (showing) { ref.read(videoPlayerControlsProvider.notifier).pause(); } @@ -104,6 +110,13 @@ class AssetViewerStateNotifier extends Notifier { state = state.copyWith(showingControls: !state.showingControls); } + void setZoomed(bool isZoomed) { + if (isZoomed == state.isZoomed) { + return; + } + state = state.copyWith(isZoomed: isZoomed); + } + void setStackIndex(int index) { if (index == state.stackIndex) { return; diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 537f2fc31d..93006ab978 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -29,15 +29,9 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; - final isSheetOpen = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); - final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - if (!showControls) { - opacity = 0; - } - final originalTheme = context.themeData; final actions = [ @@ -56,37 +50,30 @@ class ViewerBottomBar extends ConsumerWidget { ], ]; - return IgnorePointer( - ignoring: opacity < 255, - child: AnimatedOpacity( - opacity: opacity / 255, - duration: Durations.short2, - child: AnimatedSwitcher( - duration: Durations.short4, - child: isSheetOpen - ? const SizedBox.shrink() - : Theme( - data: context.themeData.copyWith( - iconTheme: const IconThemeData(size: 22, color: Colors.white), - textTheme: context.themeData.textTheme.copyWith( - labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), - ), - ), - child: Container( - color: Colors.black.withAlpha(125), - padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (asset.isVideo) const VideoControls(), - if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), - ], - ), - ), + return AnimatedSwitcher( + duration: Durations.short4, + child: showingDetails + ? const SizedBox.shrink() + : Theme( + data: context.themeData.copyWith( + iconTheme: const IconThemeData(size: 22, color: Colors.white), + textTheme: context.themeData.textTheme.copyWith( + labelLarge: context.themeData.textTheme.labelLarge?.copyWith(color: Colors.white), ), - ), - ), + ), + child: Container( + color: Colors.black.withAlpha(125), + padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (asset.isVideo) const VideoControls(), + if (!isReadonlyModeEnabled) + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ], + ), + ), + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart deleted file mode 100644 index 2e10e6856b..0000000000 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/enums.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/duration_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart'; -import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart'; -import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/repositories/asset_media.repository.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/bytes_units.dart'; -import 'package:immich_mobile/utils/timezone.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; - -const _kSeparator = ' • '; - -class AssetDetailBottomSheet extends ConsumerWidget { - final DraggableScrollableController? controller; - final double initialChildSize; - - const AssetDetailBottomSheet({this.controller, this.initialChildSize = 0.35, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - return BaseBottomSheet( - actions: [], - slivers: const [_AssetDetailBottomSheet()], - controller: controller, - initialChildSize: initialChildSize, - minChildSize: 0.1, - maxChildSize: 0.88, - expand: false, - shouldCloseOnMinExtent: false, - resizeOnScroll: false, - backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white, - ); - } -} - -class _AssetDetailBottomSheet extends ConsumerWidget { - const _AssetDetailBottomSheet(); - - String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) { - DateTime dateTime = asset.createdAt.toLocal(); - Duration timeZoneOffset = dateTime.timeZoneOffset; - - // Use EXIF timezone information if available (matching web app behavior) - if (exifInfo?.dateTimeOriginal != null) { - (dateTime, timeZoneOffset) = applyTimezoneOffset( - dateTime: exifInfo!.dateTimeOriginal!, - timeZone: exifInfo.timeZone, - ); - } - - final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); - final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; - return '$date$_kSeparator$time $timezone'; - } - - String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { - final height = asset.height; - final width = asset.width; - final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null; - final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; - - return switch ((fileSize, resolution)) { - (null, null) => '', - (String fileSize, null) => fileSize, - (null, String resolution) => resolution, - (String fileSize, String resolution) => '$fileSize$_kSeparator$resolution', - }; - } - - String? _getCameraInfoTitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - - return switch ((exifInfo.make, exifInfo.model)) { - (null, null) => null, - (String make, null) => make, - (null, String model) => model, - (String make, String model) => '$make $model', - }; - } - - String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - final exposureTime = exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; - final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; - return [exposureTime, iso].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); - } - - String? _getLensInfoSubtitle(ExifInfo? exifInfo) { - if (exifInfo == null) { - return null; - } - final fNumber = exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; - final focalLength = exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; - return [fNumber, focalLength].where((spec) => spec != null && spec.isNotEmpty).join(_kSeparator); - } - - Future _editDateTime(BuildContext context, WidgetRef ref) async { - await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context); - } - - Widget _buildAppearsInList(WidgetRef ref, BuildContext context) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SizedBox.shrink(); - } - - if (!asset.hasRemote) { - return const SizedBox.shrink(); - } - - String? remoteAssetId; - if (asset is RemoteAsset) { - remoteAssetId = asset.id; - } else if (asset is LocalAsset) { - remoteAssetId = asset.remoteAssetId; - } - - if (remoteAssetId == null) { - return const SizedBox.shrink(); - } - - final userId = ref.watch(currentUserProvider)?.id; - final assetAlbums = ref.watch(albumsContainingAssetProvider(remoteAssetId)); - - return assetAlbums.when( - data: (albums) { - if (albums.isEmpty) { - return const SizedBox.shrink(); - } - - albums.sortBy((a) => a.name); - - return Column( - spacing: 12, - children: [ - if (albums.isNotEmpty) - SheetTile( - title: 'appears_in'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - Padding( - padding: const EdgeInsets.only(left: 24), - child: Column( - spacing: 12, - children: albums.map((album) { - final isOwner = album.ownerId == userId; - return AlbumTile( - album: album, - isOwner: isOwner, - onAlbumSelected: (album) async { - ref.invalidate(assetViewerProvider); - unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album))); - }, - ); - }).toList(), - ), - ), - ], - ); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final asset = ref.watch(currentAssetNotifier); - if (asset == null) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - - final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - final cameraTitle = _getCameraInfoTitle(exifInfo); - final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null; - final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null); - final isRatingEnabled = ref - .watch(userMetadataPreferencesProvider) - .maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false); - - // Build file info tile based on asset type - Widget buildFileInfoTile() { - if (asset is LocalAsset) { - final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); - return FutureBuilder( - future: assetMediaRepository.getOriginalFilename(asset.id), - builder: (context, snapshot) { - final displayName = snapshot.data ?? asset.name; - return SheetTile( - title: displayName, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - }, - ); - } else { - // For remote assets, use the name directly - return SheetTile( - title: asset.name, - titleStyle: context.textTheme.labelLarge, - leading: Icon( - asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 24, - color: context.textTheme.labelLarge?.color, - ), - subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ); - } - } - - return SliverList.list( - children: [ - // Asset Date and Time - SheetTile( - title: _getDateTime(context, asset, exifInfo), - titleStyle: context.textTheme.labelLarge, - trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null, - onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null, - ), - if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner), - const SheetPeopleDetails(), - const SheetLocationDetails(), - // Details header - SheetTile( - title: 'details'.t(context: context), - titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - // File info - buildFileInfoTile(), - // Camera info - if (cameraTitle != null) ...[ - const SizedBox(height: 16), - SheetTile( - title: cameraTitle, - titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color), - subtitle: _getCameraInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - ], - // Lens info - if (lensTitle != null) ...[ - const SizedBox(height: 16), - SheetTile( - title: lensTitle, - titleStyle: context.textTheme.labelLarge, - leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color), - subtitle: _getLensInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - ], - // Rating bar - if (isRatingEnabled) ...[ - Padding( - padding: const EdgeInsets.only(left: 16.0, top: 16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - Text( - 'rating'.t(context: context), - style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), - ), - RatingBar( - initialRating: exifInfo?.rating?.toDouble() ?? 0, - filledColor: context.themeData.colorScheme.primary, - unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100), - itemSize: 40, - onRatingUpdate: (rating) async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round()); - }, - onClearRating: () async { - await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0); - }, - ), - ], - ), - ), - ], - // Appears in (Albums) - Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)), - // padding at the bottom to avoid cut-off - const SizedBox(height: 60), - ], - ); - } -} - -class _SheetAssetDescription extends ConsumerStatefulWidget { - final ExifInfo exif; - final bool isEditable; - - const _SheetAssetDescription({required this.exif, this.isEditable = true}); - - @override - ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState(); -} - -class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> { - late TextEditingController _controller; - final _descriptionFocus = FocusNode(); - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.exif.description ?? ''); - } - - Future saveDescription(String? previousDescription) async { - final newDescription = _controller.text.trim(); - - if (newDescription == previousDescription) { - _descriptionFocus.unfocus(); - return; - } - - final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription); - - if (!editAction.success) { - _controller.text = previousDescription ?? ''; - - ImmichToast.show( - context: context, - msg: 'exif_bottom_sheet_description_error'.t(context: context), - toastType: ToastType.error, - ); - } - - _descriptionFocus.unfocus(); - } - - @override - Widget build(BuildContext context) { - // Watch the current asset EXIF provider to get updates - final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull; - - // Update controller text when EXIF data changes - final currentDescription = currentExifInfo?.description ?? ''; - final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t( - context: context, - ); - if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) { - _controller.text = currentDescription; - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), - child: IgnorePointer( - ignoring: !widget.isEditable, - child: TextField( - controller: _controller, - keyboardType: TextInputType.multiline, - focusNode: _descriptionFocus, - maxLines: null, // makes it grow as text is added - decoration: InputDecoration( - hintText: hintText, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - focusedErrorBorder: InputBorder.none, - ), - onTapOutside: (_) => saveDescription(currentExifInfo?.description), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 538a9bde20..643d3e87ef 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provi import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; @@ -205,7 +205,7 @@ class NativeVideoViewer extends HookConsumerWidget { final videoPlayback = VideoPlaybackValue.fromNativeController(videoController); ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) { + if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) { return; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart index c1324b8ac0..a2c1372c83 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; @@ -19,8 +19,8 @@ class VideoViewerControls extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo)); bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); - final showBottomSheet = ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); - if (showBottomSheet) { + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { showControls = false; } final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state)); diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart new file mode 100644 index 0000000000..aa3b8bb93f --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; + +class ViewerBottomAppBar extends ConsumerWidget { + const ViewerBottomAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0.0; + } + + return IgnorePointer( + ignoring: opacity < 1.0, + child: AnimatedOpacity( + opacity: opacity, + duration: Durations.short2, + child: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [AssetStackRow(), ViewerBottomBar()], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 10f3595d01..fb25e9e1cb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart similarity index 80% rename from mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart rename to mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 193cf60220..4b748abc27 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -3,16 +3,15 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; @@ -35,8 +34,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final isInLockedView = ref.watch(inLockedViewProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) { @@ -44,7 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { } if (!showControls) { - opacity = 0; + opacity = 0.0; } final originalTheme = context.themeData; @@ -55,7 +54,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { IconButton( icon: const Icon(Icons.chat_outlined), onPressed: () { - EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true)); + context.router.push( + DriftActivitiesRoute( + album: album, + assetId: asset is RemoteAsset ? asset.id : null, + assetName: asset.name, + ), + ); }, ), @@ -70,17 +75,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final lockedViewActions = [ViewerKebabMenu(originalTheme: originalTheme)]; return IgnorePointer( - ignoring: opacity < 255, + ignoring: opacity < 1.0, child: AnimatedOpacity( - opacity: opacity / 255, + opacity: opacity, duration: Durations.short2, child: AppBar( - backgroundColor: isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125), + backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5), leading: const _AppBarBackButton(), iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet || isReadonlyModeEnabled + actions: showingDetails || isReadonlyModeEnabled ? null : isInLockedView ? lockedViewActions @@ -99,9 +104,9 @@ class _AppBarBackButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet)); - final backgroundColor = isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black; - final foregroundColor = isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white; + final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black; + final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white; return Padding( padding: const EdgeInsets.only(left: 12.0), @@ -112,7 +117,7 @@ class _AppBarBackButton extends ConsumerWidget { iconSize: 22, iconColor: foregroundColor, padding: EdgeInsets.zero, - elevation: isShowingSheet ? 4 : 0, + elevation: showingDetails ? 4 : 0, ), onPressed: context.maybePop, child: const Icon(Icons.arrow_back_rounded), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 75f40ca290..f6d05277ab 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart similarity index 85% rename from mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart rename to mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart index 1956170c1e..5718333759 100644 --- a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset_viewer/asset.provider.dart @@ -31,6 +31,18 @@ class CurrentAssetNotifier extends AutoDisposeNotifier { } } +class ScopedAssetNotifier extends CurrentAssetNotifier { + final BaseAsset _asset; + + ScopedAssetNotifier(this._asset); + + @override + BaseAsset? build() { + setAsset(_asset); + return _asset; + } +} + final currentAssetExifProvider = FutureProvider.autoDispose((ref) { final currentAsset = ref.watch(currentAssetNotifier); if (currentAsset == null) { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b287d73114..5fd8d2be85 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -753,10 +753,17 @@ class DriftActivitiesRoute extends PageRouteInfo { DriftActivitiesRoute({ Key? key, required RemoteAlbum album, + String? assetId, + String? assetName, List? children, }) : super( DriftActivitiesRoute.name, - args: DriftActivitiesRouteArgs(key: key, album: album), + args: DriftActivitiesRouteArgs( + key: key, + album: album, + assetId: assetId, + assetName: assetName, + ), initialChildren: children, ); @@ -766,21 +773,35 @@ class DriftActivitiesRoute extends PageRouteInfo { name, builder: (data) { final args = data.argsAs(); - return DriftActivitiesPage(key: args.key, album: args.album); + return DriftActivitiesPage( + key: args.key, + album: args.album, + assetId: args.assetId, + assetName: args.assetName, + ); }, ); } class DriftActivitiesRouteArgs { - const DriftActivitiesRouteArgs({this.key, required this.album}); + const DriftActivitiesRouteArgs({ + this.key, + required this.album, + this.assetId, + this.assetName, + }); final Key? key; final RemoteAlbum album; + final String? assetId; + + final String? assetName; + @override String toString() { - return 'DriftActivitiesRouteArgs{key: $key, album: $album}'; + return 'DriftActivitiesRouteArgs{key: $key, album: $album, assetId: $assetId, assetName: $assetName}'; } } diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 1a2883bee7..dccb765760 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -225,7 +225,7 @@ enum ActionButtonType { iconData: Icons.info_outline, iconColor: context.originalTheme?.iconTheme.color, menuItem: true, - onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()), + onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.tr(), diff --git a/mobile/lib/widgets/map/asset_market_icon.dart b/mobile/lib/widgets/map/asset_market_icon.dart new file mode 100644 index 0000000000..ff6058161b --- /dev/null +++ b/mobile/lib/widgets/map/asset_market_icon.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class AssetMarkerIcon extends StatelessWidget { + const AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); + + final String id; + final String thumbhash; + + @override + Widget build(BuildContext context) { + final imageUrl = getThumbnailUrlForRemoteId(id); + return LayoutBuilder( + builder: (context, constraints) { + final pinHeight = constraints.maxHeight * 0.14; + final pinWidth = constraints.maxWidth * 0.14; + return SizedOverflowBox( + size: Size(pinWidth, pinHeight), + child: Stack( + // alignment: AlignmentGeometry.center, + children: [ + Positioned( + bottom: 0, + left: constraints.maxWidth * 0.5, + child: CustomPaint( + painter: _PinPainter( + primaryColor: context.colorScheme.onSurface, + secondaryColor: context.colorScheme.surface, + primaryRadius: constraints.maxHeight * 0.06, + secondaryRadius: constraints.maxHeight * 0.038, + ), + child: SizedBox(height: pinHeight, width: pinWidth), + ), + ), + Positioned( + top: constraints.maxHeight * 0.07, + left: constraints.maxWidth * 0.17, + child: CircleAvatar( + radius: constraints.maxHeight * 0.40, + backgroundColor: context.colorScheme.onSurface, + child: CircleAvatar( + radius: constraints.maxHeight * 0.37, + backgroundImage: RemoteImageProvider(url: imageUrl), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _PinPainter extends CustomPainter { + final Color primaryColor; + final Color secondaryColor; + final double primaryRadius; + final double secondaryRadius; + + const _PinPainter({ + required this.primaryColor, + required this.secondaryColor, + required this.primaryRadius, + required this.secondaryRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + Paint primaryBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.fill; + + Paint secondaryBrush = Paint() + ..color = secondaryColor + ..style = PaintingStyle.fill; + + Paint lineBrush = Paint() + ..color = primaryColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); + canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); + canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); + // The line is to make the above triangluar path more prominent since it has a slight curve + canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); + } + + Path getTrianglePath(double x, double y) { + final firstEndPoint = Offset(x / 2, y); + final controlPoint = Offset(x / 2, y * 0.3); + final secondEndPoint = Offset(x, 0); + + return Path() + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) + ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) + ..lineTo(0, 0); + } + + @override + bool shouldRepaint(_PinPainter old) { + return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; + } +} diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 32d90a28d9..e0ab1cfd04 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; -import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// A non-interactive thumbnail of a map in the given coordinates with optional markers @@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude); final controller = useRef(null); final styleLoaded = useState(false); - final position = useValueNotifier?>(null); Future onMapCreated(MapLibreMapController mapController) async { controller.value = mapController; styleLoaded.value = false; - if (assetMarkerRemoteId != null) { - // The iOS impl returns wrong toScreenLocation without the delay - Future.delayed( - const Duration(milliseconds: 100), - () async => position.value = await mapController.toScreenLocation(centre), - ); - } onCreated?.call(mapController); } @@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget { child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(15)), child: Stack( - alignment: Alignment.center, + alignment: AlignmentGeometry.topCenter, children: [ style.widgetWhen( onData: (style) => MapLibreMap( - initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom), + initialCameraPosition: CameraPosition(target: centre, zoom: zoom), styleString: style, onMapCreated: onMapCreated, onStyleLoadedCallback: onStyleLoaded, @@ -109,17 +100,16 @@ class MapThumbnail extends HookConsumerWidget { attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null, ), ), - ValueListenableBuilder( - valueListenable: position, - builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null - ? PositionedAssetMarkerIcon( - size: height / 2, - point: value, - assetRemoteId: assetMarkerRemoteId!, - assetThumbhash: assetThumbhash!, - ) - : const SizedBox.shrink(), - ), + if (assetMarkerRemoteId != null && assetThumbhash != null) + Container( + width: width, + height: height / 2, + alignment: Alignment.bottomCenter, + child: SizedBox.square( + dimension: height / 2.5, + child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!), + ), + ), ], ), ), diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 95b127f5b7..41d49abf1a 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -3,8 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; @@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget { onTap: () => onTap?.call(), child: SizedBox.square( dimension: size, - child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), + child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), ), ), ); } } - -class _AssetMarkerIcon extends StatelessWidget { - const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); - - final String id; - final String thumbhash; - - @override - Widget build(BuildContext context) { - final imageUrl = getThumbnailUrlForRemoteId(id); - return LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Positioned( - bottom: 0, - left: constraints.maxWidth * 0.5, - child: CustomPaint( - painter: _PinPainter( - primaryColor: context.colorScheme.onSurface, - secondaryColor: context.colorScheme.surface, - primaryRadius: constraints.maxHeight * 0.06, - secondaryRadius: constraints.maxHeight * 0.038, - ), - child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14), - ), - ), - Positioned( - top: constraints.maxHeight * 0.07, - left: constraints.maxWidth * 0.17, - child: CircleAvatar( - radius: constraints.maxHeight * 0.40, - backgroundColor: context.colorScheme.onSurface, - child: CircleAvatar( - radius: constraints.maxHeight * 0.37, - backgroundImage: RemoteImageProvider(url: imageUrl), - ), - ), - ), - ], - ); - }, - ); - } -} - -class _PinPainter extends CustomPainter { - final Color primaryColor; - final Color secondaryColor; - final double primaryRadius; - final double secondaryRadius; - - const _PinPainter({ - required this.primaryColor, - required this.secondaryColor, - required this.primaryRadius, - required this.secondaryRadius, - }); - - @override - void paint(Canvas canvas, Size size) { - Paint primaryBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.fill; - - Paint secondaryBrush = Paint() - ..color = secondaryColor - ..style = PaintingStyle.fill; - - Paint lineBrush = Paint() - ..color = primaryColor - ..style = PaintingStyle.stroke - ..strokeWidth = 2; - - canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush); - canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush); - canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush); - // The line is to make the above triangluar path more prominent since it has a slight curve - canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush); - } - - Path getTrianglePath(double x, double y) { - final firstEndPoint = Offset(x / 2, y); - final controlPoint = Offset(x / 2, y * 0.3); - final secondEndPoint = Offset(x, 0); - - return Path() - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy) - ..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy) - ..lineTo(0, 0); - } - - @override - bool shouldRepaint(_PinPainter old) { - return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor; - } -} diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 69be96ed53..f9d3c66767 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -257,6 +257,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -299,6 +300,7 @@ class PhotoView extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.customSize, @@ -417,6 +419,9 @@ class PhotoView extends StatefulWidget { /// location. final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// A callback when a drag gesture is canceled by the system. + final VoidCallback? onDragCancel; + /// A pointer that will trigger a scale has stopped contacting the screen at a /// particular location. final PhotoViewImageScaleEndCallback? onScaleEnd; @@ -543,7 +548,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final computedOuterSize = widget.customSize ?? constraints.biggest; - final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.black); + final backgroundDecoration = widget.backgroundDecoration ?? const BoxDecoration(color: Colors.transparent); return widget._isCustomChild ? CustomChildWrapper( @@ -564,6 +569,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, @@ -596,6 +602,7 @@ class _PhotoViewState extends State with AutomaticKeepAliveClientMixi onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: computedOuterSize, diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index af5b9a7ce7..aa33d18403 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -284,6 +284,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -321,6 +322,7 @@ class _PhotoViewGalleryState extends State { onDragStart: pageOption.onDragStart, onDragEnd: pageOption.onDragEnd, onDragUpdate: pageOption.onDragUpdate, + onDragCancel: pageOption.onDragCancel, onScaleEnd: pageOption.onScaleEnd, onLongPressStart: pageOption.onLongPressStart, gestureDetectorBehavior: pageOption.gestureDetectorBehavior, @@ -367,6 +369,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -397,6 +400,7 @@ class PhotoViewGalleryPageOptions { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -454,9 +458,12 @@ class PhotoViewGalleryPageOptions { /// Mirror to [PhotoView.onDragDown] final PhotoViewImageDragEndCallback? onDragEnd; - /// Mirror to [PhotoView.onDraUpdate] + /// Mirror to [PhotoView.onDragUpdate] final PhotoViewImageDragUpdateCallback? onDragUpdate; + /// Mirror to [PhotoView.onDragCancel] + final VoidCallback? onDragCancel; + /// Mirror to [PhotoView.onTapDown] final PhotoViewImageTapDownCallback? onTapDown; diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index d21b49f020..72c4766c45 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -36,6 +36,7 @@ class PhotoViewCore extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.gestureDetectorBehavior, @@ -62,6 +63,7 @@ class PhotoViewCore extends StatefulWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, this.gestureDetectorBehavior, @@ -100,6 +102,7 @@ class PhotoViewCore extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageLongPressStartCallback? onLongPressStart; @@ -386,6 +389,7 @@ class PhotoViewCoreState extends State onDragUpdate: widget.onDragUpdate != null ? (details) => widget.onDragUpdate!(context, details, widget.controller.value) : null, + onDragCancel: widget.onDragCancel, hitDetector: this, onTapUp: widget.onTapUp != null ? (details) => widget.onTapUp!(context, details, value) : null, onTapDown: widget.onTapDown != null ? (details) => widget.onTapDown!(context, details, value) : null, diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 0d2f6fa457..6cbcec8d82 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -16,6 +16,7 @@ class PhotoViewGestureDetector extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onLongPressStart, this.child, this.onTapUp, @@ -34,6 +35,7 @@ class PhotoViewGestureDetector extends StatelessWidget { final GestureDragEndCallback? onDragEnd; final GestureDragStartCallback? onDragStart; final GestureDragUpdateCallback? onDragUpdate; + final GestureDragCancelCallback? onDragCancel; final GestureTapUpCallback? onTapUp; final GestureTapDownCallback? onTapDown; @@ -73,7 +75,8 @@ class PhotoViewGestureDetector extends StatelessWidget { instance ..onStart = onDragStart ..onUpdate = onDragUpdate - ..onEnd = onDragEnd; + ..onEnd = onDragEnd + ..onCancel = onDragCancel; }, ); } diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index cd70745703..ee18668f52 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -28,6 +28,7 @@ class ImageWrapper extends StatefulWidget { required this.onDragStart, required this.onDragEnd, required this.onDragUpdate, + required this.onDragCancel, required this.onScaleEnd, required this.onLongPressStart, required this.outerSize, @@ -62,6 +63,7 @@ class ImageWrapper extends StatefulWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -203,6 +205,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, outerSize: widget.outerSize, @@ -233,6 +236,7 @@ class _ImageWrapperState extends State { onDragStart: widget.onDragStart, onDragEnd: widget.onDragEnd, onDragUpdate: widget.onDragUpdate, + onDragCancel: widget.onDragCancel, onScaleEnd: widget.onScaleEnd, onLongPressStart: widget.onLongPressStart, gestureDetectorBehavior: widget.gestureDetectorBehavior, @@ -281,6 +285,7 @@ class CustomChildWrapper extends StatelessWidget { this.onDragStart, this.onDragEnd, this.onDragUpdate, + this.onDragCancel, this.onScaleEnd, this.onLongPressStart, required this.outerSize, @@ -313,6 +318,7 @@ class CustomChildWrapper extends StatelessWidget { final PhotoViewImageDragStartCallback? onDragStart; final PhotoViewImageDragEndCallback? onDragEnd; final PhotoViewImageDragUpdateCallback? onDragUpdate; + final VoidCallback? onDragCancel; final PhotoViewImageScaleEndCallback? onScaleEnd; final PhotoViewImageLongPressStartCallback? onLongPressStart; final Size outerSize; @@ -348,6 +354,7 @@ class CustomChildWrapper extends StatelessWidget { onDragStart: onDragStart, onDragEnd: onDragEnd, onDragUpdate: onDragUpdate, + onDragCancel: onDragCancel, onScaleEnd: onScaleEnd, onLongPressStart: onLongPressStart, gestureDetectorBehavior: gestureDetectorBehavior, From 3f41916ad752e73fbb9440d5ff32c284c9183c5b Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:53:44 +0000 Subject: [PATCH 18/78] chore(mobile): fix asset marker icon file name (#26290) --- .../map/{asset_market_icon.dart => asset_marker_icon.dart} | 0 mobile/lib/widgets/map/map_thumbnail.dart | 2 +- mobile/lib/widgets/map/positioned_asset_marker_icon.dart | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename mobile/lib/widgets/map/{asset_market_icon.dart => asset_marker_icon.dart} (100%) diff --git a/mobile/lib/widgets/map/asset_market_icon.dart b/mobile/lib/widgets/map/asset_marker_icon.dart similarity index 100% rename from mobile/lib/widgets/map/asset_market_icon.dart rename to mobile/lib/widgets/map/asset_marker_icon.dart diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index e0ab1cfd04..7defb52264 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; -import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; /// A non-interactive thumbnail of a map in the given coordinates with optional markers diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 41d49abf1a..b6d7241cf4 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/map/asset_market_icon.dart'; +import 'package:immich_mobile/widgets/map/asset_marker_icon.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; From 8f9ea6a17189dc093c7873f139442df1e5ce4ce9 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:59:52 +0100 Subject: [PATCH 19/78] fix: utc time zone upserts (#26258) fix: utc timezone upserts --- server/src/services/metadata.service.spec.ts | 6 +- server/src/services/metadata.service.ts | 15 ++++- .../specs/services/asset.service.spec.ts | 58 ++++++++++++++++++- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 8530f6fed2..7424c2154f 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -919,7 +919,7 @@ describe(MetadataService.name, () => { Orientation: 0, ProfileDescription: 'extensive description', ProjectionType: 'equirectangular', - tz: 'UTC-11:30', + zone: 'UTC-11:30', TagsList: ['parent/child'], Rating: 3, }; @@ -955,7 +955,7 @@ describe(MetadataService.name, () => { orientation: tags.Orientation?.toString(), profileDescription: tags.ProfileDescription, projectionType: 'EQUIRECTANGULAR', - timeZone: tags.tz, + timeZone: tags.zone, rating: tags.Rating, country: null, state: null, @@ -987,7 +987,7 @@ describe(MetadataService.name, () => { const tags: ImmichTags = { DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'), - tz: undefined, + zone: undefined, }; mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags(tags); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4113025914..8b9db5b376 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -527,6 +527,15 @@ export class MetadataService extends BaseService { for (const tag of EXIF_DATE_TAGS) { delete mediaTags[tag]; } + + // exiftool-vendored derives tz information from the date. + // if the sidecar file has date information, we also assume the tz information come from there. + // + // this is especially important in the case of UTC+0 where exiftool-vendored does not return tz/zone fields + // and as such the tags aren't overwritten when returning all tags. + for (const tag of ['zone', 'tz', 'tzSource'] as const) { + delete mediaTags[tag]; + } } } @@ -897,8 +906,8 @@ export class MetadataService extends BaseService { } // timezone - let timeZone = exifTags.tz ?? null; - if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + let timeZone = exifTags.zone ?? null; + if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) { // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly // https://github.com/photostructure/exiftool-vendored.js/issues/203 timeZone = 'UTC+0'; @@ -906,7 +915,7 @@ export class MetadataService extends BaseService { if (timeZone) { this.logger.verbose( - `Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`, + `Found timezone ${timeZone} via ${exifTags.zoneSource} for asset ${asset.id}: ${asset.originalPath}`, ); } else { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 29e7ea7039..db1b944e1f 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -398,6 +398,23 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with time zone UTC+0', 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, description: 'test', timeZone: 'UTC-7' }); + + await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); }); describe('updateAll', () => { @@ -456,7 +473,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets with timezone', async () => { + it('should relatively update assets with timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -477,7 +494,7 @@ describe(AssetService.name, () => { ); }); - it('should relatively update an assets and set a timezone', async () => { + it('should relatively update assets and set a timezone', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); const { user } = await ctx.newUser(); @@ -497,6 +514,26 @@ describe(AssetService.name, () => { ); }); + it('should set asset time zones to UTC', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.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, dateTimeOriginal: '2023-11-19T18:11:00', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], timeZone: 'UTC' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-19T18:11:00+00:00', + timeZone: 'UTC', + }), + }), + ); + }); + it('should update dateTimeOriginal', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -530,6 +567,23 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal with UTC time zone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.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, description: 'test', timeZone: 'UTC-7' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000Z' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }), + }), + ); + }); }); describe('upsertBulkMetadata', () => { From 5adb75c272f494c2c3830f356f61e2afe59b63ab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:05:41 +0100 Subject: [PATCH 20/78] fix(deps): update dependency @mapbox/mapbox-gl-rtl-text to v0.3.0 (#23353) * fix(deps): update dependency @mapbox/mapbox-gl-rtl-text to v0.3.0 * fix: maplibre rtl import --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- pnpm-lock.yaml | 151 ++---------------- web/package.json | 2 +- .../shared-components/map/map.svelte | 2 +- 3 files changed, 15 insertions(+), 140 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71dece4861..a67ee43489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -744,8 +744,8 @@ importers: 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) '@mapbox/mapbox-gl-rtl-text': - specifier: 0.2.3 - version: 0.2.3(mapbox-gl@1.13.3) + specifier: 0.3.0 + version: 0.3.0 '@mdi/js': specifier: ^7.4.47 version: 7.4.47 @@ -3310,48 +3310,26 @@ packages: resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} hasBin: true - '@mapbox/geojson-types@1.0.2': - resolution: {integrity: sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==} - '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} - '@mapbox/mapbox-gl-rtl-text@0.2.3': - resolution: {integrity: sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' - - '@mapbox/mapbox-gl-supported@1.5.0': - resolution: {integrity: sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==} - peerDependencies: - mapbox-gl: '>=0.32.1 <2.0.0' + '@mapbox/mapbox-gl-rtl-text@0.3.0': + resolution: {integrity: sha512-OwQplFqAAEYRobrTKm2wiVP+wcpUVlgXXiUMNQ8tcm5gPN5SQRXFADmITdQOaec4LhDhuuFchS7TS8ua8dUl4w==} '@mapbox/node-pre-gyp@1.0.11': resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true - '@mapbox/point-geometry@0.1.0': - resolution: {integrity: sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==} - '@mapbox/point-geometry@1.1.0': resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} - '@mapbox/tiny-sdf@1.2.5': - resolution: {integrity: sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==} - '@mapbox/tiny-sdf@2.0.7': resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} - '@mapbox/unitbezier@0.0.0': - resolution: {integrity: sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==} - '@mapbox/unitbezier@0.0.1': resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} - '@mapbox/vector-tile@1.3.1': - resolution: {integrity: sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==} - '@mapbox/vector-tile@2.0.4': resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} @@ -6342,9 +6320,6 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - csscolorparser@1.0.3: - resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} - cssdb@8.5.2: resolution: {integrity: sha512-Pmoj9RmD8RIoIzA2EQWO4D4RMeDts0tgAH0VXdlNdxjuBGI3a9wMOIcUwaPNmD4r2qtIa06gqkIf7sECl+cBCg==} @@ -6840,9 +6815,6 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - earcut@2.2.4: - resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} - earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -7470,9 +7442,6 @@ packages: resolution: {integrity: sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg==} hasBin: true - geojson-vt@3.2.1: - resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==} - geojson@0.5.0: resolution: {integrity: sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==} engines: {node: '>= 0.10'} @@ -7601,9 +7570,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - grid-index@1.1.0: - resolution: {integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==} - gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -8329,9 +8295,6 @@ packages: resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} hasBin: true - kdbush@3.0.0: - resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} - kdbush@4.0.2: resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} @@ -8663,10 +8626,6 @@ packages: resolution: {integrity: sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==} engines: {node: ^20.17.0 || >=22.9.0} - mapbox-gl@1.13.3: - resolution: {integrity: sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==} - engines: {node: '>=6.4.0'} - maplibre-gl@5.18.0: resolution: {integrity: sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} @@ -8694,8 +8653,8 @@ packages: engines: {node: '>= 20'} hasBin: true - marked@17.0.1: - resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + marked@17.0.3: + resolution: {integrity: sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==} engines: {node: '>= 20'} hasBin: true @@ -10147,9 +10106,6 @@ packages: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} - potpack@1.0.2: - resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} - potpack@2.1.0: resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} @@ -10301,9 +10257,6 @@ packages: resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} engines: {node: '>=18'} - quickselect@2.0.0: - resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==} - quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -10868,8 +10821,8 @@ packages: resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==} engines: {node: '>=0.12.18'} - simple-icons@16.4.0: - resolution: {integrity: sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==} + simple-icons@16.9.0: + resolution: {integrity: sha512-aKst2C7cLkFyaiQ/Crlwxt9xYOpGPk05XuJZ0ZTJNNCzHCKYrGWz2ebJSi5dG8CmTCxUF/BGs6A8uyJn/EQxqw==} engines: {node: '>=0.12.18'} sirv@2.0.4: @@ -11127,9 +11080,6 @@ packages: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} - supercluster@7.1.5: - resolution: {integrity: sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==} - supercluster@8.0.1: resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} @@ -11430,9 +11380,6 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} - tinyqueue@2.0.3: - resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==} - tinyqueue@3.0.0: resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} @@ -11956,9 +11903,6 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} - vt-pbf@3.1.3: - resolution: {integrity: sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==} - w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -14958,7 +14902,7 @@ snapshots: '@immich/svelte-markdown-preprocess@0.2.1(svelte@5.50.2)': dependencies: front-matter: 4.0.2 - marked: 17.0.1 + marked: 17.0.3 node-emoji: 2.2.0 svelte: 5.50.2 @@ -14969,7 +14913,7 @@ snapshots: '@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) luxon: 3.7.2 - simple-icons: 16.4.0 + simple-icons: 16.9.0 svelte: 5.50.2 svelte-highlight: 7.9.0 tailwind-merge: 3.4.0 @@ -15280,17 +15224,9 @@ snapshots: get-stream: 6.0.1 minimist: 1.2.8 - '@mapbox/geojson-types@1.0.2': {} - '@mapbox/jsonlint-lines-primitives@2.0.2': {} - '@mapbox/mapbox-gl-rtl-text@0.2.3(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 - - '@mapbox/mapbox-gl-supported@1.5.0(mapbox-gl@1.13.3)': - dependencies: - mapbox-gl: 1.13.3 + '@mapbox/mapbox-gl-rtl-text@0.3.0': {} '@mapbox/node-pre-gyp@1.0.11': dependencies: @@ -15323,22 +15259,12 @@ snapshots: - encoding - supports-color - '@mapbox/point-geometry@0.1.0': {} - '@mapbox/point-geometry@1.1.0': {} - '@mapbox/tiny-sdf@1.2.5': {} - '@mapbox/tiny-sdf@2.0.7': {} - '@mapbox/unitbezier@0.0.0': {} - '@mapbox/unitbezier@0.0.1': {} - '@mapbox/vector-tile@1.3.1': - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile@2.0.4': dependencies: '@mapbox/point-geometry': 1.1.0 @@ -18584,8 +18510,6 @@ snapshots: css.escape@1.5.1: {} - csscolorparser@1.0.3: {} - cssdb@8.5.2: {} cssesc@3.0.0: {} @@ -19128,8 +19052,6 @@ snapshots: duplexer@0.1.2: {} - earcut@2.2.4: {} - earcut@3.0.2: {} eastasianwidth@0.2.0: {} @@ -19985,8 +19907,6 @@ snapshots: pbf: 3.3.0 shapefile: 0.6.6 - geojson-vt@3.2.1: {} - geojson@0.5.0: {} get-caller-file@2.0.5: {} @@ -20136,8 +20056,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - grid-index@1.1.0: {} - gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -21037,8 +20955,6 @@ snapshots: dependencies: commander: 8.3.0 - kdbush@3.0.0: {} - kdbush@4.0.2: {} keygrip@1.1.0: @@ -21330,31 +21246,6 @@ snapshots: transitivePeerDependencies: - supports-color - mapbox-gl@1.13.3: - dependencies: - '@mapbox/geojson-rewind': 0.5.2 - '@mapbox/geojson-types': 1.0.2 - '@mapbox/jsonlint-lines-primitives': 2.0.2 - '@mapbox/mapbox-gl-supported': 1.5.0(mapbox-gl@1.13.3) - '@mapbox/point-geometry': 0.1.0 - '@mapbox/tiny-sdf': 1.2.5 - '@mapbox/unitbezier': 0.0.0 - '@mapbox/vector-tile': 1.3.1 - '@mapbox/whoots-js': 3.1.0 - csscolorparser: 1.0.3 - earcut: 2.2.4 - geojson-vt: 3.2.1 - gl-matrix: 3.4.4 - grid-index: 1.1.0 - murmurhash-js: 1.0.0 - pbf: 3.3.0 - potpack: 1.0.2 - quickselect: 2.0.0 - rw: 1.3.3 - supercluster: 7.1.5 - tinyqueue: 2.0.3 - vt-pbf: 3.1.3 - maplibre-gl@5.18.0: dependencies: '@mapbox/geojson-rewind': 0.5.2 @@ -21394,7 +21285,7 @@ snapshots: marked@16.4.2: {} - marked@17.0.1: {} + marked@17.0.3: {} math-intrinsics@1.1.0: {} @@ -23188,8 +23079,6 @@ snapshots: postgres@3.4.8: {} - potpack@1.0.2: {} - potpack@2.1.0: {} prelude-ls@1.2.1: {} @@ -23349,8 +23238,6 @@ snapshots: quick-lru@7.3.0: {} - quickselect@2.0.0: {} - quickselect@3.0.0: {} railroad-diagrams@1.0.0: {} @@ -24142,7 +24029,7 @@ snapshots: simple-icons@15.22.0: {} - simple-icons@16.4.0: {} + simple-icons@16.9.0: {} sirv@2.0.4: dependencies: @@ -24448,10 +24335,6 @@ snapshots: transitivePeerDependencies: - supports-color - supercluster@7.1.5: - dependencies: - kdbush: 3.0.0 - supercluster@8.0.1: dependencies: kdbush: 4.0.2 @@ -24860,8 +24743,6 @@ snapshots: tinypool@1.1.1: {} - tinyqueue@2.0.3: {} - tinyqueue@3.0.0: {} tinyrainbow@2.0.0: {} @@ -25533,12 +25414,6 @@ snapshots: vscode-uri@3.0.8: {} - vt-pbf@3.1.3: - dependencies: - '@mapbox/point-geometry': 0.1.0 - '@mapbox/vector-tile': 1.3.1 - pbf: 3.3.0 - w3c-keyname@2.2.8: {} w3c-xmlserializer@4.0.0: diff --git a/web/package.json b/web/package.json index 507b01f6bb..65eb7a2396 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", "@immich/ui": "^0.63.0", - "@mapbox/mapbox-gl-rtl-text": "0.2.3", + "@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0", diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index f008df4cb8..0b19306d6e 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -1,5 +1,5 @@ -
- +
- - -
{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/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({ From b3b9834c0040f1fbffb9dc43c9fb90b439158219 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 19 Feb 2026 02:29:13 +0100 Subject: [PATCH 39/78] feat(web): loop chromecast video (#24410) --- web/src/lib/utils/cast/gcast-destination.svelte.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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)); } /// From e520fc3b63e49108008d861c25f477528d28f8c4 Mon Sep 17 00:00:00 2001 From: Hao Xi Date: Thu, 19 Feb 2026 01:20:36 -0500 Subject: [PATCH 40/78] fix: include `DROP INDEX` in transaction to prevent missing index on rollback (#25399) * fix: ERR_PNPM_ENOENT error while `make dev` on macOS. * fix: include `DROP INDEX` in transaction to prevent missing index on rollback. * chore: clean up this PR. --- server/src/repositories/database.repository.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); From 316f86d25e0300481d30b1aab6f77b9dcc6e4b90 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 07:39:41 +0100 Subject: [PATCH 41/78] feat: add .mxf file support (#24644) * feat: add support for MXF format in media handling * Updated supported formats documentation to include MXF. * Added MXF to valid video extensions in tests. * Registered MXF MIME type in mime-types utility. * fix: enhance MXF handling in mime-types utility * Updated video mime type validation to include 'application/mxf'. * Adjusted asset type determination to recognize MXF as a video container. * chore: clean up --------- Co-authored-by: Jason Rasmussen --- docs/docs/features/supported-formats.md | 1 + server/src/services/asset-media.service.spec.ts | 1 + server/src/utils/mime-types.spec.ts | 5 ++++- server/src/utils/mime-types.ts | 6 +++++- 4 files changed, 11 insertions(+), 2 deletions(-) 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/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/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)], From f965daa8d2d8d7c1581450f5d10c56533bc895d4 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 13:14:26 +0100 Subject: [PATCH 42/78] chore: remove push trigger for check-openapi workflow (#26341) --- .github/workflows/check-openapi.yml | 5 ----- 1 file changed, 5 deletions(-) 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 }} From e0bb5f70ec67f60a0daed8b6f4b623aa3999009d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:28:12 +0000 Subject: [PATCH 43/78] fix(deps): update dependency fabric to v7 [security] (#26342) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 247 ++--------------------------------------------- web/package.json | 2 +- 2 files changed, 8 insertions(+), 241 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4719d9752..007acaa828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -780,8 +780,8 @@ importers: 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 @@ -4646,10 +4646,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'} @@ -5281,10 +5277,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==} @@ -5304,9 +5296,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: @@ -6359,16 +6348,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'} @@ -6536,10 +6515,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'} @@ -6778,11 +6753,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'} @@ -6978,11 +6948,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 @@ -7199,9 +7164,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==} @@ -7690,10 +7655,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'} @@ -7765,10 +7726,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'} @@ -8207,15 +8164,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'} @@ -10220,9 +10168,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==} @@ -10246,9 +10191,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==} @@ -11424,10 +11366,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'} @@ -11435,10 +11373,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'} @@ -11663,10 +11597,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'} @@ -11710,9 +11640,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'} @@ -11909,10 +11836,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'} @@ -12010,11 +11933,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'} @@ -12028,10 +11946,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'} @@ -12144,10 +12058,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'} @@ -16579,9 +16489,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': @@ -17437,9 +17344,6 @@ snapshots: '@zoom-image/core': 0.42.0 svelte: 5.50.2 - abab@2.0.6: - optional: true - abbrev@1.1.1: {} abbrev@4.0.0: {} @@ -17458,12 +17362,6 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-globals@7.0.1: - dependencies: - acorn: 8.15.0 - acorn-walk: 8.3.4 - optional: true - acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -18583,17 +18481,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 @@ -18791,13 +18678,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 @@ -19014,11 +18894,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 @@ -19300,15 +19175,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) @@ -19647,10 +19513,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 @@ -20288,11 +20154,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 @@ -20390,15 +20251,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 @@ -20799,42 +20651,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 @@ -23211,11 +23027,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 @@ -23239,9 +23050,6 @@ snapshots: dependencies: side-channel: 1.1.0 - querystringify@2.2.0: - optional: true - queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -24788,14 +24596,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 @@ -24803,11 +24603,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 @@ -25029,9 +24824,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: {} @@ -25090,12 +24882,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 @@ -25426,11 +25212,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 @@ -25627,11 +25408,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 @@ -25642,12 +25418,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 @@ -25735,9 +25505,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/web/package.json b/web/package.json index 65eb7a2396..53b8ae77db 100644 --- a/web/package.json +++ b/web/package.json @@ -40,7 +40,7 @@ "@zoom-image/core": "^0.42.0", "@zoom-image/svelte": "^0.3.0", "dom-to-image": "^2.6.0", - "fabric": "^6.5.4", + "fabric": "^7.0.0", "geo-coordinates-parser": "^1.7.4", "geojson": "^0.5.0", "handlebars": "^4.7.8", From d0ed76dc37c6512f2258467e128234cb1a512744 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:51:18 +0100 Subject: [PATCH 44/78] refactor: small face tests (#26340) --- server/src/queries/person.repository.sql | 24 +- server/src/repositories/person.repository.ts | 19 +- server/src/services/media.service.spec.ts | 4 +- server/src/services/person.service.spec.ts | 423 ++++++++++--------- server/src/services/person.service.ts | 8 +- server/test/factories/asset-face.factory.ts | 12 +- server/test/factories/asset.factory.ts | 2 +- server/test/fixtures/face.stub.ts | 160 ------- server/test/mappers.ts | 29 ++ 9 files changed, 287 insertions(+), 394 deletions(-) delete mode 100644 server/test/fixtures/face.stub.ts 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/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/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/person.service.spec.ts b/server/src/services/person.service.spec.ts index 0928b57f97..4b60cd8e7f 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.person.getFaces.mockResolvedValue([face]); mocks.asset.getById.mockResolvedValue(asset); - await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ - mapFaces(faceStub.primaryFace1, authStub.admin), - ]); + 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..ea7b3f9e78 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -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/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]' }, +}); From fd0338f89c4e9f993d207fa54336df597bf22bc3 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:54:28 +0100 Subject: [PATCH 45/78] refactor: asset service queries (#25535) --- server/src/queries/asset.repository.sql | 41 +++++++++ server/src/repositories/asset.repository.ts | 27 ++++++ server/src/services/asset.service.spec.ts | 4 +- server/src/services/asset.service.ts | 16 ++-- server/src/utils/asset.util.ts | 21 +++-- .../specs/services/asset.service.spec.ts | 91 ++++++++++++++++++- .../repositories/asset.repository.mock.ts | 2 + 7 files changed, 185 insertions(+), 17 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index e8cdd335e2..01c580fb13 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -636,3 +636,44 @@ 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 diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index d99d8cbab2..3165aed023 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1059,4 +1059,31 @@ 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(); + } } 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..bda2d122fe 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'); } @@ -584,7 +588,7 @@ export class AssetService extends BaseService { 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/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/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index db1b944e1f..d97551d154 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,57 @@ 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('upsertBulkMetadata', () => { it('should work', async () => { const { sut, ctx } = setup(); @@ -758,4 +813,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 ea7162c77a..b1ced1f874 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -54,5 +54,7 @@ export const newAssetRepositoryMock = (): Mocked Date: Thu, 19 Feb 2026 09:15:56 -0500 Subject: [PATCH 46/78] feat: editing descriminator (#26336) --- open-api/immich-openapi-specs.json | 20 ++++++++++++++++++-- server/src/dtos/editing.dto.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 420c7e1015..0e57fc4819 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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" 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[]; From 208c07af1f9f5683e4238062aca71f5e77251e0a Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 16:15:26 +0100 Subject: [PATCH 47/78] chore(web): merge "Add to album" and "Add to shared album" actions into a single action (#24669) * refactor: simplify album selection actions by removing shared option * Removed the shared option from AddToAlbumAction and related components. * Updated AlbumPickerModal and other components to reflect this change. * Cleaned up related tests and documentation for consistency. * fix lint --- .../actions/add-to-album-action.svelte | 15 ++---- .../asset-viewer/asset-viewer-nav-bar.svelte | 1 - .../memory-page/memory-viewer.svelte | 6 +-- .../album-selection-utils.spec.ts | 47 +++++-------------- .../album-selection/album-selection-utils.ts | 18 +++---- .../timeline/actions/AddToAlbumAction.svelte | 30 +++++++----- .../workflows/WorkflowPickerField.svelte | 2 +- web/src/lib/modals/AlbumPickerModal.svelte | 9 ++-- web/src/lib/modals/ShortcutsModal.svelte | 1 - .../[[assetId=id]]/+page.svelte | 5 +- .../[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 9 +--- .../[[assetId=id]]/+page.svelte | 13 +---- .../(user)/photos/[[assetId=id]]/+page.svelte | 7 +-- .../[[assetId=id]]/+page.svelte | 8 ++-- .../[[assetId=id]]/+page.svelte | 7 +-- 18 files changed, 67 insertions(+), 132 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index 2c6ac54ef7..cf8ba15024 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -8,19 +8,18 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { modalManager } from '@immich/ui'; - import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; + import { mdiImageAlbum } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { asset: AssetResponseDto; onAction: OnAction; - shared?: boolean; } - let { asset, onAction, shared = false }: Props = $props(); + let { asset, onAction }: Props = $props(); const onClick = async () => { - const albums = await modalManager.show(AlbumPickerModal, { shared }); + const albums = await modalManager.show(AlbumPickerModal, {}); if (!albums || albums.length === 0) { return; @@ -40,10 +39,6 @@ }; - + - + 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..93ce2f01e3 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 @@ -186,7 +186,6 @@ {:else} - {/if} {/if} diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index efa425dd30..20d43f1974 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -48,7 +48,6 @@ mdiImageSearch, mdiPause, mdiPlay, - mdiPlus, mdiSelectAll, mdiVolumeHigh, mdiVolumeOff, @@ -339,10 +338,7 @@ onclick={handleSelectAll} /> - - - - + 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/timeline/actions/AddToAlbumAction.svelte b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte index 97b75b070d..6dce0ce084 100644 --- a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte +++ b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte @@ -4,21 +4,21 @@ import type { OnAddToAlbum } from '$lib/utils/actions'; import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils'; import { getAssetControlContext } from '$lib/utils/context'; - import { modalManager } from '@immich/ui'; - import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; + import { IconButton, modalManager } from '@immich/ui'; + import { mdiImageAlbum, mdiPlus } from '@mdi/js'; import { t } from 'svelte-i18n'; interface Props { - shared?: boolean; onAddToAlbum?: OnAddToAlbum; + menuItem?: boolean; } - let { shared = false, onAddToAlbum = () => {} }: Props = $props(); + let { onAddToAlbum = () => {}, menuItem = false }: Props = $props(); const { getAssets } = getAssetControlContext(); const onClick = async () => { - const albums = await modalManager.show(AlbumPickerModal, { shared }); + const albums = await modalManager.show(AlbumPickerModal, {}); if (!albums || albums.length === 0) { return; } @@ -38,9 +38,17 @@ }; - +{#if menuItem} + +{/if} + +{#if !menuItem} + +{/if} 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/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/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/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..38817650c1 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 @@ -440,10 +440,7 @@ > - - - - + {#if assetInteraction.isAllUserOwned} - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index d33c5e7474..74993cb64b 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -19,7 +19,7 @@ import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -71,10 +71,7 @@ timelineManager.removeAssets(assetIds)} /> - - - - + diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9bca4a9094..c9ac99d10f 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -31,7 +31,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { joinPaths } from '$lib/utils/tree-utils'; import { IconButton, Text } from '@immich/ui'; - import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; + import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -130,10 +130,7 @@ icon={mdiSelectAll} onclick={handleSelectAllAssets} /> - - cancelMultiselect(assetInteraction)} /> - cancelMultiselect(assetInteraction)} shared /> - + cancelMultiselect(assetInteraction)} /> import { goto } from '$app/navigation'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte'; @@ -10,8 +9,7 @@ import { Route } from '$lib/route'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetVisibility } from '@immich/sdk'; - import { mdiArrowLeft, mdiPlus } from '@mdi/js'; - import { t } from 'svelte-i18n'; + import { mdiArrowLeft } from '@mdi/js'; import type { PageData } from './$types'; interface Props { @@ -46,10 +44,7 @@ clearSelect={() => assetInteraction.clearMultiselect()} > - - - - + {:else} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 57c5730b45..3c18b866c1 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -41,13 +41,7 @@ import { isExternalUrl } from '$lib/utils/navigation'; import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui'; - import { - mdiAccountBoxOutline, - mdiAccountMultipleCheckOutline, - mdiArrowLeft, - mdiDotsVertical, - mdiPlus, - } from '@mdi/js'; + import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; @@ -463,10 +457,7 @@ > - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index bea77bb443..bef36d5602 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -42,7 +42,7 @@ import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; import { ImageCarousel } from '@immich/ui'; - import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; let { isViewing: showAssetViewer } = assetViewingStore; @@ -134,10 +134,7 @@ - - - - + {#if isAllUserOwned} - - - - + {#if isAllUserOwned} + diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 2b498e56ea..868f23bf55 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -31,7 +31,7 @@ import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; import { Text } from '@immich/ui'; - import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js'; + import { mdiDotsVertical, mdiTag, mdiTagMultiple } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -122,10 +122,7 @@ > - - - - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} From f04efbb714685e352a1dc044f52dededf8ca25a3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 10:40:13 -0500 Subject: [PATCH 48/78] fix: safari address bar color (#26346) --- web/src/app.css | 4 ++++ web/src/lib/components/asset-viewer/asset-viewer.svelte | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/web/src/app.css b/web/src/app.css index dc2d3bf3c3..3a4d29b466 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -148,6 +148,10 @@ color: #3a3a3a; } + body.asset-viewer-open { + background-color: black; + } + input:focus-visible { outline-offset: 0px !important; outline: none !important; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7decdac835..ad34e68fb8 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 @@
- {#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 }]} From 4f2e6e3f15de5c94451843eb63b8c0b111d43eb8 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:32:25 +0100 Subject: [PATCH 53/78] fix(web): favoriting assets opened via GalleryViewer (#26350) fix(web): favoriting assets through GalleryViewer --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index ad34e68fb8..8c9bb4156b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -398,7 +398,7 @@ const onAssetUpdate = (update: AssetResponseDto) => { if (asset.id === update.id) { - cursor.current = update; + cursor = { ...cursor, current: update }; } }; From 7005e9fc50c7cb78927c5b66b1019a5c629a1733 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:33:06 +0100 Subject: [PATCH 54/78] fix(web): update @immich/ui to v0.64.0 (#26351) --- pnpm-lock.yaml | 18 +++++++++--------- web/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 007acaa828..09b24cb3df 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.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) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -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 @@ -5643,8 +5643,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 @@ -14819,12 +14819,12 @@ snapshots: node-emoji: 2.2.0 svelte: 5.50.2 - '@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.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)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.50.2) '@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.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) luxon: 3.7.2 simple-icons: 16.9.0 svelte: 5.50.2 @@ -17701,7 +17701,7 @@ 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.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): dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/dom': 1.7.4 diff --git a/web/package.json b/web/package.json index 53b8ae77db..6d1a8ce933 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.63.0", + "@immich/ui": "^0.64.0", "@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", From ffd54d043161a788a980bd3ebd79eccc3c71d7f3 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 17:53:19 +0100 Subject: [PATCH 55/78] fix(i18n): add translation key for partner's photos (#26348) * fix(i18n): add translation key for partner's photos * reuse existing key --- .../[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5c597a4b3b..2a077697f8 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,6 +11,7 @@ import { AssetVisibility } from '@immich/sdk'; import { mdiArrowLeft } from '@mdi/js'; import type { PageData } from './$types'; + import { t } from 'svelte-i18n'; interface Props { data: PageData; @@ -51,7 +52,7 @@ goto(Route.sharing())}> {#snippet leading()}

- {data.partner.name}'s photos + {$t('partner_list_user_photos', { values: { user: data.partner.name } })}

{/snippet}
From 99f7eb4ce6e4006aa6755c27408cb0a99c0d324f Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:09:12 +0100 Subject: [PATCH 56/78] chore(server): remove redundant nullish checks (#26354) --- server/src/services/metadata.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3937237ff6..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, From 7394fa1491b883b9f4198ac734e47017e58c2793 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:11:56 +0000 Subject: [PATCH 57/78] chore(deps): update dependency svelte to v5.51.5 [security] (#26352) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 174 +++++++++++++++++++++++------------------------ web/package.json | 2 +- 2 files changed, 88 insertions(+), 88 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09b24cb3df..33c0815f60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,7 +742,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.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) + version: 0.64.0(@sveltejs/kit@2.50.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,7 +775,7 @@ 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 @@ -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.50.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.50.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 @@ -6672,8 +6672,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==} @@ -11122,8 +11122,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: @@ -14812,22 +14812,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.64.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.50.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.16.0(@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.50.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) @@ -16160,17 +16160,17 @@ snapshots: dependencies: acorn: 8.15.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.50.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.50.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 @@ -16178,15 +16178,15 @@ 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.50.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/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 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 @@ -16194,28 +16194,28 @@ snapshots: 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: @@ -16463,15 +16463,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) @@ -17049,8 +17049,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': {} @@ -17339,10 +17338,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 + svelte: 5.51.5 abbrev@1.1.1: {} @@ -17701,15 +17700,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@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.50.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.50.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.50.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' @@ -18792,7 +18791,7 @@ snapshots: transitivePeerDependencies: - supports-color - devalue@5.6.2: {} + devalue@5.6.3: {} devlop@1.1.0: dependencies: @@ -19201,7 +19200,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 @@ -19213,9 +19212,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 @@ -22922,10 +22921,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: {} @@ -23542,14 +23541,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.50.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.50.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: {} @@ -24175,23 +24174,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 @@ -24200,7 +24199,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: @@ -24213,7 +24212,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 @@ -24221,10 +24220,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 @@ -24251,52 +24250,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.50.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.50.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) '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 acorn: 8.15.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 diff --git a/web/package.json b/web/package.json index 6d1a8ce933..b45e89fc97 100644 --- a/web/package.json +++ b/web/package.json @@ -98,7 +98,7 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^3.3.3", "rollup-plugin-visualizer": "^6.0.0", - "svelte": "5.50.2", + "svelte": "5.51.5", "svelte-check": "^4.1.5", "svelte-eslint-parser": "^1.3.3", "tailwindcss": "^4.1.7", From aa02310d631117bf6e205ed7d451f419990f4a5e Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:26:21 +0000 Subject: [PATCH 58/78] chore(mobile): cleanup asset viewer state (#26300) initState was quite noisy, so I've moved some things around and made the intention a bit clearer. Co-authored-by: Alex --- .../asset_viewer/asset_viewer.page.dart | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) 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) From a0077a0f514a619d8c1a5289c926ff5876bddfbb Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:57:16 +0530 Subject: [PATCH 59/78] feat(mobile): html text (#25739) * feat: html text * feat: mobile ui showcase (#25827) * feat: mobile ui showcase * remove showcase from main app * update fonts * update code to be loaded from asset * fix ci --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> # Conflicts: # mobile/lib/widgets/common/immich_sliver_app_bar.dart --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/static_analysis.yml | 8 + .../pages/dev/ui_showcase.page.dart | 100 ----- mobile/lib/routing/router.dart | 4 +- mobile/lib/routing/router.gr.dart | 16 - mobile/lib/widgets/common/immich_app_bar.dart | 6 - .../widgets/common/immich_sliver_app_bar.dart | 5 - mobile/packages/ui/.gitignore | 15 + mobile/packages/ui/lib/immich_ui.dart | 1 + .../ui/lib/src/components/html_text.dart | 189 +++++++++ mobile/packages/ui/pubspec.lock | 154 ++++++- mobile/packages/ui/pubspec.yaml | 5 + mobile/packages/ui/showcase/.gitignore | 11 + mobile/packages/ui/showcase/.metadata | 30 ++ .../ui/showcase/analysis_options.yaml | 1 + .../ui/showcase/assets/immich-text-dark.png | Bin 0 -> 36878 bytes .../ui/showcase/assets/immich-text-light.png | Bin 0 -> 36839 bytes .../ui/showcase/assets/immich_logo.png | Bin 0 -> 5198 bytes .../showcase/assets/themes/github_dark.json | 339 +++++++++++++++ .../packages/ui/showcase/lib/app_theme.dart | 96 +++++ .../packages/ui/showcase/lib/constants.dart | 16 + mobile/packages/ui/showcase/lib/main.dart | 55 +++ .../pages/components/close_button_page.dart | 41 ++ .../examples/html_text_bold_text.dart | 13 + .../components/examples/html_text_links.dart | 25 ++ .../examples/html_text_nested_tags.dart | 20 + .../lib/pages/components/form_page.dart | 79 ++++ .../lib/pages/components/html_text_page.dart | 40 ++ .../pages/components/icon_button_page.dart | 52 +++ .../pages/components/password_input_page.dart | 39 ++ .../pages/components/text_button_page.dart | 140 +++++++ .../lib/pages/components/text_input_page.dart | 65 +++ .../pages/design_system/constants_page.dart | 396 ++++++++++++++++++ .../ui/showcase/lib/pages/home_page.dart | 118 ++++++ mobile/packages/ui/showcase/lib/router.dart | 48 +++ mobile/packages/ui/showcase/lib/routes.dart | 97 +++++ .../lib/widgets/component_examples.dart | 85 ++++ .../ui/showcase/lib/widgets/example_card.dart | 237 +++++++++++ .../ui/showcase/lib/widgets/page_title.dart | 17 + .../ui/showcase/lib/widgets/shell_layout.dart | 59 +++ .../lib/widgets/sidebar_navigation.dart | 117 ++++++ mobile/packages/ui/showcase/pubspec.lock | 393 +++++++++++++++++ mobile/packages/ui/showcase/pubspec.yaml | 47 +++ mobile/packages/ui/showcase/web/favicon.ico | Bin 0 -> 15086 bytes .../showcase/web/icons/Icon-maskable-192.png | Bin 0 -> 5198 bytes .../showcase/web/icons/Icon-maskable-512.png | Bin 0 -> 13544 bytes .../ui/showcase/web/icons/apple-icon-180.png | Bin 0 -> 6358 bytes mobile/packages/ui/showcase/web/index.html | 38 ++ mobile/packages/ui/showcase/web/manifest.json | 37 ++ mobile/packages/ui/test/html_test.dart | 266 ++++++++++++ mobile/packages/ui/test/test_utils.dart | 9 + 50 files changed, 3397 insertions(+), 132 deletions(-) delete mode 100644 mobile/lib/presentation/pages/dev/ui_showcase.page.dart create mode 100644 mobile/packages/ui/.gitignore create mode 100644 mobile/packages/ui/lib/src/components/html_text.dart create mode 100644 mobile/packages/ui/showcase/.gitignore create mode 100644 mobile/packages/ui/showcase/.metadata create mode 100644 mobile/packages/ui/showcase/analysis_options.yaml create mode 100644 mobile/packages/ui/showcase/assets/immich-text-dark.png create mode 100644 mobile/packages/ui/showcase/assets/immich-text-light.png create mode 100644 mobile/packages/ui/showcase/assets/immich_logo.png create mode 100644 mobile/packages/ui/showcase/assets/themes/github_dark.json create mode 100644 mobile/packages/ui/showcase/lib/app_theme.dart create mode 100644 mobile/packages/ui/showcase/lib/constants.dart create mode 100644 mobile/packages/ui/showcase/lib/main.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/close_button_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/examples/html_text_bold_text.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/examples/html_text_links.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/examples/html_text_nested_tags.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/form_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/html_text_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/icon_button_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/password_input_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/text_button_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/components/text_input_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/design_system/constants_page.dart create mode 100644 mobile/packages/ui/showcase/lib/pages/home_page.dart create mode 100644 mobile/packages/ui/showcase/lib/router.dart create mode 100644 mobile/packages/ui/showcase/lib/routes.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/component_examples.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/example_card.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/page_title.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/shell_layout.dart create mode 100644 mobile/packages/ui/showcase/lib/widgets/sidebar_navigation.dart create mode 100644 mobile/packages/ui/showcase/pubspec.lock create mode 100644 mobile/packages/ui/showcase/pubspec.yaml create mode 100644 mobile/packages/ui/showcase/web/favicon.ico create mode 100644 mobile/packages/ui/showcase/web/icons/Icon-maskable-192.png create mode 100644 mobile/packages/ui/showcase/web/icons/Icon-maskable-512.png create mode 100644 mobile/packages/ui/showcase/web/icons/apple-icon-180.png create mode 100644 mobile/packages/ui/showcase/web/index.html create mode 100644 mobile/packages/ui/showcase/web/manifest.json create mode 100644 mobile/packages/ui/test/html_test.dart create mode 100644 mobile/packages/ui/test/test_utils.dart 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/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/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/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/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 0000000000000000000000000000000000000000..215687af8f9aa89413a7a88935cbb2e82be731bf GIT binary patch literal 36878 zcmeFZ_dnI||2Y0Y_Q;9s5lWPmIQA$cg-DX@WN!|~cC3Shib8f~0x~}{Ey6^Q6rlGD(NybD5K@g?NUB!nGM8*t3_`xJZ z;G2oiws!CzspDN;7YGvI!u`W@&y;h6pi7X7;;l#Si7R6hPRuH0S0`_>hFi1|IPs^# z@zXPwaVt+@j=MODseg9FLP&}f~bcSub_Y6q@o7h&WaimA&%lz9IvWk~$v>_eyB z;CP{coQ`dpn-in3PrY!-MB$&`AtDY!3Na()`1U_^+8d7C?j#8RLXLlbtQ9rFL~%`X za&~h0HEi%2aZO+2#AMI>*c<%c(0 zu@FHkT=O!tx8`lli*61LmjA#@3L32R}~31sq4a87$mvhh39<5R@`hhvh65oSWEnU7OlIJW1Vpk^?YK|aOjwenHIe{a!{ z_D~F4|4jTAHZ*h(<|Ji=xeB15l`Z~91WD66{ra%b zYg%hflFao2euzA|emccJJLQYO1^GB-XEEsnYn;!7S)=S-=<&CsJJJ6hO{>zqaY;E1 z%hCP+VcH8W%Y9C8lIedW=4tb;tfRPvjlCd#j%hQh{`5o&N%`*~)G!#37->$~IZQfn z-JRy=l59R*1|;3*CI2T=8bN6sE4uELy>s!3Ai{yX`b{TXqT)+9ZDA?oyv<^=E; zLJSSkr2!pbPDhhAGuvT)t2Tjx+Lw64uqbnx5EOT|6Wb2xbeZO8aWrZNAOthBY z9L5x~&zkvh83~g|USx%PQww>gR3>J7hgrXoZPzD0JK$9-P}cC!Egq!YjE$mx@EtNp z-XEZM?KfKw!-YX!0>Q`6hlH*S2!0@A{Iz84o}<=+y`N2RrkJUxJr`T)HG?Uti&J2* zO$knO%u^!h-sHjTK&3jPd(np`G>%`nq<^f&?-{Og=XOtVBL8Zod@X=+Iro*xM+1tz z9Z^oq==eEk_6?Q8QT?bZMR!W?<^m@`dyf_dSLMLAFBsj|Px~w93t>1VToY4sU(kDW z$XN2-6@mzE8et034~fOAmi($KX~Z856q>w`>H6Tufeo?eoPGwZ&o6<$+9>|ryV92) zrt_oz)szBIMSU7VD3y};=I;prcW8dm9gz&=Mz1)CQj2+1ciZpbNq3575(Q#i=8Rml zXHsz-+Nj+2 zsmuQoTCpmA0dDKBh<~LEeg}p<SW*ypz0+`_59RxisAKcod1^|RFi7es73u2RxMsFsZH*pX4!jZZ3T z_^9GqaoafVU-g4JWKt|j%{xGxqpSbAu!T3YzSV68EvSkjQpo)~4#*dAs-Z9AUD(ptP!CyTX@@n$Pyu+GSWQIr?1)I( zYQ4~8DQnNmCDRdAKK0L@eI?0Ha_t7hFwLV}2h;zvv?w zPkM&yFR!rJ4PQcdN% zS-nhyXWV_ZbXEg(RfZX^8Z9??HFnYN(JhU$Lm5QMMJ_tLtHymWv`Fbhmt=)cY9|u? z#@48+(9_ybRm2?E$To*cL0dtC}CNcbOrv?eJ zOyIau2Z+EkXljDpDhp42>;TMX_1R?sXRw`ML0%G$N+_dGzyic9XX7-+jjWIz{B(AG zNDahpEik9|0J0h`-bqzpbAyX~Tqc%NK3j0`UD92y2Kg2QJMu^p=H2MLm@`}!O14G` zHJH7$pl-i#5Nv5gvE!_Z45<#!UMtX`v&|aieWdG`k*s~@K(!&rx46v!(!i{CqNDYd zx=F6${XHoi=uXbtV+sgWe1_%yp~8CgfAOP?n-xGkWd$Y>33MJ}a!mjyEfulCY1NnG z5u)%jINLaged+*zPusKDh5*{x=fBO9-=f<27e)e5QjKZ=2!i%+UA;G{lF*61ZFM|5 zk4pz2c?ZknZarW@003lkHME;Wb7dn#ZD>Q>0w(fH{NLMfg{&Mdiky9teqe$1y(w`P z3#2|62RNF4k6oS_6@{RP9{vKU$Kb5C%o2_>^tvws=-qL^0DxbsIt-x0_(Vzg!3aw&33*dWH*`*Kqt?Ee~~%ttMf* zqIz}>uyl4?23Rj89@?SB^7+%Nh(saYvja3FJ7}QP2^?CJJO2cCsOgSa8V+t&xivCglxD+a#|0^S;#m?KYi2>tU3EuUJaa) zdIKk&P{;d7KHl(xLeAEz{2xXDd^caljx{@&VAsRPThswDT{0os<3= zd>@?9c62Y2Ktu|A(kLq2-jc_+w9d8wI4Gv!u7#_By2j!n*lV#x@C`l_tgjw$V`q=` zXRtlu2~G(uBMMC_k8DvE;K~Up*Ux^gy#Zo=u4w_FBlfQW+fzlIDInHeJ;i2l$>3HG zb`O;^GutgTPcFe`m-jx!zyg(?!L^J~AeuX)drF~+ zAIO;gqC)uur%FCak;=ew;6b0idaXv*9=v5ohoj0R z&U3f?WsXRgv#ulS=dK68CyhGu+ub??4Tq*@ILjJfpj5N!Iz9V>s=1!;hJnTNW#J6 zd3|hcO9IJS^B!0ShOC59!j151uyJgou*5{MS$kPqBU`X{)AH?^T?G^#;zQ!qw2A5P z5J0pb(8H4#lcs|wmKvNZ6~6ITSNCw&8y)B~`-^~MoKX`R!?y6*0UY!0-($i?-dEY+ z{M4m{b)3eS0Xxh9a~d{>^_l;5UjWt*K0qb<#zxr(E$P}I_Cwj~50iImMgbplZar)4 zp?hYvV*m~A8Yn!Ka#3C8^<>>M`x9m#-C&r!WXq6NZ8E5zsdN#y24Z-V*F9EvAD4K5 z2p)ot6w6hN1#uor@c|NR-`#%}tDtW6j1{^F{O76wTH|+MHSaNhwUZ)#4Lz2c2kee~ z$SMW1IzUbFXjq-M=GDIoo=U#*dtFVH?E2U97_L=zqG8%nUfFo;=#MFhaVDVvsj>_P(_> z?zom!RyZQbS<}Af+24p{BX6r)KJDB@)A$AOvja=du%k=T?-a`ZCf~KDW|p&4l4CGh zAW|pBJODMFh3LR0Gz^Bzi1g)N^~NtppMf$iUvw7<5ABL?XDB`eb~yaZU23ERV$rRa za|iZ5xBqk4t_W*XrvgDR3y#5ge^(XI2UBXbAC}}`wli6|f1Lq!tWY6gDO3XjK!b_P8pyG_aSplO@FM(WD9D3LsKsqx(4fwMY7qEi?~EyxrZ^>>1+nk9 zmI_UR8cY<<{jL3^-oX!vpcVnZ8A6`|GRWbFoL*J!UcO}c*RoR%k+Jx;>)&JdCclLX zY#MX;Z5gH%jn!mBQaR2bh;Xl3)Pyf>$O&$Va*mTS0U@AJ&Nsn^veRtO2;K4vlLI+V z<-d!s=YM(d!5Y88q%PF@auxJ)GWsZEd5h5H+tA< zBCbk<3;8ca-N6Dr^D6EMaN55=F=XAgcytTI{lRCD8eV~;WGX4@-)Yulo+vj4sqJXSUukN4jUkc+QMm=|2KgZ)66O>yDF>(^ z&k+TMw%fh9L)olL!RAHx&w%^u zoYO{{2G>V*#9*6UK!jocfVIcP65w-KsNbE^UlTgu0!F=?-&R$@e(7s^Lyo4BY#l%^ zowfxL6|BPgvjRXjz~f%or)YBFn4H!AHo668{3_|dw`2ncqHPaUXohsF4F7O)j?aIZ zzbLER+z)uy@el7b3i^zVg>h9kAboT&W!-SkctOoo3%;}a$cxd^z(?;!{{%_1Y@~I7 zFB|MCH)9r!!P)#Q8H_l)r^nx zS(*usBd&{zLyjZ6q>UYNfcw!o0U<4V^>sc|<@C& z0Hoso;XzV@@GVtP^a$rb10#92_Pgic_ZI_<)S&ola5z+^~Pl6juD$9Rn^HPd6itbQ{c*Q2l zW6|kI@~h8xtkd$>YtroyRO(U4#<;mH+r+W_1{Sq`Q}IZ$^H7#Gij8>>Pu>QA!S0P} ztz%o0>*utVnMRi1jr-RO$(@`Wu7ku-qawd!u%p5Ja4_9ASlhm*AffR`lu>ZppdG3qpDLy1`WEU|~7DzLa7B;$7m~9)i_;_(y`3bgyY6-M_K(k^GRL!FO zfb(u2RodD+LwU>R>i)~e0OkMT@l!>Mq{V}Twod(IE$KYb=3DaACyURK$)Pz>L z0QXMIm)Tw2n{nEnudUEcKeCz_t9GaW*)0(t;TwR}{$e|7USeWew(v7aAcv&w^(Pgi z+7xpV;}v0tVI8+2n6Y>d=lJEd34b4w!S!Vu+X#!=(}i4O;&JX9pXSA=@vX+xqGJF0P;O7`#-OvXQ3$dhr7u$wWH0)&0n`Dx@q*LnmS@BF)3rs$JGw6%W2&c=iLR7 zPVBPV(gaYxN@LTipiR^cbu&u&48jI%@mM@;3Vtl!S6(sUQtx`VDh1rqlg~iTm9D>G z-e=%h)x7e)BM6Z!Fu{0REQ7b>7*f-Fo{j4sT(~FsT}@Fxq%-9CF9}Sr&<#i>VEAZoha8sf>6=FlHe8Q}$ z5&Sudy=BQWC62dqNt@^)Lf{`0Gi)(bb6S8C}2m>NN-6?cjNF<|%R%J_&be z=Pf2{F;Hkr_e8tS*bfd?_$9Mu;6c83w?3(m(7U}Q=-qx_X!4qf29C&kqWMY7?NuR% zymF=%-TY$v?OU>9PVCYm(DWrMVHX%aI9uHol(*k3uXlh;{SW&*FZ`bu4kbgXKBNi}4mr(1Fb| z_2A|2Md;@)hi@<;h<5;WPkQ+1({Sl1&=&1?Oa%c{b(ip9BNrfxq;X#Ol+w`mMf?QG zBToJ!yDEmIgC725E0oT?sf_jwwv`tNIpbcSgQG!BJRu2sZ~DQVAe122!&4~Fq)w|A zWd%IV7!SfVQ!eKs=R#`UY6Pmeyt=+#=?vP)wBNIA1-+?~eh`5IQ%VSpme21ixfV}} zwNNq{!L_*ab3rfm9{wJ|+@#+6lT>0<J!S&4RRq3C@bs{5RHg1BD2+k(U6;6D*#L0R4Zmy4&)-EUF7h$cbL>X^93pjI(U#RytT)R3VZpx__?oKDj&6a~dpWy6Q>GVO zw_!4~Yjm|B!oa3n?rYtEeOi6bJl2CmZ9_O=a_a5Q{d;k32{YtG?T%aIiB#j66p8G6p$l zQhJ-mFQ?+^WoHRAWv#p-F_OP7S^yH-zq0^pJ@X6Zd{SZ7(t3mF7ZQf#K@`MO+_{@8 zpi9^1kXKztcrZ1D5UTCD2)&P`3V!f`X{#>WbMNM3r+T;Q4OIoPOpi_73f^E(pNPZ2 z-tqIaw}uMuCXq2$>Aq}!YDSbq&YY#xRkGc`8}e1{(c(~W$MbtdJF2Q5js@I}(w{a| z?M@v_-bM#rMhtP?MqlO@*}%8Ly54Nbvng>kN{|=_YFNPaT>S31mkj6gyT2t6gs7*P zU4ts41rueJ=7qY|u5Y;!^6#0A6n8vKd|q?=ge{FZCRJ0T)o^H#Fh32MCEXp@>kBKK zdK{OVkw;>Xqzu=)IwJ7lm~uFK^vm&0lTGUfi(7Vdx6umblf5Rl(bA1?5gRXW7X&!B zof6`jc8^$#?){PIkxj!=(Fo6yN+Pk>X{+3-EARFGx;+diTqU!F-`sk3B23sl89o^0&Q*Ob7B2boQCD9Q)&)Isvtqa z6rPhj^F}VJwLOT4?XF8rIFBFAoXew`VWqX(_cCvaMS)hmOTweES9FWOrJ4SE$de#+ zgXL00RA>6pPF;Z}D_y1AH=^G|lBjv;X6F8de#1S$K#C9`w+$?sDirSG2c!mxujQmnUm1b68(PI-F(ASdZ0Gk8Y2lBC-?3w(G zf#toZsv~S!mu9}1r+F$x6*CzaB2c%B6e8ZvU?2ZH45bTU)sKYhQnr8@!R&5p%k@bz~2Pq*|sR4Z{(a zGnI_eJbHD8nv!^E zahewiyK&>z)o0zzCCs2&F%WXzK9KY2?)+KZMKOg$M}tJ?dM~f66xWBRq2kL5j@Av} zY7+*J8iSWdgSW%f?8zpFvvhi%PPkT7Dk7h`-)8bNK+E8vS6v>ZVYSz8FVNxOUVy90 z9flVYsd%ttjB44KVEe8tXkDN9Bz0mIAZrU^Y&sa|r)h?&%bmx>bJ1kC+e|ZN2}#EF zkiw1pdj1fY5v&g>$kr}Xy$@GoBHOx>Yup3c+#3(6Or+Zyb?)|f?_WdvY*4XXket2r zt4PUO#5V4IbVrk?uvv+6c?d8XaDKM~X;7PUp}$rAkT_1s{k3dgWrN9qu3>ODkH>Kr zxNn<*BE1KiH<9KM?R9<%{-$5cW$Hv!!FPC3gf%)F6k3JVIbVx^eiWu%f3~-4Qm3DgdLETJWhe;~R}~d#>U!f`b3QGj)&srxsvNc% z3lU{fi+B%kJGv)UznafrCOM(;eHSx>Ov^Y1rc=zSV^zx?%{y_J15fAzm76Y=lzjND z6eO5XqWI=rnGDv92<-+auqc&IT+P5ED0A)pBZB1LxSgQTV{hl4T4WPLNNF^?jF7%? zVUhP1Ht;y*VL_^ks5EPL<@MH)>7UOf*K8CMK}`$@4g)t>;2p7aZ*Cg%L`X1=piXJ0 z?z*#VT!KyXqPPyVCf^~W;KP+T`XyslnDx#TPJvH0#T}w;O1Y~{W`~R0uVkwDBM@+r z8-7JJI@k8zcc8GegD`H_s3$@Gu(`uopWk`g@x>&jv{lQObE&ebn!2mHG6S<_5;gugr^8y*?N<%2YrB?${cTY1?KJGSzNW2JuTb4`y*_Y_K z$o{LnlyKs*3>I#_7?>WqAN6kaGjHvEj6(N@M)nn%JS*69m~SKk`>QSgS#sKFPu8d( z_NMBdU6aZ@uqdJIrk7b=4b>-K8LHrgDOCtFzIUXB`DNt2WtH`3bH4iquh2Zcj|r|y zo)UmA*lTQ!JlW8%X;UF(@3S)gOhUj_H7|mQ7_wzKx=2T|7#n3}(utrOkIlYQ46 zA>z7RqA6Gyroq4#bV zV1A(InwQqKllq|NAYI`N#TWCaHE|?I<>kb5>A>%VqD{Z-8O<9>W8#VP zAKUPx!$Qki0$*#qT3@ zo#ta~VhXU$HKfWMp9aiIHuU@EcsK<{j?lar&s~!0{)hxMMz&HC?C{oBr_$*$7xvBg zBn=#vjVH6X>N06~;bO-Ii;}2OEhyR_ zzmv7dKNM9aAbb%kOZ<4skuJu(cWy%%$A^+)YB+c;T8#EEtCGO?s0 zecT-b(LV_e2G!P$uj>wLqq46jeh3K7p_1Z53&*E1TCJH#KdLW>{jfweRlo9Iup|Dw zecJ1HyZe>NVS3b-cAN27nP_$ZHpbHCc*32$ZTAq&#i&IPqM9;F!|R)>Y1wYZ$y5!x z!4vy6&v{viJXgvWz_yq2URAT>sdRgbchH?pnV7HjNY*cnjB#wYa+xL~VNM{mMd1BY zKIDfu|6znYXj0uZ=s6Wk0m=}}@JI}LDoVagWLLROq4g&J&zl{;1$4*3FED|I@){5@ z77QFp_i_TG8>+qhX)+kCHPp8M<_#pP3{rQ`VIfz7=o7ua!Yu{&zLAALqR}E;9TGwP z?_Fp+URuWaGz25@BJ>nk-S`_E zvlgrPR6U0>RiN9{OMYBD{%{Bz-1uk{k!m|WA}JPqjxue;Utx-D$jYVTxx>|)eb*zx z^^pTL=Uv^zwgrOhiK%)`48wOeDx#^yGh{)dlcM{O_ID6p8Ak1d_SgMxGxiTmG;H$% z=g`*6K4dEegI);1RaH?O3`Ln8YGlz=U;V4+t>yErIbGf`>Xt>@u}TP=q=mRq*vm<0J}` zZZ-o-y}hYYgZBUqeM(iI46m!m=O^b6VdAr;z#a%0XaD-}nLmQm0sB*KyW`i>x%ScE z1`QIX+jgGi%6o4*(Xpn@!T@S$>A}h#7;^Dik zyQPq0P_Vl|1-jOep0XOhd&df;s~SG9&t{T;CsHod`a!@7q@nafiqr4zexZb|*rWT4 z^Q9yPl9M;SguL$Y+6N=Ssp&8LzVh~%t>K53V`?)ft;Qz&sTk1bH6zFgsnYQ5O;KOB zf!*!yM8i|toZ_pYYrOk^WRa`{t{WCTKp)5gx!gOmFVOoFQl`OcuAY78GM)UEBl` zyPJAfQ-4yZ775T`Q`YRz4fpuU`_9Q;LjC?ziw|-=emdnQ$>$VG;}3P`gFK&9qujjT zgVxHZQq5P6Budw6o_5Vgax}kzJ~p6Mk69Kj;SrCh_C&eRb6S!_@LqeQ%C=aKmmvJ7 zW+wR!XNd{w&_A|!}X?Ogke#T#8oUQ^D5!4ebfHiu9DZT@aCWlc&%vE6uuzy+o` zGD%LBMBYZ(nc;B9D-1QwR%A=&C8eZmlN5aqCSMtRN{Ut9I%geyTgfl@2A$)2FW|&? zFi^MhvDXU$n%i1BTnR^W61-_MeGR&oS{*FJ;7Ge+Po=YxyC@?enn`Kc_iFNeZxs?sCH3AFB z5!JzFFi)kqHZt7ZT%+#`_xQ6Vh)tIxW|q=O0Hd2#J8P+nUQ5&GI@-s-#7OsqNf=aZ zdHvpaNj0XCAP3xDMu$U1f7-}ApU8Txnps)qh~+2Zcdee11#e{62`T*zWEq(8yE&35 zSk{=~(hkKWhI3#dP2ny4gzxEFli$s9DZ!DJ8nID!$+8b7mQD30&gWoK!8Boh$q$`K zBVRhbTUT7Da#~#5le}FQ&~84Cc5HetUD|hKk$4Clvhxyg6e|jo)F=Uou-vf67Shac zM6mOwer95nQl7JPnS>W3T|uh74zJ}{&bCaV26{K?Nf0y3%&@a6+aC)M2nR`)4z!AO zDBA@o9Qq=au7^3t3iXj8W<-!uRMa6 z^VxXE>#JvK0ng099q&^g=YDkA-kxl70!GQdBv8`FJ{u zALAeI!kIk3KHQj-v?Y_b_gfITK?0v@|LxXWBun_#Hsh9ytVsVKz7Az(%A5FIk}pD) zm!MCt zI8Ds6EH2e`++78^7b_2As;L=JKc!M6#0iun$XK^RY{9X~uAJ20UCIK`17!Y^VE(rI z%9r&YU`!;d@o40o_0*g%qA^ueNp6Usbn%kOCf?6Y<)pwwT0_J=#x^I~{^wtbH2ls0 zbbcVahSy;;Eh9#5W2y0mhPf?JwABFeDyr)Q&dDE*JNhCxT4r##JE%bF=+n=+u4-uq z1r5@lZU!xvt_IVc=A^Nu8eW~c_D%L?GJn@0^`iiI|86p%uzh$O4JOgb z!APG*a-ZGzaq@2T1Dy0JzCL4>N#mz0bXW0wH$OxqATD6>IS~r{;FOoR5kT5m9z!;U z8mgpcY2+{JJVT$xmmoqqU5_qs6W)bXbTy$ukVdjIE}F&-ckNM;aNJzBF`bPM=6uhku#CNPi)zs zTJdr3rAzf3n0=g$rE(-;!mnicLhfLXk9v2pRC(|DQ1|^(uf!V52ssN>%c>M_{fnh@ zMBOrU69uFGzYM3SqMm?K1Fo=2Jjusa^&4S2K23tvOQB>IgI9;7S$GhV+8 z+;?QEi*eGa%YHP2hF<-;0+KZtMF<_czK7Q$D=COAQroHO;H;J}WpeQDn_?bRE<<<6 zv(@t;jpAoinzBgn7ES|4a*eu#tABi{9#uEQ{2AIF?$KsM-QGVx~Eb zQ~6B2Dh!DAHX7Ei7(5__9`HPi!cC3ljA$``F3C~XhsG=d=}>i^+d+lP(P=s_7SBOK zB3Aq+umKW(;fpRZSD=+wKL2bP3Ef{*RFZ`4%RZ2hC$R0t@3vUiqQ}~m6!KE}JXE|2 zEk%2!6S|KNOi`L%OMRp~^@IVGy@=+adzo|)z?Z><7 zP~Q@uc|!??YZ|n@bm=ZdcsMubF4p zuw{U+Bw3f3{B=NT@HUjoAYNg1R1~6`7}fK9B%KDlRQ2BZJ>3OYfBzF;Za`lTl2;J; zASu2DcVU%x8DAb4I}fHnURM)MI^kYE!M&73FeyJsrAweuVH?5|3}AEX-VHiAtf+*u zLY}{PSQ7m?DvDoIbie#;xWS8Lg#;aG`=@R~Ji{R@WA4F}Q;uNF5DaxP40ho`-P#W6 zOV7#VBObo;0@G(Goo+&AP)R+bB^44k(+i|H%jWe0(TpyHN-+RtZU&&Z{hc$n(-<;< z_k!h3NuO(R)I0LqT@$E`xPG60SCTctX6dDT4lMMdzgL>F@nd5sK-qYJgd!?+78b`O zf9F0I&xR})m1qEw@g_23y|M{7>8~!|y`KlJa;Vfpzy0i1de4a`b-7&$An7$bZLzHtYRI5RJ@}Ykinswb6o)UrNadUFiLuRT zAd1p617kWoLz-^to7eu51h-i!@eToLZj}cOjDA=Gik$V)DhbWc?K;QK!bP;w8iFOo3a4C*R}ae=C4`tA-MPgLZO8ROa7fZ-2vtlokReo zI?fN{x%XU?0>d{`s$xM{r-xeSAGzM7`eJKb(K%u|iRIs=N_CMx;6g6IPKfI=&$Ei#e1IqBn8Z|?VxS5 zul`0QpKZ``aI$u7pvzHE;DWs=oN=r+R$q44>*wuht554UvZu!1j15^*Vz-B?TeYRV zxfxrqNQRk0?iMHhXI)?R=iAT?G=TcWxd|%_8!;nIKZMs-h>jK;53~WWveWrhh5c26 z$`D8KL{slF2ju?vi4zl>w|lL}VNXZELtRyxwC6^qJ{{FB*aeq%%Sg2QJH~9rR*zT2 zSqO7q^n>YOj@y@ZW?gM;3|X|Fz2j!%%+eN;Yu~x& z!M^KvvgdcY*IGXmdHXcQ=eS)wZh82*NGH_|MeQ;D;Pm-#NSbBhoslGzPW<_m)zX=@ zz>3%h8^VK|GbKK6Wt3*rc}+d%qv&jhzsOlc|7cmEKAQB13W#y*sIHBztsVFH6&PD- z@TBh8Hv8SEOkOdu-_4>dZ|yPZ+R0lt0P#*+I~&%1+AM0jvp1U$g7*-~1nBD&4->^B zLQ5RkTNT7$H`dzs4vQYSmV|~*l(-1F8_g&eZR{Q^!BXbRLf!Xd(D!>GGv(V)E#b(Uojx>1+sc+#APvtiBM zcAvo+`AY(yTVF*BG?j+W>Ri0D5|@P5~z9^diq}NPWB754LG;I?&Y!Gr<55+%+?Cj?KE(+ zYK~J1;d z6r0F_sDoluSPDvV)f4{Aa~LX>iS-LodRjHJ7G4oP%ct~E5{%E5nI7@B46ZI!S*`K(4-v$w3M&ixC^YB%qprL2v9 zbzpZQf9oqZ%f(O_VUpS$ez?2GP)^O&JXlBN+S9={;ZnrPPEst5F5|G#Vbt+np-EI91f7(HR-#H(E`y+TbscWV&WTL8PhR7dV)#otUYH*DF73>cN+TnFY$){;Krl#VVU zWIA@RE^gkqCXYW6$unmoMx1OhKRLN(F9~kR_)}+XGio*p@(yh zVd=;{L(|Glr>`7Cm$T;)pvwTZ}t(}qtPjhI%`9cVF;g$ScK<~DL zmstrbUb^+Fy1lBi68jeKEY7lKL7wsLv2?Unl?iN_Yi6-P&eTWFvR6g83tcPPK?SkS zi4>jVUZWZ~67|~xtS5h9wzb0>SU@0G9kzp!H_ITXED1H8XKz6qa@xnUW_10ePnvPG z^_++&(h7CEHE`d~cUP421oILhj>I4hXPx-OijpaeJv;nM<)lup>l+=~Cxe?=0*-b3 zoZp@(c5xIcPsr(dThjhRmYE6DEx2AnAW!T=mK~OP!wLCn9Qdhem71=1ls>P*=a;q6 zKn!2}zQW0(75lkT;~`=I9RadXh`kA`(jbW~PI~Hp+-MssZ=;e|ROBlm(0J)r)=TmBb!3i#Py(8noodES ztY28_8}%43ZSMo1_}&j)zY7CvERs|T;~U93k1 zf*}49r#I`9)5N1gV6Wm>pXdB+m9qC9${wM2auJ+%E)9Ox(!JALooip?em$_5BTN6X zO##x4s^mek7Wogl7pLDk8*hCF>B`CvV4S@6L^-pv^pN2lmbEv491tEdE$>|fs7tSP zHX9J``+6PrsML(Sqtp@n6N1(FEN9D4LOHnF^hxR9Inv&nmdYc4d4m(fWB1352~+O# zoJp1O@i5+9Gf5=J&m?TGW9`h2bT(aUkcsQO0L zq(Bx@)06_Zv81G5Nh{>JNUzLGKO-pxDZ6?l4hTz;oUa_;yq&Bi)$qYdPz}(v2K^CT zJL6lZ3}C$9OWMGiDZZ=?SrHgaH}v`uMhR%Cw#yr>6jBU~yrYQNlzaa)FUxQI`k$Kp zlw=IGA{{``DpFYAjC@G5I1Z>BFXHO@$U1$+XMIRoD;cN(n}vVn6Oga0d{I+88ap;V z{cO}L_r?^e>0?KGZwDe7Yso#0JdO0fruJY~v!FW8#v2oxQ$h0WEX541N4jYrLv$cKO zI`Ikv6GHbxtg4Tv4emIoA&;43C{6qTdBrP6NQLrNF4-0s+?68_zRYHUuNuN@yxk@u z1;M6#-AH6RxYIq{NZfkWq2Cvxvfb5J3wI@EyJi_yLgp{HCnH_Vo`Q_D-&UG2bl2lV z7$k{w7}I0Sw2K?{?t`hqSl{6t2gjGruz{0^O!)|H|_zf^)jibAIuJC zxw8L!U9y(Qics+7cmQAVoE%+_>y-_qyTejUK}>!S=Wzm3GvhM^kzHSF})mqzJ0?s zc_n&Lpnmv9TcMFa;_-}st@6)=WWiyHyV;gQ3V{1U9aKUHZjSANf>jynV@JB>bV~+! z*GHLNU;eJ94^D$XzzToOb4eHECOE}e51FkI6Myn4@JUVzw8#fB#cLlBjkpYEnOKQc z=p`(N09d-i)VuecGL57Q_j;xIx~bwnnAGd0u9g`U6Hvo2uYI;Gq>kSvS7_aRRw13e z`$+E~1i1+5|fL|SopgY{(6Bv7E5^f&cwg09UKAtHfeR3mpuqRn_yQITv(H}h|~CA_;qwP zh9^F9b$QPkupT2gX$8OH8XeI$+AGWay~JfB!^{m54=_r~z1_T5x~JquWoTEm>VSAD zyb>FGGKM^CShE>+S;fyMOq|*;O}fw|#MjjHPzUfK)pm%$h?bJrl+BIeJkZ5N4|UcU z5HlLuzGp?P19eDMT>W9WwZc;KE&cNbBc5$w*i2Db;bA!9A&9YJS2P6h*WZy}x%hYQ`Dg6w2B}{sh`Y`@QrN z1^BB2Z&(A8j(@&uo4gTTxLGAGTe6+#DwZsKJ|Vq&#h_YZQMU~}mPM`2Ab2X()!E>0 zRX^@_yfc#R_TmbGsg8q%gr~p+a7BW@Li_QA^S!8jcM>I4SLa7QIPIwdcicFd{mukb zzLJhVb~kdbdO_b zLODsw|Lg3%n zTShrHk@>rBJ$atz^Lc$=zwhgN|546;pZj`W@9VnW*M7gx9ABIn36{#f?nhq9(06|F zW|(4LVMLZqeJ>lr0wI!?LOC!;6=KsT8(v8w;9x4%nL1Nd7W!Y+M6Fh5{|X*~V3UuI~)w15q7VHU%d6wf<<%Jixcu@)7A9r=Q%_{JwHwY3mn{PEYLc9&vw)u=cJ5)?lE zct#&|6C?<}S&C|H#wPk!-D#6)8nj zqK_0L=^*)efM3nEl?h-{7AB_@?E;w)kR6(GddNTpNQq z06$YcHFhA$p|rW4A<4MZu$Y^X@MJ9T(h?K3aCc_)v5`_3#B9&4F6wXgviOqcz5yN8 zmfpTaY$|VH0pu1xYEkw*IKZ_i4<3BsZL8_DN*K9xG)khio&h5S8@1~}->|c1l`;g3 z4U~0TVn_m%9V-|hCD5_V zNYgc&G&ntL?qdbWN;Ma<1QdOs5;)YIG&A)GoQd`3JG_a)gUfVLls;^0wNv)Dt6v8$ zo>BH0@k8fM8(cVF+bg}0`ElEjeqxB__J^ja^(J*ARW{TpkOfTospJd zR!^9dGZHQkTn}_KaNgkN@#q2_lDj~>Vy-#D)f%# zgHe`Yug&>|U=GVUW?9q8sn~ddu0saqGwWq5B{r`1M^H#s6J{B@V?eZ|c9&o1!f>x% z$Fu%>v1^I5 zSKA#*=5(MlVt)n6 zsQvO(a=WGNqn+r8`I%~&Te#Ch%b8W6wvcv*LiHC}HtD?#Q{|wUF~?8}NyM86nT;}R z47C2#=YK>=SZ|QMEvxTAVWzeS!{~2Di>bf+G$Z+>>0$# zG$0<6i5GeY|E9BKGafc@xyBGAcjIaJ;&>{{gIa}={t?n~`!nGCwf9Zdfw#fDkSTrt z7#*t-|=`{r8p zBa9jfPn-+vEykkL(hjgF6>sGTe>nZYPklDXx$e- zuwlLKivCD6nH;{x3igDPEP#>c7$K9IhjuL(cIOP>jgIZIZa&IVN~Ly!4wv0Yp>Q8V zh5)MshDFht)U_?KCPm~i>$ck2ePTg?#O|A4g(=ge(iQUfcku?gB_C8?Jo5$op@D8y z!nbX0@5Vrve#p^k<#X^+N+mt<(5tV0CTKy;v(HF-3` z)U9@9HZctyed)1QI%ww7_AAd*(P5WApa9F;spRgx#qFoh0jXeddcus{z6)z7UgZK* z&cdg5@|!?AgoDUV^f+M80xj?tdJiE*&qMZAxctppfCLP&6-d%-MzcH1gIWOTt@DZF zUIO4`HLMTxXME!hSma@^pV`47-xgG#>2@}3KL>SypMcQTR)EC}nJNQ?JuJ}2!<(c@ zK%AZx-??kmiBP^)zE-CKWb{AcjQL;_PZhW^{b%K|?6+{!^eACkcjwUv^Ki}ICzrcR z3*zBqaEcMJ(m%8MX$_t_aOIA|=COf+Z}vJu32`!HYE&8I&fzE~sGO3hf}AqK91+`n zUa<-v86ibiDE#{l(*SV(pr;^Ds0`4C4$J`4^e02jB>pH0f+my_Gd!>-zq$`_3BS%w z+`~_f8wXpGEb3X=Y;sK2_98fmQnot&Wrf$@9?Xew+{Nl@S_-1fD_tHoL+5m2Q9q2^n3inbJJ-Ftbd<=)rt2dVS(+`b|V*6v29wWBnBR^M3MPgCvb$J#7knb$gmvU zE?S+Z42!iI)aQp!EUfoGZU=!b%ik6k39Yqug$xa)Xmj8#@Kd^cwxtoKK_QAwo{ww4 zCAuu1k!szV7QimY3msnm3^`t*OhyB>bTD4Spfe*!okvGzc5E{8!;Ga@+?ZsW$lGbD z`M=8E4_;YsImLc6Op$nsd5*Vvc`7aw<$?i-UZE>dMG=|O6~c~tn0C3UfrlI-1pY>_ z#yoPEzmi?Glx5Y-mzkhPX7hatbhbI5YKzdAL@~pEx6d^a7$B&pW$$)}<^|lCg!I`QrH5|1J0rqRtBsuT8SR!2C1+O z=YS*6=qW(l;KJU&wfg|3`PMuZMouOHE!`6aGuXWx8^Cb)FNve5F$JUZf-EK?My~`l z>JC$M9NSwO1ImTMwnJ7z*D?DLSLwO466axEA=-tFAABhgdE4C=;?0ruIgq*wnj#3XA_@(?N=LvBRF)-yAVJ|Au~kNe zz;;Ll%Fi$poQ9KdYg@H6D)flg-^zA(BK>P-kBk>e;Icdy84 zpk1XfoW+Da2WETs9@akA>K4^51eHG!noMD?H&F&1kJx+H5)Idt;VpxXKTJcCP(xLn zyoXK+2pk;OD+=zkjx(H<^Wix>-@V2qK+7q_+=Y*O^hiY`T!uB9U2YZZ0f_~p_rGs~ z$2Tn504HM!xb6W+2hHTUdIn{%$)EWkJuN0EeXs6K21?S^eO5w3a0C19&)2X*AOe7D zH#*DYeSbhAS9iNVu!q4??cyzmViOohsstm;?cK}-vL9z>&q14BXw zyxn7Y(-LS4&<=Wg^MDT*_CAD{p}GM{`-2MnjbPB#`{&yGfL#nhr4H#w?e6Y{ngaOa zpRwRW&&5B){m+MCJI!5c()=^<##2lV{Er+WQZu`Hj~fczfEQdeW@z!mv8P+BIm50IBiMg) zI%$vkrloD@B1D-%y>~MWOa;iY>i>{s%!?oOj`IImNMM1gUGD9E?;o|;32wu0tu*|H zc!-r5ijyy@9Ck0$QuIw&tO)Y>A0@L43-^D#DHjV?V1e&H3mQw<%E{5dO$WJvPCNxb z*wdWDZfNZDuNzHH;LZI1>L^gvYc}A`BZ$x*#!_=&YW}0Iv_Y{#rwfEe|JI<@&Ybp{kbtUpIlg(!a$May!3aa`&JaI`iSmzvnRA^G9%#J)=?U4{-s4{0g(w2NZXI zBOvS_)}VzQX>%0&f4>H6q<;w_a#7T({L6HEHiLr*8F_%$u(!C!%K!#Z*mYvWKy7bi zThuTp^qWlnJzFs}JlMUaU}sVDViC$?)YS9-Q6U|ayTIFbfv5f<*nmNSq1!HXhi~lx zM?DA>Jv)@RVta&`f~fU%P#|=zNZIacqp&?{mexAfF1DZytrpzV?JnGmPaCc^fjO>0 z9opYlQBer=5XsM1yKRv7T++7&a>GvAH%Js6St^1eTUW6N@=xXPwEK%afoLBSKeqoFYH@ZA4#n@ky+{7*`R zJH?U(24>j|)~yTMu~6T0=u}XsI5ok8R_N9M`h5;{DqB5G$oWvRG=m`$FA%;4S*+xXb=k4GUpc?uSO) zGS}l_CahlCV~jk6J;i}*af!Z4iRL6&3XOr5XF4ZqaRaBBIn4>MMt@eJ+$y*{^DkU9 z1`^Byu*R7`%yhe2h1j=)Y@Skh!m;u)Z|cBsd#wc z%RpCL;y41km8)Ks{+hBdg#&Z<5C)GI%jKn}n%WMHoSmw|o0`kUaU+~nigi(s(vUnf zy|Q~TgX;vlG`m4$8Xe``GtUgHIK_>q1JE4^@{^eNA^&(QqF&%$L_|`4Pb{)34srTr z6S$rAV*(;*#N>~K#oD0CXrIf6Aa-}pPfg5|Gehxx225M1A z?x6Koyu4)VTrQqz|F1*TSN!A=|2?Byx*LC2hzvZ=u?+Ty(m2hO-r6tnYGjX)E+|qL zg7L`w^&tOt_wH>`LhQRgHUT>e=ooNxQht99?H(x>Z$l4~1t1iU{umZVRHg0C-U<@r zNSOtBL6!OB)Z>Gf-y;-zcGoffS!AG~~c|%KK95kv@?J1H{mEJs0 z6pe%;-pR?vX0M#zx=-xH-n!326IS4ciT6x_q)+y;4f9xEngx6G*E7f7uWG!+$&-76 zw4uy#dZ^~JFxgME={I1?XQ=Pl3xee6f4%_O(fL;q0xKbe5FLaE9_$r&{|5cSh#1Y@ zfMQBKF7@}&_*lL=`{%|rahs|^aSSuIa*tj2Z6obP(d4cqpOLgv#Fo941X8i5*tQ@P z#%7o2LWi8>zSo_rgFu_ih(LjXL)bxx*LDR6%K{)WbYt8vr2ky|rf45hDY+_6l6-Fu zQCX(6eoC&CDaD-dk+pB~l2FT2CYYk#LoytQW;Iic>TWjrq@NqJ(Re1Jd~~gD>>(mO z!ad$*mmtJ&w3mSRTlRH9#A{DMZY>HzM%aTfNS_I`)e^cWj14A0p&RJ$;h-;r)_ua% zCt^?lQ;;}D@Oz=c@`qg^K$B66y>?AZ+LB%K*3MxB5}7|Pj2yz=-V>(n63EA@s@5o6 zrn>ip*`ON0Pwn5EM=Wp`;(*E`yZY_3kR0&7bt{yC^{(V7-GfOcD@y03U91=N9(-ZRM3P-Ej`AB7w^Ke|q# z&f%!(!L-UQNirnfbPU_J7e(Ah6Vt(4FB6qX`0ZHIj~z`rgC&$R-&=@P;+IRlf zce6Q&+S&KdY$Ol($rnKnq1&w?{w@fxp^u``AB3*oJpv56YZU4pHv#%j;Q(AolH63= z4~2bm;oi_9kF0~|^s}Alwjs>g%p9S*3lZI5{#da<&Nu+b6NPT|p-T#$IX~+C&#_ok z@k1E{LAKJMZ0UT4cKQVBm31(Zb#!=x|51I;Er6WH6w<;9bx!sax96-CUcI7S3=F3R z_iZ2iGzL^u%^D8NQOSA}K+^r>P3%~{Aj6mPtEZbGUGuZ%NmD-*Hr*6FKeQ1zKAk?0 zhsu6WV!SmBg7hS<-Z6z;3*cz_}_ zcaF^P2^j|Zn}>|_#GbR-kO!Fep8N3esj!UsB$l~EpXh%2ymtCRySLHHf)V30ib2D^ z#9!?S$+jkh+Xj0?_LGsF1U412F2}cvZ9vF~_}O}jNVPlG-WPuqxRZmb{fA?ByBDHI z`jl2(j}lM4u;yY6JIL^Wu8{sV{eC~A5-rmq&#O`Iuh9H#oGeqG%?~qA0E7CAB2P3) zC-|Esp|trJqVRBXW;Dj8M+bD}*tEo<8R`1T`7kC#j2#;L@4Fci*3N_XTV4IOfv|^F zR-GX1Y9_rW)n!O-Vtr8yoL1mb^+tcl^j2SN{atdx~8KamFl7zC$>J{*NaZE=+S^!jcjD2SStPbUyxhJ zDoMN64;EQXjg%_`dpoFE(o=RIvYMjortoho)Q<&kYYG)E7FQfI_O{&_l6I%Omx|!a z1pwp=b!|Ps>SK+f(g- z^*wIUR*S8d-8o=dYg2gSP|*@b5~&#F%-BuL*F$opnqzO0i$XZ*f;Eb!#P>oP*}ACL z8e=22?;gkKkj8-LvKu~Z*F#zoXWx!W^s87ybL<95;}SJ!!wO4V@{aBig0Ob!FAHzm z&h1XO+Q;oacl2avL9C4!QEhm|ABd zYuiy>Q9G}uAqu;Ty+@XFDz9O5msl#%N|T%&%PU6l3nd%6frgSnCHQ8Tboe5Jxjs!c z_ORA11KglF$?!EasFD^!y_#3%G|(mD6@og?OkMo>N%l~DtI-DcH_HZgPpsm3nLux* zL0!$Dqr8~+bGz?+(HX|DRo4CPmwMB5D=~jYnCwyx;fv30ABi}3;ONR;uSS@bfe&h z1vvEL?v;Q}yny_5R0q9cA1(gI&GZzPE&G;>J35zgo)_A}aI8O(22)RAfufSA38h|6cytx-GsotS@-Yg!p_Y6K{d_YwGvwoFw=@Cux7cgsMi4I}is6O`&84h^#PC|?9e<0x@g zxw3}ny}q6MFV%#YQeiKLV-z!CkwM}<*OoR@d8`a@ESav~Ale#GZd+o2->qtnbZwe& z8zZGL2$~FpmMKua?4!ocR6+LOtSi;++;Cbd$%-$;%jto%zs`yWiQF;T8oJtO8^e^+ zFI1i3ny&hnDJY*%XUPlVy#3L$UXY($s>!or8SEj!Efv6e?{GV6-aKJGKLUT}!PX3< zVpv;KfSeESbNJxfj5A5+Es7z;f)lXKCzCH$Db#rvcT&m54SVQA*firE4H1kayUYnC zb{rV^iUpj`X3|26yxdjS#}Brb#7RDk{f3N?WW1%6hQuipcU3uzVwnla1i}I}?1`O) z!=1FqSU^8PlmK3%ZbPZsiW_8J)YVT~kViuY2RV_z+RoRq_-~jn5xwf63Tk@Rx#}re zCZ~N9{+b;UKKsvBJh+mZJVJZ+%>!b(YSKF-!wE!oB*Rn>1xdU}xUc8cv2Nbziq=|@ zqNey*qoo!5%XDH$IwW7E9Ien55A;i`LfwW_l0+@Cius|-#e6ouvJ#KYycm^A zvq|hHc-~ggQk&cgZMV`~7l=-(+g@R$;fPXUJ3QHOHL_j!Ei34BfwRHACSDG!uFR@t zV_sGDt2MRO_(5w~r8@UO#nY{qvX>^Njm1pky2ZQZ(bR=&_gO2d6z?TyNuPaeB!5I> zT=<#>F^-4=E#OxNx^@3Z%GdAZ)$Z_zIC)rQT>`tL0?RGOjXNjzqbYa#sMt>6fJXBU zHTA~?_qSnzx%sDMUdW`7DT%a&uUKlcHG^iq9*`XKC6VT}?PAd)o)dq<^_*L`Fe%bszS} z_hv)L*ua#%yJy&)74;`BegAY%c5_`+4C4xU5FcJ?MMH`i` zI!R5J5S|~y4x>y-DYOD~4GjHn2_$Xc{B_;BoX5o2XuUIL5%+r8$WUCWe+^3Zb|^S9 zD<^*|q(^E+AvuI~$)RG{=z2F0i+Svk#9Q_Vhs~$p zx4E(qCD;I=d_mMY;=)HHOtD_7BKr*H;pd@d^3Vyu;J+t8AXfnkPb_9ts|VW0l9^i@ z_2^)?DOrJI)<}H$LCu=Xk?Jc+Hr=+Ni?&lSBLYA>xr~IK+K;-DB%i-c%zo?%e+J6n z6n9Plrj2J8)TCW8{BIC)w6Jk95NiJfYS@5@Ah+9-UT;|d=a42?%nfe2J+^&Hq}nOQ z#8Xe%F^E6fo9WB2VMn&#YNex|KowA|emBueU@>5-&<~N>MQ5l7BNCWUH7inU=s6oU z09bOxOfzig?O)kxd331H#r8@tr7 zRrgrcE`^We+nmRta}5I#b;Z%$MUcA~baZKGY#cV&X`zx6h1tVl{2 zpuVr|z&7g1YZ>e(z9$&(85|g%Uv`tm;6o8Lw9*htpkI%?lFx>e--BHs2-1coRpk`8 zDg4ZXrUSA|RF~PsiRHTa^l&X$7(;*xaKnsunI22|{ac^Om*EB^$<+=O@5r6o<@g!| zO>#<=uts5JHnpJ2Zv7-Q<^K{H4MlbON=b>wXVMYGxwU4v70_lTzaE123n+p7Arh!g z(m?q)ECOs}K+`mc_RmAA_x2mq4NCOd_Gyx8kGX!_7v{7dnTm4B{fTlb5;l3OoosI_ z1%e`S2bq!tKs)bJO_3F>STQ7cAHNrAvXzNo7w}vHXH#lvcE4@8H?(vaG=wtFx0nS! zu^_Xbpw3qbBkb^(g?GB~hdO*w)+oaStGNGEt|U^1577rszqhiFVoo)}Jn4QvbE?`> z=I`e(BKAunSse-coS!5T4J)kXmmN*&6z`&UZA3+0{dclYjf>nFxnT^<2n&>jc$eu8 zBQ( zOOaE@JV)F4;i`@S;s>kQl71cue{WUL2K@^suUV0Xe1ry)j?4`ZwQ+PqHE_EK zO65!$JYt)F0lt*DxXxPySm_`*xgg~i=n;2bvm>(n2=dtq0{zRwcjaGvQS1?;i2Uv@ z7_Vy4I1Vd`O77d(JiA|l~r+zM*&`Z2dmvU z0mAcExFerl8si&y041@6{d}s)$BCTbxmZFzbM7@;(q-ioLvQq1kc-Irx8vpRp24nF zJ!%=+;VRD9CpxI|fdM}^yt&aOh>YGs2X8qq@3PtZ#TcqXr(%aP0~$3{^I>l904i~t zr<}AE>7)wGIf?r${t0D!*GImoPN?jXI24Rj17kw^(GS3|WbLC{rwom-N8qMy9*jl+ zH-`<@#eYKsoL>A@nZg1i5@nqEjE=U*BeqW>y+a_t++F%L<@#%2(mSeCm}MlhK|-?n z14x2xo)Z|tw5zXteNHJkxZlx`3N9OKFFMTmPTUU{pY;k|)Yj$yhAF`fFjG4O!ey5v ztm0+p*9*|84Dw@M&uh~%JFr`)o$4dYSS(a0!5zsEx}z811c;(!Ii3?g#&rMwk72K; z>T4yL55??z!4WW_}9=ISmWRG7*$3y{&Tt&YIa%HNhDDXfer@9Xq9Apdqt_-$K9X zMRrg8ixK8mQH%=T`FCu#NdamWG1j|ZI^pe9=x5EjrzU;83XTZ9{?c--?UK)90sJyo8S6I zi0lu*fpXC(g_JZIr2`LlRz1x08OnvSFIR`SF--o@`yEJXokon#W9|w7FtK~;07Re9 z0jg1>i0i?l&&?CDr%+bB`Og^W#7n_NY7Sh3T_b8y8$F2@790)?D4LyLm=0e;|L9)- z&iLNH7U(GlJCCm|YgM8QN0)0nJbn?aK9B{^1PD>2zkhiyb_SXjttM#20&uuRm|TdD z<@=YzZtr_{ZRs|xY6Ib*OGS)|OpH2Dkc&?Nd__PziPF@gN0xbQz^FETOoTy?k6hLC z5!t7hB#`exqvH zW!(NcRra0wyd^aQit<@?hb;+HEuezRVA>_av0DqRnbhL8PX>(^@&va()0JUt z`%?Z*?AI%09j=y7gtGF`ZTppVCK(>|4Ntz%U6<%f4vhv<8JOGzazstiWZzZQ{B>x; z$_KagQMXLh@YNgI_Kjn)o10OVZ+7T{D5od?K<+DmYG&|jyEe1Go>}y{dT(k)e=l;o z@J_4$_O-L^`h9SkN%hOu3oIhRZ(H5KL7>iG*$fefJgsUoA2`mnE8W zo$m{T4!)zY+DX}d0(}Nl#kVGG9bQ4jBZ8#(dPmws)@Rv52PAWrW3(2GlR3$V9t!3| z*|E&H2bh2dk>)F7Lt@J&wRAhCpWJ$)?*M!pye_~L6coH{u!ud%^ZHb~c|5T@TK*m4 zKA)=5+8$Av!#m+(fmZotKi2;q4RH4d_eEE+Zf?Yju6{~_eFEe5`Q3#~_E_KNG*tU} z+J7`u+1chB7LK)a5skaf=kcqn&!=Wb%2OZKgZ{JOd*zOw;>`Z4H8RiwPF(ol@P-#K zrTEMF_EYF#^{-{JvE%JId54UW*B~0*yliLjg(!XG1`~Y*>J0c%Hg^e%UV92?ScuU) z=j9*#V%yT_Pt7855?Td^Rhpq14gIPHu5kxWM`)P#_{%)`c?K2|elXoI4DN2fN9+_p8f>@Vz>#W8BzIN_Fd6;lTU6 zwVWlqs&1+36KS8c^3YNOmU<(#>e&eupHEc*>?SYZ_xn6=L3B{)+rbA%Jp*~FuUJK@ z7JE*!CfOGlK1|HlVmWW9sByILQPE#xH+=}DQJi?*uR7*1eCSz7s%^|4ha9KCb=A2v zy4LnsH-Dye35o8O(0;B4`e)V_V~Y8YIHQW>?BdR+9i(31ktDZ$Y^|_q39I4O5&GA` zc!Xo6k?DkA83kjSux?fH9sG09gdCz5@k43RFHY%!+r-w+<=*M|+F4_HdQjxR@`rk# z?Ewr|!*#4#K_j7Zm6g!5d+Vj!UiQaGq=l3|$;OX0!Nv@Ohua0NDw)5Piai5$Re*~j zcW-oHOx$UvO0(>lE6!n0pqTOA!BRfR7o(QsUNF+x^@L?@D}PFxhhM3p&_bTL?9{qx ze#l^tNyI3Wmu0p5-$%>zW?s#W!xcmFm}Bt}=cVu0g!xU!K#Eb74`byzylTwIxozn}-FKOlx9@Qc zp>?iIg^QK&>sHSA+v2}c<$`QH(r_GpM$d1Tym>&;oM)HJKNw5)h30|w^B)24Ewe@< z<+$9~Ka}22G>fw^;MjKa67}8>+>pf_|J8)p1vv{&KC|-{C4I1bkhxU{$?!dy8>y z(?YqgC9--rWdgohLhEQ{p~vaJdN*PcjjNSyKaU6{?CU(qXnrv*cRC?d`1xHjZsiu2 zhWgmcx1Rq<*bd7Q$zbAx+QIpJ@?}%TG8IPk_dm|c_%jlfnPi8a5}C6YY%g`NYhiOW zr(?wMN;r+N@s?7B7JZ^rETJ*H=kw+G3ombOZcd#g;WcyTFrU)T_07dDr~bO1EAOM~*Bd_WB#mWSzAh0dXMsG$cq5L0ciLcj^TtdLsGgN`*R-cudcv$xqc1 zJD)zSfVujQ?fy4|D9=bZ88NCB(KA`69a$ami+FIB{B?A%IPpZO_$7u;3xqLrg(y>S^sx~Om zX3%~En+Nn_TiD{vb6X$Jygj&bfI>n2-jWiKOYB!~hjzYUor7P=!4*&Mm_U}IiT&C- z;_Vpri6DC(4bTzCr=(U5UC8$LeHBk59h2&FeZTCG-9gO#v@%DeJBR3*LaT0*6pdEm z6~^5D+d9KieT2{QT;43sjYum_!v@KU$%sub0np)FHjM^K^7*Dcwd zjjElm_DO+|%`;Wl)STX_9kZP=30`p@>}Y0YidQVMaD z*-2Jwbx(@8gaue*(?{2HztM}V%cro>{-Sr*W1eQCF;MfVP{$>ZIwWMAYvBYf-qFr{ zHHEFqcD=W;Wlqh{>~>QtV?$YZU@-pBYb71A-t6r#L7f)Pmv8_~=)h9#hR`Nnf_>W> zRAdavHzpf>vDowS#JLRNFH|H|Pq+K4s$x7-gO<5DQq<9*U2OhhIw3)hw%ZZsYUP!gyFiJ2b2Svxhx*O;qRjtjG0k2faw5OLWnCoubVt zL1oWMO}D-{@~OSzimXys+=E(;sp-Mg`3HA-PRV?5!eie3XeN0T`>cY@dgRQyq^n=5 z`~}q~Y>C1Ly$`_|et)eza*`~(@%{S}AzRI^9Z?Bwm zR=jxB`o&Xq<)P;v?w(k7$oV1kKS{Lic}-Ba#7m=FOuZ=WFh+uu&r~Pj z`i>}0a&cxcM1s`4geqXKIN9jFm?t0Gw-jU>hPipS3;$}Y+)3>|oD6O}-e)fw_6+ORdiu(v*^$1?53BVG_uh%eBNr2Jk6_R)#i1!KI8 z`L+0>&ctwAp_*os9&ogY!?+bC+4(f!8 zitXFr$TZ^*sRfyN8PQ_Gor*4WTjq~MJkgBx=y1rRE~bqr2{|u(rvilgV%T3J7-#ar zc>M8>s3*9GhJs{U3HQUO>D>(TTkR+YKhVu4kku4ExyRXgG}*LOa@myIB%QCA+Rjw| zc>3~dnn{hWTblmG@v`Fb|qgt-k z(P>kPjg+~w@@<$0MMm%ZuUT?t=Nk2pi|HGQ&i9&-_*90P82*rPw)RY9ez+L&jZ7kg zLFg_=?Awjg&g9mdiwucoOVt__Wuc6Pi#bmc>@7x)xqn$Mq!YxK82+$hq=sc%aJh{@ ziLp_tT*OK2(?!e`m-}%emCHu8fs+B-LjJ|`CJ%ycQoha>+uHw=!tu#Xg2QKtOD|3@ z(@}j{c4(&m@^DBVzb>9pDM#V_tltU0UaI1J=)-Fl!p@Gqw}PjY80OD+S>xBsoSYBQ zzcizqVoz|sxWi&p%$F}TYe-5gb?}}FbDKcNgKyUOiL*Dowv~dpF`h!neq6rx5?#94 zp0rNIQP%ivSut9|+a35w{R;y`DFHnz0WZHXsU`Tlw(}C_V!X)M(f;l<@r}nO_epLG zBp(T08BZ{kW307K@*xzlOsAt>5d3jJBV88`L1iiKye%w8MJ8+mZit3?mAwrV%I#hPX@k_KrQ97lr zf}XFt-vQg?pkS(8qq`x(;DcjuPV`uf(q@O^8i#7T(qH63s1oa?a0_dQ^j=hhW= z-losdN@oVu+`EJm%yeWKjFWJD^(nQM{mWDkFUIqFqut2Ywe52@_Rj=vM!klxzT%MA z&-42lEWP^cV#g4l3OZqT)B|Zo9%< zSc;)g{rq|%Jl}E*&hJ=yI&EuzkU8slm9RGv?bm4sS{D0|4D+4Iz*{=cj9cPrQk%Iq z816dIEWgSuE(sZM&H23YC>1c`!}NFaR?B#qdcSSwH$581tzu|B&Q{6#&SsHJyQ3U0 zMSIeef7FV@h`DUrHY#o(acUlhB|pCB*87~qdoTs!{d@@)L>&b+LBXL~DW)onRe^ep z{3$&@u6W`a-sGv?d%Zu|(qnAWEpov|MT=#0qrc*f)!Ms3&L_d##w^=+?$;1#K7N%~ zdoD;1_aeQ=R$tw2dzo;cyLLy$ncvis-}EzkW_Ex5>cF+5mdWxSikFtA=KPerNu}@f ztw~iDFUfF*;(`LZuMH3T+1=sXnpmiyT+Y9^p?i_FtkR~l*89i>%iKf8EL&k(-TBj# zAzCT&DvaJg8a7J*nsPIgPUx3s0|XDY)~rc2@z|$(uP#*N_1_|)T9#kAOws9Es zdA|906O(UwT3=6|s3Z=vo$@R0f`_%|k#+ft-MngBYfh>Jqw=2jhT?nP*d2L7^OC39 zYrPlOV1svQL%bPVJ*+&vab3j68_Aa>J1ufU@72W6esU5ps|c@f%T!ZSJc&|O*6KPg zK>c9AWkYetVL$N?cM>$QYh#-ai9C|eOSkCVT(Npl*)uQV!MYsr>vWY<#cBmR*@ev5 zVRs?#@#1V;WxgEI%36x-X8t|T#Ty2!)O})~(|OKsru}N(np^b>)zb+N?1?OABn#_O zMpWagq@L$)-0VzduAdFSRPJn5sw~l_gvvXGB}S7uyruYj(0b%?eqs^AWHNU=$ZgrB z=G2VGDL&nRuk^_-ByCq?d>=j$&%;>Tw)?+3`u9E@WPafG!bsul$9Y<5=2y)?tc(x_WP%fp@y5SCpjz*AW@j=M6|DEQCPC5kz(55o9EVJI?1o_PH zi=L0>-uvmJ1&PlJ2%-dqtlyBTU+#KdbAADx75d(-S2SK(l{lA@|Y J&Uu3e{~y~#t)&0} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..478158d39c354a06182383e0e098d8583f470494 GIT binary patch literal 36839 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7ec34e9e53c53af721fe70e6177fd4f8e75625f0 GIT binary patch literal 15086 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 rx2V}n>sIfhp;D>8Y*wmoJJ=30I)9_=xAE{ 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a7220554bced1f2d0702d9911829b9272716d509 GIT binary patch literal 13544 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4e642631a3ad61c030b0fca2bb1f2b92efd16d34 GIT binary patch literal 6358 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 Y2jUXJ7`x~Fp8x;=07*qoM6N<$g8y~;Bme*a literal 0 HcmV?d00001 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))); + } +} From b2a510efee57dc33c7e57f88636be121b1953d40 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 13:52:21 -0500 Subject: [PATCH 60/78] refactor: remove unused actions (#26363) --- web/src/lib/components/asset-viewer/actions/action.ts | 2 -- web/src/lib/components/asset-viewer/asset-viewer.svelte | 1 - web/src/lib/components/timeline/TimelineAssetViewer.svelte | 3 +-- web/src/lib/constants.ts | 2 -- 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 19cc5afa8d..812047e350 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -8,11 +8,9 @@ type ActionMap = { [AssetAction.TRASH]: { asset: TimelineAsset }; [AssetAction.DELETE]: { asset: TimelineAsset }; [AssetAction.RESTORE]: { asset: TimelineAsset }; - [AssetAction.ADD]: { asset: TimelineAsset }; [AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto }; [AssetAction.STACK]: { stack: StackResponseDto }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; - [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset }; [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; [AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto }; [AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8c9bb4156b..8811d46ff4 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -339,7 +339,6 @@ }; break; } - case AssetAction.KEEP_THIS_DELETE_OTHERS: case AssetAction.UNSTACK: { closeViewer(); break; diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index e26d969858..628c501397 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -127,8 +127,7 @@ const handleAction = (action: Action) => { switch (action.type) { case AssetAction.ARCHIVE: - case AssetAction.UNARCHIVE: - case AssetAction.ADD: { + case AssetAction.UNARCHIVE: { timelineManager.upsertAssets([action.asset]); break; } diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 53a73d471d..30ae796136 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -6,11 +6,9 @@ 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', From a43680c8b1a281a05c8e6d52ca6fe41473364988 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:18:44 +0000 Subject: [PATCH 61/78] chore(mobile): simplify drag logic (#26291) We were manually tracking whether gestures should be blocked, which was a remnant of how the old code worked. This is no longer needed as we have better heuristics for knowing whether we should skip drag updates now. Co-authored-by: Alex --- .../asset_viewer/asset_page.widget.dart | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) 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 3dfc37e2f3..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,9 +135,7 @@ 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) { @@ -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; From 8eec3c810ed952db4648c7666a375dcef88f6dd3 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 21:21:03 +0100 Subject: [PATCH 62/78] fix(web): single select scroll behavior (#26358) refactor(timeline): remove single select scroll behavior --- web/src/lib/components/timeline/Timeline.svelte | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 9c07bff828..04f833e87a 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -419,11 +419,6 @@ } onSelect(asset); - if (singleSelect) { - timelineManager.scrollTo(0); - return; - } - const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0; const deselect = assetInteraction.hasSelectedAsset(asset.id); From 1d11106dd09cc07033dd95b59f4f1d82fe1942bb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 15:27:30 -0500 Subject: [PATCH 63/78] refactor: add to album (#26366) --- .../components/asset-viewer/actions/action.ts | 3 +- .../actions/add-to-album-action.svelte | 44 ---------- .../asset-viewer/asset-viewer-nav-bar.svelte | 13 ++- .../asset-viewer/asset-viewer.svelte | 14 +--- .../memory-page/memory-viewer.svelte | 7 +- .../timeline/actions/AddToAlbumAction.svelte | 54 ------------ web/src/lib/constants.ts | 1 - web/src/lib/managers/event-manager.svelte.ts | 2 +- .../lib/modals/AssetAddToAlbumModal.svelte | 27 ++++++ web/src/lib/services/album.service.ts | 82 +++++++++++++++++-- web/src/lib/services/asset.service.ts | 20 ++++- .../lib/stores/asset-interaction.svelte.ts | 9 ++ web/src/lib/utils/asset-utils.ts | 76 ----------------- web/src/lib/utils/file-uploader.ts | 4 +- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 7 +- .../[[assetId=id]]/+page.svelte | 7 +- .../[[assetId=id]]/+page.svelte | 10 +-- .../[[assetId=id]]/+page.svelte | 9 +- .../[[assetId=id]]/+page.svelte | 16 +++- .../(user)/photos/[[assetId=id]]/+page.svelte | 9 +- .../[[assetId=id]]/+page.svelte | 16 ++-- .../[[assetId=id]]/+page.svelte | 8 +- 23 files changed, 202 insertions(+), 242 deletions(-) delete mode 100644 web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte delete mode 100644 web/src/lib/components/timeline/actions/AddToAlbumAction.svelte create mode 100644 web/src/lib/modals/AssetAddToAlbumModal.svelte diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 812047e350..df57d73a7e 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -1,6 +1,6 @@ import type { AssetAction } from '$lib/constants'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk'; +import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk'; type ActionMap = { [AssetAction.ARCHIVE]: { asset: TimelineAsset }; @@ -8,7 +8,6 @@ type ActionMap = { [AssetAction.TRASH]: { asset: TimelineAsset }; [AssetAction.DELETE]: { asset: TimelineAsset }; [AssetAction.RESTORE]: { asset: TimelineAsset }; - [AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto }; [AssetAction.STACK]: { stack: StackResponseDto }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] }; [AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto }; diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte deleted file mode 100644 index cf8ba15024..0000000000 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - - 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 93ce2f01e3..e3b03c3a7b 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 @@ -3,7 +3,6 @@ import ActionButton from '$lib/components/ActionButton.svelte'; import ActionMenuItem from '$lib/components/ActionMenuItem.svelte'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; - import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; @@ -102,6 +101,7 @@ Unfavorite, PlayMotionPhoto, StopMotionPhoto, + AddToAlbum, ZoomIn, ZoomOut, Copy, @@ -129,6 +129,7 @@ Unfavorite, PlayMotionPhoto, StopMotionPhoto, + AddToAlbum, ZoomIn, ZoomOut, Copy, @@ -181,14 +182,12 @@ - {#if !isLocked} - {#if asset.isTrashed} - - {:else} - - {/if} + {#if !isLocked && asset.isTrashed} + {/if} + + {#if isOwner} {#if stack} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8811d46ff4..c011a5e466 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -167,9 +167,7 @@ }), ); - if (!sharedLink) { - await handleGetAllAlbums(); - } + await onAlbumAddAssets(); }); onDestroy(() => { @@ -182,7 +180,7 @@ syncAssetViewerOpenClass(false); }); - const handleGetAllAlbums = async () => { + const onAlbumAddAssets = async () => { if (authManager.isSharedLink) { return; } @@ -303,10 +301,6 @@ }; const handleAction = async (action: Action) => { switch (action.type) { - case AssetAction.ADD_TO_ALBUM: { - await handleGetAllAlbums(); - break; - } case AssetAction.DELETE: case AssetAction.TRASH: { eventManager.emit('AssetsDelete', [asset.id]); @@ -369,7 +363,7 @@ const refresh = async () => { await refreshStack(); - await handleGetAllAlbums(); + await onAlbumAddAssets(); ocrManager.clear(); if (!sharedLink) { if (previewStackedAsset) { @@ -441,7 +435,7 @@ - + diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 20d43f1974..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, @@ -328,6 +328,7 @@ assets={assetInteraction.selectedAssets} clearSelect={() => cancelMultiselect(assetInteraction)} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} - + diff --git a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte b/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte deleted file mode 100644 index 6dce0ce084..0000000000 --- a/web/src/lib/components/timeline/actions/AddToAlbumAction.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - -{#if menuItem} - -{/if} - -{#if !menuItem} - -{/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 30ae796136..389ebbefab 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -6,7 +6,6 @@ export enum AssetAction { TRASH = 'trash', DELETE = 'delete', RESTORE = 'restore', - ADD_TO_ALBUM = 'add-to-album', STACK = 'stack', UNSTACK = 'unstack', SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset', 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/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/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 fc3911e45b..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, 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/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 38817650c1..b9e5e166dd 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 @@ -14,7 +14,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 UserAvatar from '$lib/components/shared-components/user-avatar.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'; @@ -45,6 +44,7 @@ handleDownloadAlbum, } from '$lib/services/album.service'; import { getGlobalActions } from '$lib/services/app.service'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; @@ -438,9 +438,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {#if assetInteraction.isAllUserOwned} assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + timelineManager.update(ids, (asset) => (asset.visibility = visibility))} /> - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 74993cb64b..b13146aab6 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -2,7 +2,6 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.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'; @@ -17,8 +16,10 @@ import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences } from '$lib/stores/user.store'; + import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -68,10 +69,12 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + timelineManager.removeAssets(assetIds)} /> - + diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index c9ac99d10f..3cafdcbc5b 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -8,7 +8,6 @@ import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import Sidebar from '$lib/components/sidebar/sidebar.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'; @@ -27,10 +26,9 @@ import { foldersStore } from '$lib/stores/folders.svelte'; import { preferences } from '$lib/stores/user.store'; import { cancelMultiselect } from '$lib/utils/asset-utils'; - import { getAssetControlContext } from '$lib/utils/context'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { joinPaths } from '$lib/utils/tree-utils'; - import { IconButton, Text } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, IconButton, Text } from '@immich/ui'; import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -119,8 +117,8 @@ assets={assetInteraction.selectedAssets} clearSelect={() => cancelMultiselect(assetInteraction)} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} - + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - cancelMultiselect(assetInteraction)} /> + import { goto } from '$app/navigation'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; - import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte'; import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetVisibility } from '@immich/sdk'; + import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui'; import { mdiArrowLeft } from '@mdi/js'; - import type { PageData } from './$types'; import { t } from 'svelte-i18n'; + import type { PageData } from './$types'; interface Props { data: PageData; @@ -44,8 +45,10 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {:else} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3c18b866c1..d28847068b 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -12,7 +12,6 @@ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.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'; @@ -31,6 +30,7 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { getPersonActions } from '$lib/services/person.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { locale } from '$lib/stores/preferences.store'; @@ -40,7 +40,15 @@ import { handleError } from '$lib/utils/handle-error'; import { isExternalUrl } from '$lib/utils/navigation'; import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk'; - import { ContextMenuButton, LoadingSpinner, modalManager, toastManager, type ActionItem } from '@immich/ui'; + import { + ActionButton, + CommandPaletteDefaultProvider, + ContextMenuButton, + LoadingSpinner, + modalManager, + toastManager, + type ActionItem, + } from '@immich/ui'; import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, mdiArrowLeft, mdiDotsVertical } from '@mdi/js'; import { DateTime } from 'luxon'; import { onMount } from 'svelte'; @@ -455,9 +463,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index bef36d5602..dd2080a831 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -4,7 +4,6 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.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'; @@ -36,12 +35,11 @@ type OnLink, type OnUnlink, } from '$lib/utils/actions'; - import { getAssetControlContext } from '$lib/utils/context'; import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { getAltText } from '$lib/utils/thumbnail-util'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetVisibility } from '@immich/sdk'; - import { ImageCarousel } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui'; import { mdiDotsVertical } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -130,11 +128,12 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {#if isAllUserOwned} { + const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => { cancelMultiselect(assetInteraction); if (terms.isNotInAlbum.toString() == 'true') { @@ -248,6 +247,8 @@ + + {#if terms}
cancelMultiselect(assetInteraction)} > - {@const Actions = getAssetBulkActions($t, getAssetControlContext())} + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + {#if isAllUserOwned} - + diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 868f23bf55..fefd8dd032 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,7 +9,6 @@ import Sidebar from '$lib/components/sidebar/sidebar.svelte'; import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; import Timeline from '$lib/components/timeline/Timeline.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,12 +24,13 @@ import SkipLink from '$lib/elements/SkipLink.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { Route } from '$lib/route'; + import { getAssetBulkActions } from '$lib/services/asset.service'; import { getTagActions } from '$lib/services/tag.service'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { preferences, user } from '$lib/stores/user.store'; import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { getAllTags, type TagResponseDto } from '@immich/sdk'; - import { Text } from '@immich/ui'; + import { ActionButton, CommandPaletteDefaultProvider, Text } from '@immich/ui'; import { mdiDotsVertical, mdiTag, mdiTagMultiple } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -120,9 +120,11 @@ assets={assetInteraction.selectedAssets} clearSelect={() => assetInteraction.clearMultiselect()} > + {@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())} + - + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} From 3d4dec0cca011fb5e3aa50bd5ec73a664d08b73d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 15:42:37 -0500 Subject: [PATCH 64/78] refactor: asset actions (#26367) --- web/src/lib/components/ActionButton.svelte | 15 ---- .../album-page/album-shared-link.svelte | 3 +- .../components/album-page/album-viewer.svelte | 3 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 90 +++++-------------- .../navigation-bar/navigation-bar.svelte | 3 +- .../sharedlinks-page/SharedLinkCard.svelte | 3 +- .../[[assetId=id]]/+page.svelte | 10 ++- 7 files changed, 34 insertions(+), 93 deletions(-) delete mode 100644 web/src/lib/components/ActionButton.svelte diff --git a/web/src/lib/components/ActionButton.svelte b/web/src/lib/components/ActionButton.svelte deleted file mode 100644 index ae8d1199e0..0000000000 --- a/web/src/lib/components/ActionButton.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -{#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 @@ - +
- - - - - - - - - - - + + + + + + + + + + + {#if isOwner} {/if} - + {#if isOwner} @@ -179,14 +133,14 @@ {/if} - - + + {#if !isLocked && asset.isTrashed} {/if} - + {#if isOwner} @@ -249,10 +203,10 @@ {/if} {#if isOwner}
- - - - + + + + {/if} {/if} diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 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 @@ - - -
-
- 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 + +
+ From 5c7c07a09fb19ee070b4e28c45a4aae840770395 Mon Sep 17 00:00:00 2001 From: David Baxter Date: Thu, 19 Feb 2026 13:09:05 -0800 Subject: [PATCH 66/78] perf: add indexes to improve People API response times (#26337) Add SQL indexes for people search endpoints --- .../migrations/1771478781948-PeopleSearchIndex.ts | 15 +++++++++++++++ server/src/schema/tables/asset-face.table.ts | 5 +++++ server/src/schema/tables/asset.table.ts | 5 +++++ 3 files changed, 25 insertions(+) create mode 100644 server/src/schema/migrations/1771478781948-PeopleSearchIndex.ts 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() From 7b4cabc2c68b79d9e836fcac7f0ceb72f2568e3a Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 19 Feb 2026 22:10:55 +0100 Subject: [PATCH 67/78] chore: update task commands in web/mise.toml to use pnpm (#26345) * chore: update task commands in mise.toml to use pnpm * Replaced direct commands with pnpm run equivalents for consistency. * Added new tasks for type checking and Svelte checks. * Removed deprecated svelte-kit-sync task and adjusted dependencies accordingly. * mroe * chore: update mise.toml to add demo server task * Removed the direct IMMICH_SERVER_URL setting from the environment section. * Added a new task for starting the demo server with the IMMICH_SERVER_URL environment variable. * Ensured consistency in task definitions. --- mise.toml | 5 ++--- web/mise.toml | 50 ++++++++++++++++++++------------------------------ 2 files changed, 22 insertions(+), 33 deletions(-) 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/web/mise.toml b/web/mise.toml index 5aca2d737d..00b2b30c6b 100644 --- a/web/mise.toml +++ b/web/mise.toml @@ -1,56 +1,46 @@ [tasks.install] run = "pnpm install --filter immich-web --frozen-lockfile" -[tasks."svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "svelte-kit sync" - [tasks.build] -env._.path = "./node_modules/.bin" -run = "vite build" +run = "pnpm run build" [tasks."build-stats"] -env.BUILD_STATS = "true" -env._.path = "./node_modules/.bin" -run = "vite build" +run = "pnpm run build:stats" [tasks.preview] -env._.path = "./node_modules/.bin" -run = "vite preview" +run = "pnpm run preview" [tasks.start] -env._.path = "./node_modules/.bin" -run = "vite dev --host 0.0.0.0 --port 3000" +depends = [":install", "//:sdk:install", "//:sdk:build"] +run = "pnpm run dev" + +[tasks."start-demo"] +env.IMMICH_SERVER_URL = "https://demo.immich.app" +run = { task = ":start" } [tasks.test] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "vitest" +run = "pnpm run test" [tasks.format] -env._.path = "./node_modules/.bin" -run = "prettier --check ." +run = "pnpm run format" [tasks."format-fix"] -env._.path = "./node_modules/.bin" -run = "prettier --write ." +run = "pnpm run format:fix" [tasks.lint] -env._.path = "./node_modules/.bin" -run = "eslint . --max-warnings 0 --concurrency 4" +run = "pnpm run lint" [tasks."lint-fix"] -run = { task = "lint --fix" } +run = "pnpm run lint:fix" -[tasks.check] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "tsc --noEmit" +[tasks.check-typescript] +run = "pnpm run check:typescript" [tasks."check-svelte"] -depends = ["svelte-kit-sync"] -env._.path = "./node_modules/.bin" -run = "svelte-check --no-tsconfig --fail-on-warnings" +run = "pnpm run check:svelte" + +[tasks.check] +run = { tasks = [":check-typescript", ":check-svelte"] } [tasks.checklist] run = [ From e8bedfdb7a981f6ca3b8be6d00a3c46a0863ce91 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:19:19 -0500 Subject: [PATCH 68/78] chore(deps): update dependency @sveltejs/kit to v2.52.2 [security] (#26371) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 107 ++++++++++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 54 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33c0815f60..6e25b7c820 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -742,7 +742,7 @@ importers: version: link:../open-api/typescript-sdk '@immich/ui': specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.50.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) + 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 @@ -863,13 +863,13 @@ 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.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))) + 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.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.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)) + 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.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)) @@ -4317,8 +4317,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: @@ -5316,8 +5316,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 @@ -14819,12 +14819,12 @@ snapshots: node-emoji: 2.2.0 svelte: 5.51.5 - '@immich/ui@0.64.0(@sveltejs/kit@2.50.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)': + '@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.51.5) '@internationalized/date': 3.10.0 '@mdi/js': 7.4.47 - bits-ui: 2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.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) + 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.51.5 @@ -15230,7 +15230,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 @@ -15239,7 +15239,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 @@ -16156,13 +16156,13 @@ 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.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/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.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/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.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: @@ -16178,20 +16178,19 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.50.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/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/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.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.51.5 @@ -17361,23 +17360,23 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-import-attributes@1.9.5(acorn@8.15.0): + acorn-import-attributes@1.9.5(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-import-phases@1.0.4(acorn@8.15.0): + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: - acorn: 8.15.0 + acorn: 8.16.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.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: {} @@ -17700,15 +17699,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.16.0(@internationalized/date@3.10.0)(@sveltejs/kit@2.50.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): + 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.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) + 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.50.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-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' @@ -19074,7 +19073,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,8 +19305,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: {} @@ -20343,8 +20342,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 @@ -21505,8 +21504,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 @@ -21783,7 +21782,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 @@ -23233,10 +23232,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 @@ -23541,14 +23540,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.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): + 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.51.5 optionalDependencies: - '@sveltejs/kit': 2.50.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/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: {} @@ -24276,10 +24275,10 @@ snapshots: 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.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-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.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) + 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.51.5 transitivePeerDependencies: @@ -24289,10 +24288,10 @@ snapshots: 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 '@types/trusted-types': 2.0.7 - acorn: 8.15.0 + acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 clsx: 2.1.1 @@ -24469,7 +24468,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 @@ -24840,7 +24839,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 @@ -25242,7 +25241,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 @@ -25332,8 +25331,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 @@ -25364,8 +25363,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 From 01050a3d54084fb8eed585ec9188061c3be4006d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 19 Feb 2026 16:50:39 -0500 Subject: [PATCH 69/78] fix: pin code reset modal (#26370) --- web/src/lib/modals/PinCodeResetModal.svelte | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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} From 7461479f6025c5dc709165b0aa07dcbd82c15458 Mon Sep 17 00:00:00 2001 From: dotlambda Date: Thu, 19 Feb 2026 14:58:25 -0800 Subject: [PATCH 70/78] chore(ml): remove unused dependency ftfy (#25529) --- machine-learning/pyproject.toml | 1 - 1 file changed, 1 deletion(-) 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", From a1839b367648cc45344e9988a607d776e61ce721 Mon Sep 17 00:00:00 2001 From: Benjamin Nguyen Date: Fri, 20 Feb 2026 03:07:26 -0800 Subject: [PATCH 71/78] fix(mobile): Reset "People" search filter chip if no selections are made (#26267) * filter by tags * reset people search filter chip if no selections --- .../presentation/pages/search/drift_search.page.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 45a14643c9..0d9bba146a 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -150,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() { From 19da6553904fa7f04505830540b3ab045b7b2826 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 09:16:42 -0500 Subject: [PATCH 72/78] fix: exiftool-vendored.exe (#26393) --- .pnpmfile.cjs | 16 +++++++++++----- pnpm-lock.yaml | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index 0e76dabe66..6dbed0bb6c 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -4,12 +4,18 @@ module.exports = { if (!pkg.name) { return pkg; } + // make exiftool-vendored.pl a regular dependency since Docker prod + // images build with --no-optional to reduce image size if (pkg.name === "exiftool-vendored") { - if (pkg.optionalDependencies["exiftool-vendored.pl"]) { - // make exiftool-vendored.pl a regular dependency - pkg.dependencies["exiftool-vendored.pl"] = - pkg.optionalDependencies["exiftool-vendored.pl"]; - delete pkg.optionalDependencies["exiftool-vendored.pl"]; + const binaryPackage = + process.platform === "win32" + ? "exiftool-vendored.exe" + : "exiftool-vendored.pl"; + + if (pkg.optionalDependencies[binaryPackage]) { + pkg.dependencies[binaryPackage] = + pkg.optionalDependencies[binaryPackage]; + delete pkg.optionalDependencies[binaryPackage]; } } return pkg; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e25b7c820..0e8f0c84b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,7 @@ overrides: packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54= -pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0= +pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA= importers: From b4e16efdf4f15ce192514bc84d30c86175a14d5a Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 09:23:40 -0500 Subject: [PATCH 73/78] test: face ordering issue/flakiness (#26382) --- e2e/src/specs/server/api/asset.e2e-spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index d4eee16232..11e825a7cd 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -253,7 +253,8 @@ describe('/asset', () => { expect(status).toBe(200); expect(body.id).toEqual(facesAsset.id); - expect(body.people).toMatchObject(expectedFaces); + const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name)); + expect(sortedPeople).toMatchObject(expectedFaces); }); }); From 6044b4164852e06869497b35001cf428d7fbb857 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 09:37:07 -0500 Subject: [PATCH 74/78] fix: align devcontainers with standard development containers (#26321) --- .devcontainer/devcontainer.json | 27 ++-------- .../mobile/container-compose-overrides.yml | 20 +++----- .devcontainer/mobile/devcontainer.json | 3 +- .devcontainer/server/container-common.sh | 51 +------------------ .../server/container-compose-overrides.yml | 21 +++----- .devcontainer/server/container-start.sh | 17 ------- server/Dockerfile.dev | 10 ++-- 7 files changed, 25 insertions(+), 124 deletions(-) delete mode 100755 .devcontainer/server/container-start.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c6c2b3b51e..1d1a6eec16 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,6 +2,7 @@ "name": "Immich - Backend, Frontend and ML", "service": "immich-server", "runServices": [ + "immich-init", "immich-server", "redis", "database", @@ -31,29 +32,8 @@ "tasks": { "version": "2.0.0", "tasks": [ - { - "label": "Fix Permissions, Install Dependencies", - "type": "shell", - "command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0", - "isBackground": true, - "presentation": { - "echo": true, - "reveal": "always", - "focus": false, - "panel": "dedicated", - "showReuseMessage": true, - "clear": false, - "group": "Devcontainer tasks", - "close": true - }, - "runOptions": { - "runOn": "default" - }, - "problemMatcher": [] - }, { "label": "Immich API Server (Nest)", - "dependsOn": ["Fix Permissions, Install Dependencies"], "type": "shell", "command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0", "isBackground": true, @@ -74,7 +54,6 @@ }, { "label": "Immich Web Server (Vite)", - "dependsOn": ["Fix Permissions, Install Dependencies"], "type": "shell", "command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0", "isBackground": true, @@ -130,8 +109,8 @@ } }, "overrideCommand": true, - "workspaceFolder": "/workspaces/immich", - "remoteUser": "node", + "workspaceFolder": "/usr/src/app", + "remoteUser": "root", "userEnvProbe": "loginInteractiveShell", "remoteEnv": { // The location where your uploaded files are stored diff --git a/.devcontainer/mobile/container-compose-overrides.yml b/.devcontainer/mobile/container-compose-overrides.yml index 99e41cbece..3d9e1b00b6 100644 --- a/.devcontainer/mobile/container-compose-overrides.yml +++ b/.devcontainer/mobile/container-compose-overrides.yml @@ -1,23 +1,17 @@ services: + immich-app-base: + image: busybox immich-server: + extends: + service: immich-app-base + profiles: !reset [] + image: immich-server-dev:latest build: target: dev-container-mobile environment: - IMMICH_SERVER_URL=http://127.0.0.1:2283/ - volumes: !override # bind mount host to /workspaces/immich - - ..:/workspaces/immich + volumes: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage - /etc/localtime:/etc/localtime:ro immich-web: env_file: !reset [] diff --git a/.devcontainer/mobile/devcontainer.json b/.devcontainer/mobile/devcontainer.json index 140a2ecac3..0be9b72969 100644 --- a/.devcontainer/mobile/devcontainer.json +++ b/.devcontainer/mobile/devcontainer.json @@ -2,6 +2,7 @@ "name": "Immich - Mobile", "service": "immich-server", "runServices": [ + "immich-init", "immich-server", "redis", "database", @@ -35,7 +36,7 @@ }, "forwardPorts": [], "overrideCommand": true, - "workspaceFolder": "/workspaces/immich", + "workspaceFolder": "/usr/src/app", "remoteUser": "node", "userEnvProbe": "loginInteractiveShell", "remoteEnv": { diff --git a/.devcontainer/server/container-common.sh b/.devcontainer/server/container-common.sh index 3aa72379c3..fa3e60f211 100755 --- a/.devcontainer/server/container-common.sh +++ b/.devcontainer/server/container-common.sh @@ -2,11 +2,6 @@ export IMMICH_PORT="${DEV_SERVER_PORT:-2283}" export DEV_PORT="${DEV_PORT:-3000}" -# search for immich directory inside workspace. -# /workspaces/immich is the bind mount, but other directories can be mounted if runing -# Devcontainer: Clone [repository|pull request] in container volumne -WORKSPACES_DIR="/workspaces" -IMMICH_DIR="$WORKSPACES_DIR/immich" IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log" log() { @@ -30,52 +25,8 @@ run_cmd() { return "${PIPESTATUS[0]}" } -# Find directories excluding /workspaces/immich -mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*") - -if [ ${#other_dirs[@]} -gt 1 ]; then - log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR." - exit 1 -elif [ ${#other_dirs[@]} -eq 1 ]; then - export IMMICH_WORKSPACE="${other_dirs[0]}" -else - export IMMICH_WORKSPACE="$IMMICH_DIR" -fi +export IMMICH_WORKSPACE="/usr/src/app" log "Found immich workspace in $IMMICH_WORKSPACE" log "" -fix_permissions() { - - log "Fixing permissions for ${IMMICH_WORKSPACE}" - - # Change ownership for directories that exist - for dir in "${IMMICH_WORKSPACE}/.vscode" \ - "${IMMICH_WORKSPACE}/server/upload" \ - "${IMMICH_WORKSPACE}/.pnpm-store" \ - "${IMMICH_WORKSPACE}/.github/node_modules" \ - "${IMMICH_WORKSPACE}/cli/node_modules" \ - "${IMMICH_WORKSPACE}/e2e/node_modules" \ - "${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \ - "${IMMICH_WORKSPACE}/server/node_modules" \ - "${IMMICH_WORKSPACE}/server/dist" \ - "${IMMICH_WORKSPACE}/web/node_modules" \ - "${IMMICH_WORKSPACE}/web/dist"; do - if [ -d "$dir" ]; then - run_cmd sudo chown node -R "$dir" - fi - done - - log "" -} - -install_dependencies() { - - log "Installing dependencies" - ( - cd "${IMMICH_WORKSPACE}" || exit 1 - export CI=1 FROZEN=1 OFFLINE=1 - run_cmd make setup-web-dev setup-server-dev - ) - log "" -} diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index cc2b0c907b..5c312efd07 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -1,26 +1,21 @@ services: + immich-app-base: + image: busybox immich-server: + extends: + service: immich-app-base + profiles: !reset [] + image: immich-server-dev:latest build: target: dev-container-server env_file: !reset [] hostname: immich-dev environment: - IMMICH_SERVER_URL=http://127.0.0.1:2283/ - volumes: !override - - ..:/workspaces/immich + volumes: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - /etc/localtime:/etc/localtime:ro - - pnpm-store:/usr/src/app/.pnpm-store - - server-node_modules:/usr/src/app/server/node_modules - - web-node_modules:/usr/src/app/web/node_modules - - github-node_modules:/usr/src/app/.github/node_modules - - cli-node_modules:/usr/src/app/cli/node_modules - - docs-node_modules:/usr/src/app/docs/node_modules - - e2e-node_modules:/usr/src/app/e2e/node_modules - - sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules - - app-node_modules:/usr/src/app/node_modules - - sveltekit:/usr/src/app/web/.svelte-kit - - coverage:/usr/src/app/web/coverage + - pnpm_store_server:/buildcache/pnpm-store - ../plugins:/build/corePlugin immich-web: env_file: !reset [] diff --git a/.devcontainer/server/container-start.sh b/.devcontainer/server/container-start.sh deleted file mode 100755 index 0edd38172e..0000000000 --- a/.devcontainer/server/container-start.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# shellcheck source=common.sh -# shellcheck disable=SC1091 -source /immich-devcontainer/container-common.sh - -log "Setting up Immich dev container..." -fix_permissions - -log "Setup complete, please wait while backend and frontend services automatically start" -log -log "If necessary, the services may be manually started using" -log -log "$ /immich-devcontainer/container-start-backend.sh" -log "$ /immich-devcontainer/container-start-frontend.sh" -log -log "From different terminal windows, as these scripts automatically restart the server" -log "on error, and will continuously run in a loop" diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 74757956fc..f778c20afb 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -27,16 +27,14 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] FROM dev AS dev-container-server RUN apt-get update --allow-releaseinfo-change && \ - apt-get install sudo inetutils-ping openjdk-21-jre-headless \ + apt-get install inetutils-ping openjdk-21-jre-headless \ vim nano curl \ -y --no-install-recommends --fix-missing -RUN usermod -aG sudo node && \ - echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \ - mkdir -p /workspaces/immich +RUN mkdir -p /workspaces && \ + ln -s /usr/src/app /workspaces/immich -RUN chown node:node -R /workspaces -COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/ +COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/ WORKDIR /workspaces/immich From 84f29569410ec95a80e15d61060991b45963fe36 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 20 Feb 2026 15:54:08 +0100 Subject: [PATCH 75/78] fix(cli): delete sidecar files after upload if requested (#26353) * fix(cli): delete sidecar files after upload if requested Introduced a new function, findSidecar, to locate XMP sidecar files based on specified naming conventions. Updated the deleteFiles function to delete associated sidecar files when the main asset file is deleted. Added unit tests for findSidecar to ensure correct functionality. * lint and format * fix test * chore: clean up --------- Co-authored-by: Jason Rasmussen --- cli/src/commands/asset.spec.ts | 92 +++++++++++++++++++++++++++++++++- cli/src/commands/asset.ts | 54 ++++++++++++-------- 2 files changed, 123 insertions(+), 23 deletions(-) diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index 7dce135985..ea57eeb74b 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -7,7 +7,15 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest'; import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; import createFetchMock from 'vitest-fetch-mock'; -import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset'; +import { + checkForDuplicates, + deleteFiles, + findSidecar, + getAlbumName, + startWatch, + uploadFiles, + UploadOptionsDto, +} from 'src/commands/asset'; vi.mock('@immich/sdk'); @@ -309,3 +317,85 @@ describe('startWatch', () => { await fs.promises.rm(testFolder, { recursive: true, force: true }); }); }); + +describe('findSidecar', () => { + let testDir: string; + let testFilePath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-')); + testFilePath = path.join(testDir, 'test.jpg'); + fs.writeFileSync(testFilePath, 'test'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should find sidecar file with photo.xmp naming convention', () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + const result = findSidecar(testFilePath); + expect(result).toBe(sidecarPath); + }); + + it('should find sidecar file with photo.ext.xmp naming convention', () => { + const sidecarPath = path.join(testDir, 'test.jpg.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + const result = findSidecar(testFilePath); + expect(result).toBe(sidecarPath); + }); + + it('should prefer photo.ext.xmp over photo.xmp when both exist', () => { + const sidecarPath1 = path.join(testDir, 'test.xmp'); + const sidecarPath2 = path.join(testDir, 'test.jpg.xmp'); + fs.writeFileSync(sidecarPath1, 'xmp data 1'); + fs.writeFileSync(sidecarPath2, 'xmp data 2'); + + const result = findSidecar(testFilePath); + // Should return the first one found (photo.xmp) based on the order in the code + expect(result).toBe(sidecarPath1); + }); + + it('should return undefined when no sidecar file exists', () => { + const result = findSidecar(testFilePath); + expect(result).toBeUndefined(); + }); +}); + +describe('deleteFiles', () => { + let testDir: string; + let testFilePath: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-')); + testFilePath = path.join(testDir, 'test.jpg'); + fs.writeFileSync(testFilePath, 'test'); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it('should delete asset and sidecar file when main file is deleted', async () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 }); + + expect(fs.existsSync(testFilePath)).toBe(false); + expect(fs.existsSync(sidecarPath)).toBe(false); + }); + + it('should not delete sidecar file when delete option is false', async () => { + const sidecarPath = path.join(testDir, 'test.xmp'); + fs.writeFileSync(sidecarPath, 'xmp data'); + + await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 }); + + expect(fs.existsSync(testFilePath)).toBe(true); + expect(fs.existsSync(sidecarPath)).toBe(true); + }); +}); diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 42c33491f2..7d4b09b69d 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar'; import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { chunk } from 'lodash-es'; import micromatch from 'micromatch'; -import { Stats, createReadStream } from 'node:fs'; +import { Stats, createReadStream, existsSync } from 'node:fs'; import { stat, unlink } from 'node:fs/promises'; import path, { basename } from 'node:path'; import { Queue } from 'src/queue'; @@ -403,23 +403,6 @@ export const uploadFiles = async ( const uploadFile = async (input: string, stats: Stats): Promise => { const { baseUrl, headers } = defaults; - const assetPath = path.parse(input); - const noExtension = path.join(assetPath.dir, assetPath.name); - - const sidecarsFiles = await Promise.all( - // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp - [`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => { - try { - const stats = await stat(sidecarPath); - return new UploadFile(sidecarPath, stats.size); - } catch { - return false; - } - }), - ); - - const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false); - const formData = new FormData(); formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, '')); formData.append('deviceId', 'CLI'); @@ -429,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise => { +export const findSidecar = (filepath: string): string | undefined => { + const assetPath = path.parse(filepath); + const noExtension = path.join(assetPath.dir, assetPath.name); + + // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp + for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) { + if (existsSync(sidecarPath)) { + return sidecarPath; + } + } +}; + +export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise => { let fileCount = 0; if (options.delete) { fileCount += uploaded.length; @@ -474,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo const chunkDelete = async (files: Asset[]) => { for (const assetBatch of chunk(files, options.concurrency)) { - await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath))); + await Promise.all( + assetBatch.map(async (input: Asset) => { + await unlink(input.filepath); + const sidecarPath = findSidecar(input.filepath); + if (sidecarPath) { + await unlink(sidecarPath); + } + }), + ); deletionProgress.update(assetBatch.length); } }; From 18bf96b4b2351ee6076378d736f0d8c9d9b88d92 Mon Sep 17 00:00:00 2001 From: Benjamin Nguyen Date: Fri, 20 Feb 2026 06:57:28 -0800 Subject: [PATCH 76/78] fix(mobile): handle userPreferencesProvider error state during sync (#26332) fix drift_search_page render bug --- mobile/lib/presentation/pages/search/drift_search.page.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0d9bba146a..0ce3f20641 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -698,7 +698,7 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_location'.t(context: context), currentFilter: locationCurrentFilterWidget.value, ), - if (userPreferences.value?.tagsEnabled ?? false) + if (userPreferences.valueOrNull?.tagsEnabled ?? false) SearchFilterChip( icon: Icons.sell_outlined, onTap: showTagPicker, @@ -724,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget { label: 'search_filter_media_type'.t(context: context), currentFilter: mediaTypeCurrentFilterWidget.value, ), - if (userPreferences.value?.ratingsEnabled ?? false) ...[ + if (userPreferences.valueOrNull?.ratingsEnabled ?? false) SearchFilterChip( icon: Icons.star_outline_rounded, onTap: showStarRatingPicker, label: 'search_filter_star_rating'.t(context: context), currentFilter: ratingCurrentFilterWidget.value, ), - ], SearchFilterChip( icon: Icons.display_settings_outlined, onTap: showDisplayOptionPicker, From aae64b5e2f3a8fce08e6ddf9f41240ac1fe3eca9 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 20 Feb 2026 10:04:17 -0500 Subject: [PATCH 77/78] test: thumbnail selector (#26383) * test: face ordering issue/flakiness * test: thumbnail selector --- e2e/src/ui/specs/timeline/utils.ts | 7 ++----- web/src/lib/components/assets/thumbnail/thumbnail.svelte | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index 774839b174..e3799a7c3b 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -65,7 +65,7 @@ export const thumbnailUtils = { return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`); }, selectedAsset(page: Page) { - return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])'); + return page.locator('[data-thumbnail-focus-container][data-selected]'); }, async clickAssetId(page: Page, assetId: string) { await thumbnailUtils.withAssetId(page, assetId).click(); @@ -103,11 +103,8 @@ export const thumbnailUtils = { await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0); }, async expectSelectedReadonly(page: Page, assetId: string) { - // todo - need a data attribute for selected await expect( - page.locator( - `[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`, - ), + page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`), ).toBeVisible(); }, async expectTimelineHasOnScreenAssets(page: Page) { diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 8270646470..5604e6f59d 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -223,6 +223,7 @@ bind:this={element} data-asset={asset.id} data-thumbnail-focus-container + data-selected={selected ? true : undefined} tabindex={0} role="link" > From 82c6302549e4b041c3939723519283f5c9a7879e Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Fri, 20 Feb 2026 20:21:26 +0200 Subject: [PATCH 78/78] feat(mobile): timeline - add persistentBottomBar flag (#25634) * feat(mobile): timeline - add selectable all-assets control * feature(mobile): introduce bottomWidgetBuilder in Timeline remove redundant code * fix(mobile): remove redundant code * refactor(mobile): refactor new code in Timeline * fix(mobile): fix format * refactor(mobile): replace unsupported Dart syntax for analyzer compatibility * refactor(mobile): remove Timeline.bottomSheet and migrate to bottomWidgetBuilder * refactor(mobile): restore Timeline.bottomSheet and remove bottomWidgetBuilder add withPersistentBottomBar param to Timeline class * refactor(mobile): refactor var name --------- Co-authored-by: Peter Ombodi --- .../widgets/timeline/timeline.widget.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9f7c695c8b..5190e2007f 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -74,6 +74,7 @@ class Timeline extends StatefulWidget { this.snapToMonth = true, this.initialScrollOffset, this.readOnly = false, + this.persistentBottomBar = false, }); final Widget? topSliverWidget; @@ -87,6 +88,7 @@ class Timeline extends StatefulWidget { final bool snapToMonth; final double? initialScrollOffset; final bool readOnly; + final bool persistentBottomBar; @override State createState() => _TimelineState(); @@ -143,6 +145,7 @@ class _TimelineState extends State { appBar: widget.appBar, bottomSheet: widget.bottomSheet, withScrubber: widget.withScrubber, + persistentBottomBar: widget.persistentBottomBar, snapToMonth: widget.snapToMonth, initialScrollOffset: widget.initialScrollOffset, ), @@ -173,6 +176,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { this.appBar, this.bottomSheet, this.withScrubber = true, + this.persistentBottomBar = false, this.snapToMonth = true, this.initialScrollOffset, }); @@ -182,6 +186,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { final Widget? appBar; final Widget? bottomSheet; final bool withScrubber; + final bool persistentBottomBar; final bool snapToMonth; final double? initialScrollOffset; @@ -404,6 +409,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled; + final isBottomWidgetVisible = + widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar); return PopScope( canPop: !isMultiSelectEnabled, @@ -519,7 +527,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { child: Stack( children: [ timeline, - if (!isSelectionMode && isMultiSelectEnabled) ...[ + if (isMultiSelectStatusVisible) Positioned( top: MediaQuery.paddingOf(context).top, left: 25, @@ -528,8 +536,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { child: Center(child: _MultiSelectStatusButton()), ), ), - if (widget.bottomSheet != null) widget.bottomSheet!, - ], + if (isBottomWidgetVisible) widget.bottomSheet!, ], ), ),