Compare commits

...

12 Commits

Author SHA1 Message Date
midzelis
4f22482e4a fix: development containers init race conditions 2026-02-03 23:32:35 +00:00
Jason Rasmussen
3ea65f8d27 fix: album dto docs (#25873) 2026-02-03 21:05:18 +00:00
github-actions
38c1f0b1fd chore: version v2.5.3 2026-02-03 18:14:21 +00:00
Michel Heusschen
5212bca3d0 fix: reset zoom when navigating between assets (#25863) 2026-02-03 11:07:06 -06:00
Daniel Dietzler
2990bde0bb fix: metadata extraction race condition (#25866) 2026-02-03 11:03:27 -06:00
Michel Heusschen
af1ecaf5cc fix: prevent backspace from accidentally triggering delete modals (#25858)
* fix: prevent backspace from accidentally triggering delete modals

* ignore input fields instead of removing shortcut
2026-02-03 16:42:46 +00:00
Alex
3870ebc3c6 fix: prevent album page get rebuilt when resuming app (#25862) 2026-02-03 16:35:53 +00:00
Michel Heusschen
0a9d969b47 fix: prevent stale values in edit user form after save (#25859) 2026-02-03 17:29:01 +01:00
Daniel Dietzler
94965f6d66 chore: rework tags sidebar (#25855) 2026-02-03 16:06:26 +00:00
Alex
8872d2c7ae chore: remove swift logs (#25857) 2026-02-03 16:00:17 +00:00
Alex
23445fdcc1 fix: upload progress bar flickering (#25829)
* fix: upload progress bar flickering

* pr feedback and more logs
2026-02-03 09:28:29 -06:00
renovate[bot]
25f2273e24 chore(deps): update redis:6.2-alpine docker digest to 46884be (#25839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 12:56:56 +01:00
42 changed files with 261 additions and 135 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.5.2",
"version": "2.5.3",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -14,7 +14,7 @@
],
"devDependencies": {
"@eslint/js": "^9.8.0",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/sdk": "workspace:*",
"@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",

View File

@@ -14,6 +14,48 @@
name: immich-dev
services:
immich-init:
container_name: immich_init
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command:
- |
rm -f /tmp/init-complete
if [ -d "node_modules/.pnpm" ] && [ "$(ls -A node_modules/.pnpm 2>/dev/null)" ] && [ -f "pnpm-lock.yaml" ] && pnpm list --silent &>/dev/null; then
echo "Dependencies valid, skipping install"
else
echo "Installing dependencies..."
pnpm install
fi
touch /tmp/init-complete
exec tail -f /dev/null
volumes:
- ..:/usr/src/app
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/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
restart: 'no'
healthcheck:
test: ['CMD', 'test', '-f', '/tmp/init-complete']
interval: 2s
timeout: 3s
retries: 300
start_period: 300s
immich-server:
container_name: immich_server
command: ['immich-dev']
@@ -29,8 +71,11 @@ services:
volumes:
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /LUNA/ALPHA/PERSONAL/git/immich-dev/flickr-search/downloads:/ext-lib
- /LUNA/ALPHA/PERSONAL/git/immich-dev/wikimedia-search/downloads:/ext-lib2
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/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
@@ -63,6 +108,8 @@ services:
- 9231:9231
- 2283:2283
depends_on:
immich-init:
condition: service_healthy
redis:
condition: service_started
database:
@@ -85,7 +132,8 @@ services:
- 24678:24678
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-web:/buildcache/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
@@ -98,6 +146,8 @@ services:
- coverage:/usr/src/app/web/coverage
restart: unless-stopped
depends_on:
immich-init:
condition: service_healthy
immich-server:
condition: service_started
@@ -173,7 +223,9 @@ volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-store:
pnpm-cache:
pnpm-store-server:
pnpm-store-web:
server-node_modules:
web-node_modules:
github-node_modules:

View File

@@ -1,7 +1,7 @@
[
{
"label": "v2.5.2",
"url": "https://docs.v2.5.2.archive.immich.app"
"label": "v2.5.3",
"url": "https://docs.v2.5.3.archive.immich.app"
},
{
"label": "v2.4.1",

View File

@@ -1,6 +1,55 @@
name: immich-e2e
services:
immich-init:
container_name: immich-e2e-init
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command:
- |
set -x
echo "Init container starting"
rm -f /tmp/init-complete
echo "Checking for existing dependencies"
if [ -d "node_modules/.pnpm" ] && [ "$(ls -A node_modules/.pnpm 2>/dev/null)" ] && [ -f "pnpm-lock.yaml" ] && pnpm list --silent &>/dev/null; then
echo "Dependencies valid, skipping install"
else
echo "Installing dependencies"
pnpm install
echo "Dependencies installed successfully"
fi
echo "Creating init-complete signal file"
touch /tmp/init-complete
echo "Init complete, keeping container alive"
exec tail -f /dev/null
volumes:
- ..:/usr/src/app
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/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
restart: "no"
healthcheck:
test: ["CMD", "test", "-f", "/tmp/init-complete"]
interval: 2s
timeout: 3s
retries: 300
start_period: 300s
immich-server:
container_name: immich-e2e-server
command: ['immich-dev']
@@ -24,7 +73,8 @@ services:
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-server:/buildcache/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
@@ -37,6 +87,8 @@ services:
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
depends_on:
immich-init:
condition: service_healthy
redis:
condition: service_started
database:
@@ -56,7 +108,8 @@ services:
- IMMICH_SERVER_URL=http://immich-server:2285/
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- pnpm-cache:/buildcache/pnpm-cache
- pnpm-store-web:/buildcache/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
@@ -67,10 +120,13 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
depends_on:
immich-init:
condition: service_healthy
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
@@ -92,7 +148,9 @@ volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-store:
pnpm-cache:
pnpm-store-server:
pnpm-store-web:
server-node_modules:
web-node_modules:
github-node_modules:

View File

@@ -42,7 +42,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.5.2",
"version": "2.5.3",
"description": "",
"main": "index.js",
"type": "module",
@@ -21,9 +21,9 @@
"devDependencies": {
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "file:../cli",
"@immich/e2e-auth-server": "file:../e2e-auth-server",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/cli": "workspace:*",
"@immich/e2e-auth-server": "workspace:*",
"@immich/sdk": "workspace:*",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.5.2",
"version": "2.5.3",
"private": true,
"scripts": {
"format": "prettier --check .",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.5.2"
version = "2.5.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"

View File

@@ -882,7 +882,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.5.2"
version = "2.5.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3033,
"android.injected.version.name" => "2.5.2",
"android.injected.version.code" => 3034,
"android.injected.version.name" => "2.5.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -133,7 +133,6 @@ class LocalImageApiImpl: LocalImageApi {
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
print("Successful response for \(requestId)")
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.2</string>
<string>2.5.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -33,7 +33,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
@override
Widget build(BuildContext context) {
final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length));
final showScrollbar = albumCount > 10;
final showScrollbar = albumCount > 20;
final scrollView = CustomScrollView(
controller: _scrollController,

View File

@@ -87,7 +87,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}
void onSearch(String searchTerm, QuickFilterMode filterMode) {
final userId = ref.watch(currentUserProvider)?.id;
final userId = ref.read(currentUserProvider)?.id;
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode);
filterAlbums();
@@ -186,7 +186,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
@override
Widget build(BuildContext context) {
final userId = ref.watch(currentUserProvider)?.id;
final userId = ref.watch(currentUserProvider.select((user) => user?.id));
// refilter and sort when albums change
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {

View File

@@ -259,6 +259,11 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> startForegroundBackup(String userId) async {
// Cancel any existing backup before starting a new one
if (state.cancelToken != null) {
await stopForegroundBackup();
}
state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken();
@@ -375,21 +380,21 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
_logger.info("Start background backup sequence");
state = state.copyWith(error: BackupError.none);
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
_logger.info("Found ${tasks.length} pending tasks");
if (tasks.isEmpty) {
_logger.info("Start backup with URLSession");
_logger.info("No pending tasks, starting new upload");
return _backgroundUploadService.uploadBackupCandidates(userId);
}
_logger.info("Tasks to resume: ${tasks.length}");
_logger.info("Resuming upload ${tasks.length} assets");
return _backgroundUploadService.resume();
}
}

View File

@@ -164,9 +164,12 @@ class BackgroundUploadService {
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
_logger.info("No new backup candidates found, finishing background upload");
return;
}
_logger.info("Found ${candidates.length} backup candidates for background tasks");
const batchSize = 100;
final batch = candidates.take(batchSize).toList();
List<UploadTask> tasks = [];
@@ -179,6 +182,7 @@ class BackgroundUploadService {
}
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
_logger.info("Enqueuing ${tasks.length} background upload tasks");
await enqueueTasks(tasks);
}
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.5.2
- API version: 2.5.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -473,7 +473,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = only own, undefined = all
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -516,7 +516,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = only own, undefined = all
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) {

View File

@@ -1249,10 +1249,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1942,10 +1942,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.5.2+3033
version: 2.5.3+3034
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -1618,7 +1618,7 @@
"name": "shared",
"required": false,
"in": "query",
"description": "Filter by shared status: true = only shared, false = only own, undefined = all",
"description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums",
"schema": {
"type": "boolean"
}
@@ -15057,7 +15057,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.5.2",
"version": "2.5.3",
"contact": {}
},
"tags": [

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.5.2",
"version": "2.5.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.5.2
* 2.5.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.5.2",
"version": "2.5.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",

10
pnpm-lock.yaml generated
View File

@@ -45,7 +45,7 @@ importers:
specifier: ^9.8.0
version: 9.39.2
'@immich/sdk':
specifier: file:../open-api/typescript-sdk
specifier: workspace:*
version: link:../open-api/typescript-sdk
'@types/byte-size':
specifier: ^8.1.0
@@ -202,13 +202,13 @@ importers:
specifier: ^10.1.0
version: 10.2.0
'@immich/cli':
specifier: file:../cli
specifier: workspace:*
version: link:../cli
'@immich/e2e-auth-server':
specifier: file:../e2e-auth-server
specifier: workspace:*
version: link:../e2e-auth-server
'@immich/sdk':
specifier: file:../open-api/typescript-sdk
specifier: workspace:*
version: link:../open-api/typescript-sdk
'@playwright/test':
specifier: ^1.44.1
@@ -738,7 +738,7 @@ importers:
specifier: ^0.4.3
version: 0.4.3
'@immich/sdk':
specifier: file:../open-api/typescript-sdk
specifier: workspace:*
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.59.0

View File

@@ -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" \

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.5.2",
"version": "2.5.3",
"description": "",
"author": "",
"private": true,

View File

@@ -102,7 +102,7 @@ export class UpdateAlbumDto {
export class GetAlbumsDto {
@ValidateBoolean({
optional: true,
description: 'Filter by shared status: true = only shared, false = only own, undefined = all',
description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums',
})
shared?: boolean;

View File

@@ -307,7 +307,6 @@ export class MetadataService extends BaseService {
const assetHeight = isSidewards ? validate(width) : validate(height);
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }),
this.assetRepository.update({
id: asset.id,
duration: this.getDuration(exifTags),
@@ -322,6 +321,7 @@ export class MetadataService extends BaseService {
}),
];
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
await this.applyTagList(asset);
if (this.isMotionPhoto(asset, exifTags)) {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.5.2",
"version": "2.5.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -26,7 +26,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.59.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",

View File

@@ -14,6 +14,7 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -36,6 +37,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { CommandPaletteDefaultProvider } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -426,8 +428,11 @@
!assetViewerManager.isShowEditor &&
ocrManager.hasOcrData,
);
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Icon, modalManager, Text } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js';
import { Badge, IconButton, Link, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -18,22 +19,23 @@
let tags = $derived(asset.tags || []);
const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) {
asset = await getAssetInfo({ id: asset.id });
}
};
const handleRemove = async (tagId: string) => {
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
if (ids) {
asset = await getAssetInfo({ id: asset.id });
}
};
const onAssetsTag = async (ids: string[]) => {
if (ids.includes(asset.id)) {
asset = await getAssetInfo({ id: asset.id });
}
};
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
<OnEvents {onAssetsTag} />
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">
@@ -42,36 +44,24 @@
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
<Badge size="small" class="items-center px-0" shape="round">
<Link
href={Route.tags({ path: tag.value })}
class="text-light no-underline rounded-full hover:bg-primary-400 px-2"
>
<p class="text-sm">
{tag.value}
</p>
</a>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
{tag.value}
</Link>
<IconButton
aria-label={$t('remove_tag')}
icon={mdiClose}
onclick={() => handleRemove(tag.id)}
>
<Icon icon={mdiClose} />
</button>
</div>
size="tiny"
class="hover:bg-primary-400"
shape="round"
/>
</Badge>
{/each}
<button
type="button"
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
title={$t('add_tag')}
onclick={handleAddTag}
>
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"
><Icon icon={mdiPlus} />{$t('add')}</span
>
</button>
<HeaderActionButton action={Tag} />
</section>
</section>
{/if}

View File

@@ -55,13 +55,10 @@
let loader = $state<HTMLImageElement>();
assetViewerManager.zoomState = {
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
};
$effect.pre(() => {
void asset.id;
untrack(() => assetViewerManager.resetZoomState());
});
onDestroy(() => {
$boundingBoxesArray = [];

View File

@@ -20,11 +20,8 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (success) {
clearSelect();
}
await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
clearSelect();
};
</script>

View File

@@ -5,6 +5,14 @@ import type { ZoomImageWheelState } from '@zoom-image/core';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const createDefaultZoomState = (): ZoomImageWheelState => ({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
@@ -12,13 +20,7 @@ export type Events = {
};
export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state<ZoomImageWheelState>({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
#zoomState = $state(createDefaultZoomState());
imgRef = $state<HTMLImageElement | undefined>();
isShowActivityPanel = $state(false);
@@ -67,6 +69,10 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}
resetZoomState() {
this.zoomState = createDefaultZoomState();
}
toggleActivityPanel() {
this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel;

View File

@@ -37,6 +37,7 @@ export type Events = {
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [];
AlbumUpdate: [AlbumResponseDto];

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { FormModal, Icon } from '@immich/ui';
@@ -9,7 +10,7 @@
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
interface Props {
onClose: (success?: true) => void;
onClose: () => void;
assetIds: string[];
}
@@ -30,8 +31,8 @@
return;
}
await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
onClose(true);
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
eventManager.emit('AssetsTag', updatedIds);
};
const handleSelect = async (option?: ComboBoxOption) => {

View File

@@ -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 AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import { getAssetJobName, getSharedLink, sleep } from '$lib/utils';
@@ -41,6 +42,7 @@ import {
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
@@ -49,6 +51,7 @@ import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const sharedLink = getSharedLink();
const currentAuthUser = get(authUser);
const userPreferences = get(preferences);
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
const Share: ActionItem = {
@@ -155,7 +158,16 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
type: $t('assets'),
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: [{ key: 'i' }],
shortcuts: { key: 'i' },
};
const Tag: ActionItem = {
title: $t('add_tag'),
icon: mdiTagPlusOutline,
type: $t('assets'),
$if: () => userPreferences.tags.enabled,
onAction: () => modalManager.show(AssetTagModal, { assetIds: [asset.id] }),
shortcuts: { key: 't' },
};
const Edit: ActionItem = {
@@ -212,6 +224,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
ZoomIn,
ZoomOut,
Copy,
Tag,
Edit,
RefreshFacesJob,
RefreshMetadataJob,

View File

@@ -67,6 +67,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
color: 'danger',
onAction: () => handleDeleteLibrary(library),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const AddFolder: ActionItem = {

View File

@@ -67,6 +67,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onAction: () => modalManager.show(UserDeleteConfirmModal, { user }),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const getDeleteDate = (deletedAt: string): Date =>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
@@ -48,10 +48,7 @@
const { children, data }: Props = $props();
let user = $state(data.user);
const userPreferences = $state(data.userPreferences);
const userStatistics = $state(data.userStatistics);
const userSessions = $state(data.userSessions);
const { user, userPreferences, userStatistics, userSessions } = $derived(data);
const TiB = 1024 ** 4;
const usage = $derived(user.quotaUsageInBytes ?? 0);
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
@@ -79,9 +76,10 @@
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
const onUpdate = async (update: UserAdminResponseDto) => {
if (update.id === user.id) {
user = update;
data.user = update;
await invalidateAll();
}
};

View File

@@ -16,12 +16,10 @@
let { data }: Props = $props();
const user = $state(data.user);
let isAdmin = $state(user.isAdmin);
let name = $state(user.name);
let email = $state(user.email);
let storageLabel = $state(user.storageLabel || '');
const previousQuota = $state(user.quotaSizeInBytes);
const user = $derived(data.user);
let { isAdmin, name, email } = $derived(user);
let storageLabel = $derived(user.storageLabel || '');
const previousQuota = $derived(user.quotaSizeInBytes);
let quotaSize = $derived(
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,