Compare commits

...

8 Commits

Author SHA1 Message Date
Daniel Dietzler
648e51f7c3 refactor: actionable toasts 2026-03-23 18:33:08 +01:00
Yaros
0e93aa74cf fix(mobile): add keys to people list (#27112)
mobile(fix): add keys to people list
2026-03-23 10:50:56 -05:00
Yaros
e95ad9d2eb fix(mobile): option padding on search dropdowns (#27154)
* fix(mobile): option padding on search dropdowns

* chore: prevent height fill up screen and block the bottom menu entry

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-03-23 15:03:07 +00:00
Nicolas-micuda-becker
b98a227bbd fix(web): update upload summary when removing items (#27035) (#27139) 2026-03-23 10:02:09 -05:00
Michel Heusschen
2dd785e3e2 fix(web): restore duplicate viewer arrow key navigation (#27176) 2026-03-23 10:01:15 -05:00
Daniel Dietzler
7e754125cd fix: download original stale cache when edited (#27195) 2026-03-23 10:00:32 -05:00
Yaros
e2eb03d3a4 fix(mobile): star rating always defaults to 0 (#27157) 2026-03-23 09:56:27 -05:00
Yaros
bf065a834f fix(mobile): no results before applying filter (#27155) 2026-03-23 09:41:13 -05:00
14 changed files with 154 additions and 123 deletions

View File

@@ -0,0 +1,51 @@
import { AssetMediaResponseDto, LoginResponseDto, updateAssets } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import crypto from 'node:crypto';
import { asBearerAuth, utils } from 'src/utils';
test.describe('Duplicates Utility', () => {
let admin: LoginResponseDto;
let firstAsset: AssetMediaResponseDto;
let secondAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test.beforeEach(async ({ context }) => {
[firstAsset, secondAsset] = await Promise.all([
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-a' }),
utils.createAsset(admin.accessToken, { deviceAssetId: 'duplicate-b' }),
]);
await updateAssets(
{
assetBulkUpdateDto: {
ids: [firstAsset.id, secondAsset.id],
duplicateId: crypto.randomUUID(),
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await utils.setAuthCookies(context, admin.accessToken);
});
test('navigates with arrow keys between duplicate preview assets', async ({ page }) => {
await page.goto('/utilities/duplicates');
await page.getByRole('button', { name: 'View' }).first().click();
await page.waitForSelector('#immich-asset-viewer');
const getViewedAssetId = () => new URL(page.url()).pathname.split('/').at(-1) ?? '';
const initialAssetId = getViewedAssetId();
expect([firstAsset.id, secondAsset.id]).toContain(initialAssetId);
await page.keyboard.press('ArrowRight');
await expect.poll(getViewedAssetId).not.toBe(initialAssetId);
await page.keyboard.press('ArrowLeft');
await expect.poll(getViewedAssetId).toBe(initialAssetId);
});
});

View File

@@ -79,6 +79,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
final person = people[index];
return Column(
key: ValueKey(person.id),
children: [
GestureDetector(
onTap: () {
@@ -88,6 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
key: ValueKey('avatar-${person.id}'),
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),

View File

@@ -69,6 +69,7 @@ class DriftSearchPage extends HookConsumerWidget {
);
final previousFilter = useState<SearchFilter?>(null);
final hasRequestedSearch = useState<bool>(false);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
@@ -91,9 +92,11 @@ class DriftSearchPage extends HookConsumerWidget {
if (filter.isEmpty) {
previousFilter.value = null;
hasRequestedSearch.value = false;
return;
}
hasRequestedSearch.value = true;
unawaited(ref.read(paginatedSearchProvider.notifier).search(filter));
previousFilter.value = filter;
}
@@ -721,7 +724,7 @@ class DriftSearchPage extends HookConsumerWidget {
),
),
),
if (filter.value.isEmpty)
if (!hasRequestedSearch.value)
const _SearchSuggestions()
else
_SearchResultGrid(onScrollEnd: loadMoreSearchResults),

View File

@@ -39,6 +39,16 @@ class _RatingBarState extends State<RatingBar> {
_currentRating = widget.initialRating;
}
@override
void didUpdateWidget(covariant RatingBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialRating != widget.initialRating && _currentRating != widget.initialRating) {
setState(() {
_currentRating = widget.initialRating;
});
}
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;

View File

@@ -16,9 +16,15 @@ class SearchDropdown<T> extends StatelessWidget {
final Widget? label;
final Widget? leadingIcon;
static const WidgetStatePropertyAll<EdgeInsetsGeometry> _optionPadding = WidgetStatePropertyAll<EdgeInsetsGeometry>(
EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0),
);
@override
Widget build(BuildContext context) {
final menuStyle = const MenuStyle(
final mediaQuery = MediaQuery.of(context);
final maxMenuHeight = mediaQuery.size.height * 0.5 - mediaQuery.viewPadding.bottom;
const menuStyle = MenuStyle(
shape: WidgetStatePropertyAll<OutlinedBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15))),
),
@@ -26,11 +32,26 @@ class SearchDropdown<T> extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final styledEntries = dropdownMenuEntries
.map(
(entry) => DropdownMenuEntry<T>(
value: entry.value,
label: entry.label,
labelWidget: entry.labelWidget,
enabled: entry.enabled,
leadingIcon: entry.leadingIcon,
trailingIcon: entry.trailingIcon,
style: (entry.style ?? const ButtonStyle()).copyWith(padding: _optionPadding),
),
)
.toList(growable: false);
return DropdownMenu(
controller: controller,
leadingIcon: leadingIcon,
width: constraints.maxWidth,
dropdownMenuEntries: dropdownMenuEntries,
menuHeight: maxMenuHeight,
dropdownMenuEntries: styledEntries,
label: label,
menuStyle: menuStyle,
trailingIcon: const Icon(Icons.arrow_drop_down_rounded),

10
pnpm-lock.yaml generated
View File

@@ -744,8 +744,8 @@ importers:
specifier: workspace:*
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.65.3
version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
specifier: ^0.67.2
version: 0.67.2(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.3.0
version: 0.3.0
@@ -3038,8 +3038,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.65.3':
resolution: {integrity: sha512-jMXzCzMNTcCdWXt9IUP7GkALE5oEvPQk/jCOuI2bfxsxCZFzMkUfUS+AV83Vg1vQ6l+g39PbKSPKBEzv125ATQ==}
'@immich/ui@0.67.2':
resolution: {integrity: sha512-GsaoJRiRORJ34CT+W3pAOdhbLr61nNlgFaOzDcnVnSWFonu7+HR3CXdCxbSdyU4+r3xEmcawwo6rMuLgRplHfw==}
peerDependencies:
svelte: ^5.0.0
@@ -15123,7 +15123,7 @@ snapshots:
node-emoji: 2.2.0
svelte: 5.53.13
'@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
'@immich/ui@0.67.2(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.53.13)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)(typescript@5.9.3)(vite@8.0.0(@types/node@25.4.0)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.13)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.13)
'@internationalized/date': 3.10.0

View File

@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.65.3",
"@immich/ui": "^0.67.2",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",

View File

@@ -1,33 +0,0 @@
<script lang="ts">
import { Button, ToastContainer, ToastContent, type Color, type IconLike } from '@immich/ui';
type Props = {
onClose?: () => void;
color?: Color;
title: string;
icon?: IconLike | false;
description: string;
button?: {
text: string;
color?: Color;
onClick: () => void;
};
};
const { onClose, title, description, color, icon, button }: Props = $props();
const onClick = () => {
button?.onClick();
onClose?.();
};
</script>
<ToastContainer {color}>
<ToastContent {color} {title} {description} {onClose} {icon}>
{#if button}
<div class="flex justify-end gap-2 px-2 pb-2 me-3 mt-2">
<Button color={button.color ?? 'secondary'} size="small" onclick={onClick}>{button.text}</Button>
</div>
{/if}
</ToastContent>
</ToastContainer>

View File

@@ -1,5 +1,4 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
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';
@@ -138,16 +137,8 @@ const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: strin
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 })) },
},
},
toastManager.primary(
{ description, button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id: albumId })) } },
{ timeout: 5000 },
);
};
@@ -229,18 +220,9 @@ export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbum
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: dto });
eventManager.emit('AlbumUpdate', response);
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick: () => goto(Route.viewAlbum({ id })),
},
},
toastManager.primary({
description: $t('album_info_updated'),
button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id })) },
});
return true;

