Compare commits

..

10 Commits

Author SHA1 Message Date
Mees Frensel 3fe3e0960c refactor(web): simple actions 2026-06-22 18:10:51 +02:00
Brandon Wees a5198e23a8 refactor: use SemVer classes for version compatability message (#29056)
* refactor: use SemVer classes for version compatability message

* chore: readd major version compatabilty messages

* fix: remove 1.106.0 check

(we dont support v1 servers anymore)
2026-06-22 11:28:56 -04:00
Mees Frensel 51f2905fcc fix(web): remove map's fullscreen button (#29192) 2026-06-22 16:58:07 +02:00
Vogeluff 3b7d75c18a feat(web): Add text-white-shadow to elements and increase the shadows effect (#29165)
* fix(web): increase text shadow strength for white text on thumbnails

* fix(web): fix class order for text-white-shadow

* fixup: format fix
2026-06-22 09:43:15 -05:00
Daniel Dietzler c484bd99b6 fix: ignore external libraries for integrity report checksum check (#29248) 2026-06-22 13:56:24 +00:00
Anthony Clerici c0bf5a4c56 fix(server): use VBR for QSV so the max bitrate is respected (#29240)
* fix(server): use VBR for QSV so the max bitrate is respected

* update test
2026-06-22 09:56:20 -04:00
MuySup d9d50d2848 fix: turkish readme translation (#29234)
* Translation completed

3-2-1 rule translated

* Fix formatting of warning message in Turkish README
2026-06-22 09:55:58 -04:00
Daniel Dietzler c7453a67fd fix: detail panel people reactivity and iterator consumption (#29250) 2026-06-22 15:47:09 +02:00
Daniel Dietzler e918e3a313 feat: keyboard seeking for new video player (#29208) 2026-06-22 09:42:59 -04:00
Matthew Momjian dc7d57ff9a fix(docsc): v3 bump (#29246)
v3 bump in docs
2026-06-21 20:44:58 -05:00
28 changed files with 153 additions and 184 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
+1 -1
View File
@@ -1548,7 +1548,7 @@
"map_location_picker_page_use_location": "Use this location",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_location_service_disabled_title": "Location Service disabled",
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
"map_marker_with_image": "Map marker with image",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied",
+12 -3
View File
@@ -1,7 +1,16 @@
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
import 'package:immich_mobile/utils/semver.dart';
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
// Add latest compat info up top
if (serverMinor < 106 && appMinor >= 106) {
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
// ensure mobile app major version is not behind server major version
if (appVersion.major < serverVersion.major) {
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
}
// ensure mobile app major version is not ahead of server major version by more than 1 major version
if (appVersion.major > serverVersion.major + 1) {
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
}
return null;
+4 -12
View File
@@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
@@ -88,18 +89,9 @@ class LoginForm extends HookConsumerWidget {
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
warningMessage.value = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
final appSemVer = SemVer.fromString(packageInfo.version);
final serverSemVer = serverInfo.serverVersion;
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
@@ -1,29 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
void main() {
test('getVersionCompatibilityMessage', () {
String? result;
group('app major version behind server', () {
const message =
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
result = getVersionCompatibilityMessage(1, 106, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns message when app major is behind server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns null when app major matches server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
result = getVersionCompatibilityMessage(1, 106, 1, 106);
expect(result, null);
group('app major version too far ahead of server', () {
const message =
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
result = getVersionCompatibilityMessage(1, 107, 1, 106);
expect(result, null);
test('returns message when app major is more than one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 3, minor: 0, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 108);
expect(result, null);
test('returns null when app major is exactly one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
}
-28
View File
@@ -289,34 +289,6 @@
"required": ["albumIds"]
}
},
{
"name": "assetDataWebhook",
"title": "Trigger Webhook",
"description": "POST asset data to any URL",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "URL",
"description": "Asset data will be POSTed to this URL as a JSON object"
},
"headerName": {
"type": "string",
"title": "Header name",
"description": "The name of an additional header to include with the request (e.g. authentication)"
},
"headerValue": {
"type": "string",
"title": "Header value",
"description": "The value of the additional header"
}
},
"required": ["url"]
}
},
{
"name": "noop1",
"title": "DEV: Nested properties",
-2
View File
@@ -5,7 +5,6 @@ declare module 'extism:host' {
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
httpRequest(ptr: PTR): I64;
}
}
@@ -25,5 +24,4 @@ declare module 'main' {
export function assetTimeline(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
export function assetDataWebhook(): I32;
}
-22
View File
@@ -181,25 +181,3 @@ export const assetAddToAlbums = () => {
return {};
});
};
export const assetDataWebhook = () => {
return wrapper<WorkflowType.AssetV1, { url: string; headerName?: string; headerValue?: string }>(
({ config, data, functions }) => {
let headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (config.headerName && config.headerValue) {
headers[config.headerName] = config.headerValue;
}
functions.httpRequest(config.url, {
method: 'POST',
body: JSON.stringify(data.asset),
headers,
});
return {};
},
);
};
-12
View File
@@ -13,7 +13,6 @@ declare module 'extism:host' {
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
httpRequest(ptr: PTR): I64;
}
}
@@ -34,11 +33,6 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
type HttpRequestOptions = {
method?: string;
headers?: Record<string, string>;
body?: string;
};
export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions();
@@ -81,11 +75,5 @@ export const hostFunctions = (authToken: string) => {
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
httpRequest: (url: string, options?: HttpRequestOptions) =>
call<[string, HttpRequestOptions | undefined], string>(
'httpRequest',
authToken,
[url, options],
),
};
};
+2 -2
View File
@@ -38,8 +38,8 @@
</p>
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
> ⚠️ Değerli fotoğraflarınız ve videolarınız için daima [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) yedekleme planını uygulayın!
>
> [!NOTE]
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
@@ -129,6 +129,7 @@ from
and "integrity_report"."type" = $1
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by
@@ -177,6 +177,7 @@ export class IntegrityRepository {
'asset.id as assetId',
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
@@ -2939,6 +2939,8 @@ describe(MediaService.name, () => {
'7',
'-global_quality:v',
'23',
'-b:v',
'6897k',
'-maxrate',
'10000k',
'-bufsize',
@@ -74,26 +74,12 @@ export class WorkflowExecutionService extends BaseService {
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
const httpRequest = this.wrap<
[
url: string,
options?: {
method?: string;
headers?: Record<string, string>;
body?: string;
},
]
>(async (_, args) => {
const res = await fetch(...args);
return res.text();
});
const functions = {
searchAlbums,
createAlbum,
addAssetsToAlbum,
addAssetsToAlbums,
httpRequest,
};
const stubs: typeof functions = {
@@ -101,7 +87,6 @@ export class WorkflowExecutionService extends BaseService {
createAlbum: dummy,
addAssetsToAlbum: dummy,
addAssetsToAlbums: dummy,
httpRequest: dummy,
};
const plugins = await this.pluginRepository.getForLoad();
+6
View File
@@ -788,6 +788,12 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'}`, `${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
// Workaround for https://github.com/immich-app/immich/issues/29220, to be revisited
// QSV seems to ignore -maxrate without -b:v
// -b:v alongside global_quality uses QVBR
if (!this.useCQP()) {
options.push('-b:v', `${bitrates.target}${bitrates.unit}`);
}
options.push('-maxrate', `${bitrates.max}${bitrates.unit}`, '-bufsize', `${bitrates.max * 2}${bitrates.unit}`);
}
return options;
@@ -686,6 +686,22 @@ describe(IntegrityService.name, () => {
nextCursor: undefined,
});
});
it('should skip external library files', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue(void 0);
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, isExternal: true });
await sut.handleChecksumFiles({ refreshOnly: false });
await expect(
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined });
});
});
describe('handleChecksumRefresh', () => {
+3 -1
View File
@@ -159,7 +159,9 @@
}
.text-white-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
text-shadow:
0 0 4px rgba(0, 0, 0, 0.9),
0 1px 3px rgba(0, 0, 0, 0.8);
}
.icon-white-drop-shadow {
@@ -10,13 +10,11 @@
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
@@ -84,6 +82,27 @@
shortcuts: [{ key: 'Escape' }],
});
const PlayOriginalVideo: ActionItem = $derived({
title: playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video'),
icon: mdiVideoOutline,
$if: () => asset.type === AssetTypeEnum.Video,
onAction: () => setPlayOriginalVideo(!playOriginalVideo),
});
const ViewInTimeline: ActionItem = $derived({
title: $t('view_in_timeline'),
icon: mdiImageSearch,
$if: () => isOwner && !isLocked && !asset.isArchived && !asset.isTrashed,
onAction: () => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id })),
});
const ViewSimilar: ActionItem = $derived({
title: $t('view_similar_photos'),
icon: mdiCompare,
$if: () => !isLocked && !asset.isArchived && !asset.isTrashed && smartSearchEnabled,
onAction: () => goto(Route.search({ queryAssetId: stack?.primaryAssetId ?? asset.id })),
});
const Actions = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink();
</script>
@@ -169,41 +188,21 @@
{#if person}
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{/if}
{#if !isLocked}
{#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_in_timeline')}
/>
{/if}
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() => goto(Route.search({ queryAssetId: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_similar_photos')}
/>
{/if}
<ActionMenuItem action={Actions.SetProfilePicture} />
{#if isOwner && !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
{/if}
<ActionMenuItem action={ViewInTimeline} />
<ActionMenuItem action={ViewSimilar} />
{#if !asset.isTrashed && isOwner}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiVideoOutline}
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
<ActionMenuItem action={PlayOriginalVideo} />
{#if isOwner}
<hr />
<ActionMenuItem action={Actions.RefreshFacesJob} />
@@ -23,7 +23,7 @@
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
@@ -310,14 +310,13 @@
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
<Link
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
class="text-primary"
>
{$t('open_in_openstreetmap')}
</a>
</Link>
</div>
{/snippet}
</Map>
@@ -324,6 +324,18 @@
shortcut: { key: ' ' },
onShortcut: () => (videoPlayer?.paused ? videoPlayer?.play() : videoPlayer?.pause()),
},
{
shortcut: { shift: true, key: 'ArrowLeft' },
onShortcut: () =>
videoPlayer ? (videoPlayer.currentTime = Math.max(videoPlayer.currentTime - 0.4, 0)) : undefined,
},
{
shortcut: { shift: true, key: 'ArrowRight' },
onShortcut: () =>
videoPlayer
? (videoPlayer.currentTime = Math.min(videoPlayer.currentTime + 0.4, videoPlayer.duration))
: undefined,
},
]}
/>
@@ -1,20 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiAccountCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
</script>
<MenuOption
icon={mdiAccountCircleOutline}
onClick={() => modalManager.show(ProfileImageCropperModal, { asset })}
text={$t('set_as_profile_picture')}
/>
@@ -342,7 +342,7 @@
{#if !!assetOwner}
<div class="absolute inset-e-2 bottom-1 z-2 max-w-[50%]">
<p class="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
<p class="text-white-shadow max-w-full truncate p-1 text-xs font-medium text-white">
{assetOwner.name}
</p>
</div>
@@ -38,7 +38,6 @@
Control,
ControlButton,
ControlGroup,
FullscreenControl,
GeoJSON,
GeolocateControl,
MapLibre,
@@ -343,7 +342,6 @@
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
@@ -401,13 +399,13 @@
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[calc(5px-50%)] text-primary" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
? $t('map_marker_for_image', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
@@ -415,7 +413,7 @@
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
{@render popup({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
+10
View File
@@ -11,6 +11,7 @@ import {
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAccountCircleOutline,
mdiAlertOutline,
mdiCogRefreshOutline,
mdiContentCopy,
@@ -41,6 +42,7 @@ 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 ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
@@ -242,6 +244,13 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }],
};
const SetProfilePicture: ActionItem = {
title: $t('set_as_profile_picture'),
icon: mdiAccountCircleOutline,
$if: () => asset.type === AssetTypeEnum.Image && asset.visibility !== AssetVisibility.Locked,
onAction: () => modalManager.show(ProfileImageCropperModal, { asset }),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -286,6 +295,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag,
TagPeople,
Edit,
SetProfilePicture,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
+3 -2
View File
@@ -24,7 +24,8 @@ class FaceManager {
});
readonly people = $derived.by(() => {
const people = new SvelteMap<string, PersonResponseDto>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const people = new Map<string, PersonResponseDto>();
for (const face of this.data) {
if (face.person) {
@@ -32,7 +33,7 @@ class FaceManager {
}
}
return people.values();
return Array.from(people.values());
});
readonly facesByPersonId = $derived.by(() => {
@@ -169,7 +169,9 @@
preload={false}
/>
{#if person.name}
<span class="absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text">
<span
class="text-white-shadow absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text"
>
{person.name}
</span>
{/if}