diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 5f77f28d8e..43ff43e573 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -127,18 +127,29 @@ class GalleryViewerPage extends HookConsumerWidget { context: context, useSafeArea: true, builder: (context) { - return FractionallySizedBox( - heightFactor: 0.75, - child: Padding( - padding: EdgeInsets.only( - bottom: context.viewInsets.bottom, - ), - child: ref - .watch(appSettingsServiceProvider) - .getSetting(AppSettingsEnum.advancedTroubleshooting) - ? AdvancedBottomSheet(assetDetail: asset) - : DetailPanel(asset: asset), - ), + return DraggableScrollableSheet( + minChildSize: 0.5, + maxChildSize: 1, + initialChildSize: 0.75, + expand: false, + builder: (context, scrollController) { + return Padding( + padding: EdgeInsets.only( + bottom: context.viewInsets.bottom, + ), + child: ref.watch(appSettingsServiceProvider).getSetting( + AppSettingsEnum.advancedTroubleshooting, + ) + ? AdvancedBottomSheet( + assetDetail: asset, + scrollController: scrollController, + ) + : DetailPanel( + asset: asset, + scrollController: scrollController, + ), + ); + }, ); }, ); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 27be2c046d..c059f48f0e 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -372,7 +372,6 @@ class BackgroundService { HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); apiService.setAccessToken(Store.get(StoreKey.accessToken)); - AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); AlbumRepository albumRepository = AlbumRepository(db); AssetRepository assetRepository = AssetRepository(db); @@ -422,7 +421,7 @@ class BackgroundService { ); BackupService backupService = BackupService( apiService, - settingService, + settingsService, albumService, albumMediaRepository, fileMediaRepository, diff --git a/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart b/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart index 367519fead..1e6aba2bda 100644 --- a/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart +++ b/mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart @@ -6,12 +6,18 @@ import 'package:immich_mobile/entities/asset.entity.dart'; class AdvancedBottomSheet extends HookConsumerWidget { final Asset assetDetail; + final ScrollController? scrollController; - const AdvancedBottomSheet({super.key, required this.assetDetail}); + const AdvancedBottomSheet({ + super.key, + required this.assetDetail, + this.scrollController, + }); @override Widget build(BuildContext context, WidgetRef ref) { return SingleChildScrollView( + controller: scrollController, child: Container( margin: const EdgeInsets.symmetric(horizontal: 8.0), child: LayoutBuilder( diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart b/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart index db9dafebcb..8ad2cdc687 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/detail_panel.dart @@ -9,12 +9,14 @@ import 'package:immich_mobile/entities/asset.entity.dart'; class DetailPanel extends HookConsumerWidget { final Asset asset; + final ScrollController? scrollController; - const DetailPanel({super.key, required this.asset}); + const DetailPanel({super.key, required this.asset, this.scrollController}); @override Widget build(BuildContext context, WidgetRef ref) { return ListView( + controller: scrollController, shrinkWrap: true, children: [ Padding( diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index 0dd3305302..4af9846cf6 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -18,7 +18,7 @@ class FileInfo extends StatelessWidget { final height = asset.orientatedHeight ?? asset.height; final width = asset.orientatedWidth ?? asset.width; String resolution = - height != null && width != null ? "$height x $width " : ""; + height != null && width != null ? "$width x $height " : ""; String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : ""; diff --git a/server/Dockerfile b/server/Dockerfile index 3b2ac262d0..4c1aecb8fa 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20241217@sha256:7e69fa317cf90a0345927bbea13438dc39efc584bac13ff77ea5735c57cd008a AS dev +FROM ghcr.io/immich-app/base-server-dev:20241224@sha256:6832c632c2a8cba5e20053ab694c9a8080e621841c784ed5d4675ef9dd203588 AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -42,7 +42,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20241217@sha256:040c83a6d3e45755419837747fa70fa68cf92433d483c116a971b3400bb8415d +FROM ghcr.io/immich-app/base-server-prod:20241224@sha256:69da007c241a961d6927d3d03f1c83ef0ec5c70bf656bff3ced32546a777e6f6 WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 2ff5f53073..aa7fb87ac5 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -204,7 +204,7 @@ describe('getEnv', () => { it('should return default network options', () => { const { network } = getEnv(); expect(network).toEqual({ - trustedProxies: [], + trustedProxies: ['linklocal', 'uniquelocal'], }); }); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index a8a1c9972b..cc05fd927c 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -177,7 +177,7 @@ const getEnv = (): EnvData => { licensePublicKey: isProd ? productionKeys : stagingKeys, network: { - trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? [], + trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'], }, otel: { diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 5196e7595c..efc705deaf 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -32,7 +32,7 @@ async function bootstrap() { logger.setContext('Bootstrap'); app.useLogger(logger); - app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal', ...network.trustedProxies]); + app.set('trust proxy', ['loopback', ...network.trustedProxies]); app.set('etag', 'strong'); app.use(cookieParser()); app.use(json({ limit: '10mb' })); diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte index a95b67494e..3419e62a18 100644 --- a/web/src/lib/components/forms/tag-asset-form.svelte +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -5,10 +5,8 @@ import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte'; import FullScreenModal from '../shared-components/full-screen-modal.svelte'; import { onMount } from 'svelte'; - import { getAllTags, type TagResponseDto } from '@immich/sdk'; + import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk'; import Icon from '$lib/components/elements/icon.svelte'; - import { AppRoute } from '$lib/constants'; - import FormatMessage from '$lib/components/i18n/format-message.svelte'; import { SvelteSet } from 'svelte/reactivity'; interface Props { @@ -22,6 +20,7 @@ let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag]))); let selectedIds = $state(new SvelteSet()); let disabled = $derived(selectedIds.size === 0); + let allowCreate: boolean = $state(true); onMount(async () => { allTags = await getAllTags(); @@ -29,12 +28,18 @@ const handleSubmit = () => onTag([...selectedIds]); - const handleSelect = (option?: ComboBoxOption) => { + const handleSelect = async (option?: ComboBoxOption) => { if (!option) { return; } - selectedIds.add(option.value); + if (option.id) { + selectedIds.add(option.value); + } else { + const [newTag] = await upsertTags({ tagUpsertDto: { tags: [option.label] } }); + allTags.push(newTag); + selectedIds.add(newTag.id); + } }; const handleRemove = (tag: string) => { @@ -48,22 +53,13 @@ -
-

- - {#snippet children({ message })} - - {message} - - {/snippet} - -

-
({ id: tag.id, label: tag.value, value: tag.id }))} placeholder={$t('search_tags')} /> diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 9dcb4d8f25..a6a1422eef 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -36,6 +36,14 @@ options?: ComboBoxOption[]; selectedOption?: ComboBoxOption | undefined; placeholder?: string; + /** + * whether creating new items is allowed. + */ + allowCreate?: boolean; + /** + * select first matching option on enter key. + */ + defaultFirstOption?: boolean; onSelect?: (option: ComboBoxOption | undefined) => void; } @@ -45,6 +53,8 @@ options = [], selectedOption = $bindable(), placeholder = '', + allowCreate = false, + defaultFirstOption = false, onSelect = () => {}, }: Props = $props(); @@ -141,7 +151,7 @@ const onInput: FormEventHandler = (event) => { openDropdown(); searchQuery = event.currentTarget.value; - selectedIndex = undefined; + selectedIndex = defaultFirstOption ? 0 : undefined; optionRefs[0]?.scrollIntoView({ block: 'nearest' }); }; @@ -221,9 +231,15 @@ searchQuery = selectedOption ? selectedOption.label : ''; }); - let filteredOptions = $derived( - options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), - ); + let filteredOptions = $derived.by(() => { + const _options = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + + if (allowCreate && searchQuery !== '' && _options.filter((option) => option.label === searchQuery).length === 0) { + _options.unshift({ label: searchQuery, value: searchQuery }); + } + + return _options; + }); let position = $derived(calculatePosition(bounds)); let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport)); @@ -352,7 +368,7 @@ id={`${listboxId}-${0}`} onclick={() => closeDropdown()} > - {$t('no_results')} + {allowCreate ? searchQuery : $t('no_results')} {/if} {#each filteredOptions as option, index (option.id || option.label)} diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 5412464766..215707543c 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -398,7 +398,9 @@ export class AssetStore { } async updateOptions(options: AssetStoreOptions) { - if (!this.initialized) { + // Make sure to re-initialize if the personId changes + const needsReinitializing = this.options.personId !== options.personId; + if (!this.initialized && !needsReinitializing) { this.setOptions(options); return; } 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 e1e50cfb2e..79760b192c 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 @@ -74,8 +74,13 @@ const assetStore = new AssetStore(assetStoreOptions); $effect(() => { + // Check to trigger rebuild the timeline when navigating between people from the info panel + const change = assetStoreOptions.personId !== data.person.id; assetStoreOptions.personId = data.person.id; handlePromiseError(assetStore.updateOptions(assetStoreOptions)); + if (change) { + assetStore.triggerUpdate(); + } }); const assetInteraction = new AssetInteraction();