View File

@@ -8,17 +8,16 @@ import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import type { AssetControlContext } from '$lib/types';
import { getSharedLink, sleep } from '$lib/utils';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetJobName,
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
getBaseUrl,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -308,6 +307,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
{
filename: asset.originalFileName,
id: asset.id,
cacheKey: asset.thumbhash,
},
];
@@ -321,13 +321,12 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
assets.push({
filename: motionAsset.originalFileName,
id: asset.livePhotoVideoId,
cacheKey: motionAsset.thumbhash,
});
}
}
const queryParams = asQueryString(authManager.params);
for (const [i, { filename, id }] of assets.entries()) {
for (const [i, { filename, id, cacheKey }] of assets.entries()) {
if (i !== 0) {
// play nice with Safari
await sleep(500);
@@ -335,12 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
try {
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
(queryParams ? `?${queryParams}&edited=${edited}` : `?edited=${edited}`),
filename,
);
downloadUrl(getAssetMediaUrl({ id, size: AssetMediaSize.Original, edited, cacheKey }), filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}

View File

@@ -80,7 +80,34 @@ function createUploadStore() {
};
const removeItem = (id: string) => {
uploadAssets.update((uploadingAsset) => uploadingAsset.filter((a) => a.id != id));
uploadAssets.update((uploadingAsset) => {
const assetToRemove = uploadingAsset.find((a) => a.id === id);
if (assetToRemove) {
stats.update((stats) => {
switch (assetToRemove.state) {
case UploadState.DONE: {
stats.success--;
break;
}
case UploadState.DUPLICATED: {
stats.duplicates--;
break;
}
case UploadState.ERROR: {
stats.errors--;
break;
}
}
stats.total--;
return stats;
});
}
return uploadingAsset.filter((a) => a.id != id);
});
};
const dismissErrors = () =>

View File

@@ -1,4 +1,3 @@
import ToastAction from '$lib/components/ToastAction.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { StackResponse } from '$lib/utils/asset-utils';
@@ -32,24 +31,15 @@ export const deleteAssets = async (
await deleteBulk({ assetBulkDeleteDto: { ids, force } });
onAssetDelete(ids);
toastManager.custom(
toastManager.primary(
{
component: ToastAction,
props: {
title: $t('success'),
description: force
? $t('assets_permanently_deleted_count', { values: { count: ids.length } })
: $t('assets_trashed_count', { values: { count: ids.length } }),
color: 'success',
button:
onUndoDelete && !force
? {
color: 'secondary',
text: $t('undo'),
onClick: () => undoDeleteAssets(onUndoDelete, assets),
}
: undefined,
},
description: force
? $t('assets_permanently_deleted_count', { values: { count: ids.length } })
: $t('assets_trashed_count', { values: { count: ids.length } }),
button:
onUndoDelete && !force
? { label: $t('undo'), color: 'secondary', onclick: () => undoDeleteAssets(onUndoDelete, assets) }
: undefined,
},
{ timeout: 5000 },
);

View File

@@ -1,4 +1,3 @@
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';
@@ -326,16 +325,11 @@ export const stackAssets = async (assets: { id: string }[], showNotification = t
try {
const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
if (showNotification) {
toastManager.custom({
component: ToastAction,
props: {
title: $t('success'),
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
color: 'success',
button: {
text: $t('view_stack'),
onClick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
}

View File

@@ -178,19 +178,7 @@
const handleFirst = () => navigateToIndex(0);
const handlePrevious = () => navigateToIndex(Math.max(duplicatesIndex - 1, 0));
const handlePreviousShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handlePrevious();
};
const handleNext = async () => navigateToIndex(Math.min(duplicatesIndex + 1, duplicates.length - 1));
const handleNextShortcut = async () => {
if ($showAssetViewer) {
return;
}
await handleNext();
};
const handleLast = () => navigateToIndex(duplicates.length - 1);
const navigateToIndex = async (index: number) =>
@@ -198,10 +186,12 @@
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
]}
use:shortcuts={$showAssetViewer
? []
: [
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
]}
/>
<UserPageLayout title={data.meta.title + ` (${duplicates.length.toLocaleString($locale)})`} scrollbar={true}>