diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 8c58baac17..cbd029296f 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -52,7 +52,7 @@ Password login has been enabled. Disable Maintenance Mode ``` -immich-admin disable-maintenace-mode +immich-admin disable-maintenance-mode Maintenance mode has been disabled. ``` 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 08ac9f982c..23cd19f363 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 @@ -109,7 +109,10 @@ class _AddActionButtonState extends ConsumerState { return; } - final List slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))]; + final List slivers = [ + const CreateAlbumButton(), + AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album)), + ]; showModalBottomSheet( context: context, @@ -153,6 +156,9 @@ class _AddActionButtonState extends ConsumerState { context: context, msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), ); + + // Invalidate using the asset's remote ID to refresh the "Appears in" list + ref.invalidate(albumsContainingAssetProvider(latest.remoteId!)); } if (!context.mounted) { diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 4110966e57..c42f49091f 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -12,8 +12,10 @@ import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/presentation/widgets/album/album_tile.dart'; +import 'package:immich_mobile/presentation/widgets/album/new_album_name_modal.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; @@ -766,3 +768,68 @@ class AddToAlbumHeader extends ConsumerWidget { ); } } + +class CreateAlbumButton extends ConsumerWidget { + const CreateAlbumButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future onCreateAlbum() async { + var albumName = await showDialog(context: context, builder: (context) => const NewAlbumNameModal()); + if (albumName == null) { + return; + } + + final asset = ref.read(currentAssetNotifier); + + if (asset == null) { + ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error); + return; + } + + final album = await ref + .read(remoteAlbumProvider.notifier) + .createAlbum(title: albumName, assetIds: [asset.remoteId!]); + + if (album == null) { + ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr()); + return; + } + + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ); + + // Invalidate using the asset's remote ID to refresh the "Appears in" list + ref.invalidate(albumsContainingAssetProvider(asset.remoteId!)); + + context.pop(); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("add_to_album", style: context.textTheme.titleSmall).tr(), + TextButton.icon( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: onCreateAlbum, + icon: Icon(Icons.add, color: context.primaryColor), + label: Text( + "common_create_new_album", + style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14), + ).tr(), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/album/new_album_name_modal.widget.dart b/mobile/lib/presentation/widgets/album/new_album_name_modal.widget.dart new file mode 100644 index 0000000000..a5e21af489 --- /dev/null +++ b/mobile/lib/presentation/widgets/album/new_album_name_modal.widget.dart @@ -0,0 +1,53 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class NewAlbumNameModal extends StatefulWidget { + const NewAlbumNameModal({super.key}); + + @override + State createState() => _NewAlbumNameModalState(); +} + +class _NewAlbumNameModalState extends State { + TextEditingController nameController = TextEditingController(); + + @override + void dispose() { + nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("album_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(), + content: SingleChildScrollView( + child: TextFormField( + controller: nameController, + textCapitalization: TextCapitalization.words, + autofocus: true, + decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()), + ), + ), + actions: [ + TextButton( + onPressed: () => context.pop(null), + child: Text( + "cancel", + style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold), + ).tr(), + ), + TextButton( + onPressed: () { + context.pop(nameController.text.trim()); + }, + child: Text( + "create_album", + style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ); + } +} 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 08b5b25343..8727f40a1a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart'; @@ -104,7 +105,12 @@ class NativeVideoViewer extends HookConsumerWidget { throw Exception('No file found for the video'); } - final source = await VideoSource.init(path: file.path, type: VideoSourceType.file); + // Pass a file:// URI so Android's Uri.parse doesn't + // interpret characters like '#' as fragment identifiers. + final source = await VideoSource.init( + path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path, + type: VideoSourceType.file, + ); return source; } diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 04e4bd2a2c..bbf55e674a 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -17,8 +17,10 @@ class PersonApiRepository extends ApiRepository { } Future update(String id, {String? name, DateTime? birthday}) async { - final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday))); - return _toPerson(dto); + final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); + final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc); + final response = await checkNull(_api.updatePerson(id, dto)); + return _toPerson(response); } static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 32882de0e0..1334d1220f 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -89,6 +89,7 @@ export class MetadataRepository { // Enable exiftool LFS to parse metadata for files larger than 2GB. readArgs: ['-api', 'largefilesupport=1'], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], + taskTimeoutMillis: 2 * 60 * 1000, }); constructor(private logger: LoggingRepository) { diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 60913ff47b..2dde2c35ee 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -114,7 +114,11 @@ return; } - await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime }); + await modalManager.show(AssetChangeDateModal, { + asset: toTimelineAsset(asset), + initialDate: dateTime, + initialTimeZone: timeZone, + }); }; 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 442d3cef6c..7f873aa918 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 @@ -15,9 +15,24 @@ import { joinPaths, TreeNode } from '$lib/utils/tree-utils'; import { deleteTag, getAllTags, type TagResponseDto } from '@immich/sdk'; import { Button, HStack, modalManager, Text } from '@immich/ui'; - import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; + import { mdiDotsVertical, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.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'; + import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte'; + import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte'; + import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte'; + import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte'; + import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte'; + import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte'; + import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte'; + import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; + import { preferences, user } from '$lib/stores/user.store'; interface Props { data: PageData; @@ -79,6 +94,11 @@ // navigate to parent await navigateToView(tag.parent ? tag.parent.path : ''); }; + + const handleSetVisibility = (assetIds: string[]) => { + timelineManager.removeAssets(assetIds); + assetInteraction.clearMultiselect(); + }; @@ -131,3 +151,45 @@ {/if} + +
+ {#if assetInteraction.selectionActive} +
+ assetInteraction.clearMultiselect()} + > + + + + + + + timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} + > + + + + + + timelineManager.update(ids, (asset) => (asset.visibility = visibility))} + /> + {#if $preferences.tags.enabled} + + {/if} + timelineManager.removeAssets(assetIds)} + onUndoDelete={(assets) => timelineManager.upsertAssets(assets)} + /> + + + +
+ {/if} +