merge main

This commit is contained in:
martabal
2024-05-26 22:14:36 +02:00
1208 changed files with 13676 additions and 34138 deletions
+1 -1
View File
@@ -1 +1 @@
v20.12
20.13
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:iron-alpine3.18@sha256:fe31b16ddfb4ba4ae1a42ea540e9e44b916d754e67c64642b090839a9b2ed0ee
FROM node:iron-alpine3.18@sha256:53108f67824964a573ea435fed258f6cee4d88343e9859a99d356883e71b490c
RUN apk add --no-cache tini
USER node
+245 -288
View File
@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.103.1",
"version": "1.105.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.103.1",
"version": "1.105.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
@@ -36,6 +36,7 @@
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
"@types/lodash-es": "^4.17.12",
@@ -47,7 +48,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-unicorn": "^53.0.0",
"factory.ts": "^1.4.1",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
@@ -65,7 +66,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.103.1",
"version": "1.105.1",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
@@ -318,9 +319,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
"integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz",
"integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==",
"dev": true,
"engines": {
"node": ">=6.9.0"
@@ -1843,11 +1844,12 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.7.tgz",
"integrity": "sha512-6uedTzrb7nQrw6HALxnPrPaXdIN2jJJTzTIl96Z3P5NiG+OAfpdPbrWrvkJ3GN4CfWqrmU4dJqwMMRMTD/C7ow==",
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.8.tgz",
"integrity": "sha512-ZQXYaVHd1p0kDGwOi4l82i5kAiUQtrhMthDKtJi0zVzmNupKJ0ZlBVAoceuarCuIntPNctyQchW29h5DkFxd1Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^0.6.0",
@@ -2012,9 +2014,9 @@
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz",
"integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==",
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.5.tgz",
"integrity": "sha512-AguB9yvTXmCnySBP1lWjfNNUwpbElsaQ567lt2VdGqAdHtpieLgjmcVyv1q7PMIvLbgpDdkWV5Ydv3FEejyp2A==",
"dev": true,
"dependencies": {
"@adobe/css-tools": "^4.3.2",
@@ -2023,7 +2025,7 @@
"chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"lodash": "^4.17.15",
"lodash": "^4.17.21",
"redent": "^3.0.0"
},
"engines": {
@@ -2154,6 +2156,19 @@
}
}
},
"node_modules/@testing-library/user-event": {
"version": "14.5.2",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
"integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
"dev": true,
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.2.tgz",
@@ -2190,12 +2205,6 @@
"@types/geojson": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/justified-layout": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@types/justified-layout/-/justified-layout-4.1.4.tgz",
@@ -2271,12 +2280,6 @@
"integrity": "sha512-I469DU0UXNC1aHepwirWhu9YKg5fkxohZD95Ey/5A7lovC+Siu+MCLffva87lnfThaOrw9Vb1DUN5t55oULAAw==",
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
@@ -2286,21 +2289,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.8.0.tgz",
"integrity": "sha512-gFTT+ezJmkwutUPmB0skOj3GZJtlEGnlssems4AjkVweUPGj7jRwwqg0Hhg7++kPGJqKtTYx+R05Ftww372aIg==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.9.0.tgz",
"integrity": "sha512-6e+X0X3sFe/G/54aC3jt0txuMTURqLyekmEHViqyA2VnxhLMpvA6nqmcjIy+Cr9tLDHPssA74BP5Mx9HQIxBEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/type-utils": "7.8.0",
"@typescript-eslint/utils": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"debug": "^4.3.4",
"@typescript-eslint/scope-manager": "7.9.0",
"@typescript-eslint/type-utils": "7.9.0",
"@typescript-eslint/utils": "7.9.0",
"@typescript-eslint/visitor-keys": "7.9.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
"semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
@@ -2320,49 +2322,17 @@
}
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/@typescript-eslint/parser": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.8.0.tgz",
"integrity": "sha512-KgKQly1pv0l4ltcftP59uQZCi4HUYswCLbTqVZEJu7uLX8CTLyswqMLqLN+2QFz4jCptqWVV4SB7vdxcH2+0kQ==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.9.0.tgz",
"integrity": "sha512-qHMJfkL5qvgQB2aLvhUSXxbK7OLnDkwPzFalg458pxQgfxKDfT1ZDbHQM/I6mDIf/svlMkj21kzKuQ2ixJlatQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"@typescript-eslint/scope-manager": "7.9.0",
"@typescript-eslint/types": "7.9.0",
"@typescript-eslint/typescript-estree": "7.9.0",
"@typescript-eslint/visitor-keys": "7.9.0",
"debug": "^4.3.4"
},
"engines": {
@@ -2382,13 +2352,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.8.0.tgz",
"integrity": "sha512-viEmZ1LmwsGcnr85gIq+FCYI7nO90DVbE37/ll51hjv9aG+YZMb4WDE2fyWpUR4O/UrhGRpYXK/XajcGTk2B8g==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.9.0.tgz",
"integrity": "sha512-ZwPK4DeCDxr3GJltRz5iZejPFAAr4Wk3+2WIBaj1L5PYK5RgxExu/Y68FFVclN0y6GGwH8q+KgKRCvaTmFBbgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0"
"@typescript-eslint/types": "7.9.0",
"@typescript-eslint/visitor-keys": "7.9.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -2399,13 +2370,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.8.0.tgz",
"integrity": "sha512-H70R3AefQDQpz9mGv13Uhi121FNMh+WEaRqcXTX09YEDky21km4dV1ZXJIp8QjXc4ZaVkXVdohvWDzbnbHDS+A==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.9.0.tgz",
"integrity": "sha512-6Qy8dfut0PFrFRAZsGzuLoM4hre4gjzWJB6sUvdunCYZsYemTkzZNwF1rnGea326PHPT3zn5Lmg32M/xfJfByA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "7.8.0",
"@typescript-eslint/utils": "7.8.0",
"@typescript-eslint/typescript-estree": "7.9.0",
"@typescript-eslint/utils": "7.9.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -2426,10 +2398,11 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.8.0.tgz",
"integrity": "sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.9.0.tgz",
"integrity": "sha512-oZQD9HEWQanl9UfsbGVcZ2cGaR0YT5476xfWE0oE5kQa2sNK2frxOlkeacLOTh9po4AlUT5rtkGyYM5kew0z5w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || >=20.0.0"
},
@@ -2439,13 +2412,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.8.0.tgz",
"integrity": "sha512-5pfUCOwK5yjPaJQNy44prjCwtr981dO8Qo9J9PwYXZ0MosgAbfEMB008dJ5sNo3+/BN6ytBPuSvXUg9SAqB0dg==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.9.0.tgz",
"integrity": "sha512-zBCMCkrb2YjpKV3LA0ZJubtKCDxLttxfdGmwZvTqqWevUPN0FZvSI26FalGFFUZU/9YQK/A4xcQF9o/VVaCKAg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/visitor-keys": "7.8.0",
"@typescript-eslint/types": "7.9.0",
"@typescript-eslint/visitor-keys": "7.9.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -2471,27 +2445,17 @@
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
"integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -2503,13 +2467,11 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
"integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -2517,25 +2479,17 @@
"node": ">=10"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/@typescript-eslint/utils": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.8.0.tgz",
"integrity": "sha512-L0yFqOCflVqXxiZyXrDr80lnahQfSOfc9ELAAZ75sqicqp2i36kEZZGuUymHNFoYOqxRT05up760b4iGsl02nQ==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.9.0.tgz",
"integrity": "sha512-5KVRQCzZajmT4Ep+NEgjXCvjuypVvYHUW7RHlXzNPuak2oWpVoD1jf5xCP0dPAuNIchjC7uQyvbdaSTFaLqSdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.15",
"@types/semver": "^7.5.8",
"@typescript-eslint/scope-manager": "7.8.0",
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/typescript-estree": "7.8.0",
"semver": "^7.6.0"
"@typescript-eslint/scope-manager": "7.9.0",
"@typescript-eslint/types": "7.9.0",
"@typescript-eslint/typescript-estree": "7.9.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -2548,46 +2502,14 @@
"eslint": "^8.56.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.8.0.tgz",
"integrity": "sha512-q4/gibTNBQNA0lGyYQCmWRS5D15n8rXh4QjK3KV+MBPlTYHpfBUT3D3PaPR/HeNiI9W6R7FvlkcGhNyAoP+caA==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.9.0.tgz",
"integrity": "sha512-iESPx2TNLDNGQLyjKhUvIKprlP49XNEK+MvIf9nIO7ZZaZdbnfWKHnXAgufpxqfA0YryH8XToi4+CjBgVnFTSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "7.8.0",
"@typescript-eslint/types": "7.9.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -2605,9 +2527,9 @@
"dev": true
},
"node_modules/@vitest/coverage-v8": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.5.3.tgz",
"integrity": "sha512-DPyGSu/fPHOJuPxzFSQoT4N/Fu/2aJfZRtEpEp8GI7NHsXBGE94CQ+pbEGBUMFjatsHPDJw/+TAF9r4ens2CNw==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz",
"integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@@ -2628,17 +2550,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "1.5.3"
"vitest": "1.6.0"
}
},
"node_modules/@vitest/expect": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.5.3.tgz",
"integrity": "sha512-y+waPz31pOFr3rD7vWTbwiLe5+MgsMm40jTZbQE8p8/qXyBX3CQsIXRx9XK12IbY7q/t5a5aM/ckt33b4PxK2g==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz",
"integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==",
"dev": true,
"dependencies": {
"@vitest/spy": "1.5.3",
"@vitest/utils": "1.5.3",
"@vitest/spy": "1.6.0",
"@vitest/utils": "1.6.0",
"chai": "^4.3.10"
},
"funding": {
@@ -2646,12 +2568,12 @@
}
},
"node_modules/@vitest/runner": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.5.3.tgz",
"integrity": "sha512-7PlfuReN8692IKQIdCxwir1AOaP5THfNkp0Uc4BKr2na+9lALNit7ub9l3/R7MP8aV61+mHKRGiqEKRIwu6iiQ==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz",
"integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==",
"dev": true,
"dependencies": {
"@vitest/utils": "1.5.3",
"@vitest/utils": "1.6.0",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
@@ -2687,9 +2609,9 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.5.3.tgz",
"integrity": "sha512-K3mvIsjyKYBhNIDujMD2gfQEzddLe51nNOAf45yKRt/QFJcUIeTQd2trRvv6M6oCBHNVnZwFWbQ4yj96ibiDsA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz",
"integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==",
"dev": true,
"dependencies": {
"magic-string": "^0.30.5",
@@ -2733,9 +2655,9 @@
"dev": true
},
"node_modules/@vitest/spy": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.5.3.tgz",
"integrity": "sha512-Llj7Jgs6lbnL55WoshJUUacdJfjU2honvGcAJBxhra5TPEzTJH8ZuhI3p/JwqqfnTr4PmP7nDmOXP53MS7GJlg==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz",
"integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==",
"dev": true,
"dependencies": {
"tinyspy": "^2.2.0"
@@ -2745,9 +2667,9 @@
}
},
"node_modules/@vitest/utils": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.5.3.tgz",
"integrity": "sha512-rE9DTN1BRhzkzqNQO+kw8ZgfeEBCLXiHJwetk668shmNBpSagQxneT5eSqEBLP+cqSiAeecvQmbpFfdMyLcIQA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz",
"integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==",
"dev": true,
"dependencies": {
"diff-sequences": "^29.6.3",
@@ -2792,9 +2714,10 @@
"dev": true
},
"node_modules/@zoom-image/core": {
"version": "0.34.0",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.34.0.tgz",
"integrity": "sha512-2gvhcxJ5J3c4ZAhTRC9opNRnPTnseM5w6IU+SbSSUOT+MN6+0/XX6Qsyebl+ADXdrgOU5Nu8wGfzeCm1QuQSNQ==",
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.34.4.tgz",
"integrity": "sha512-urTvlDhOo4Uo5I4M79E4tkZlOWO/zG/9IlXsHUoCnkqnknnzZPb6HfyJn0KLF7bwZRhrok1dM2ckzGhv+EIlCQ==",
"license": "MIT",
"dependencies": {
"@namnode/store": "^0.1.0"
},
@@ -2804,11 +2727,12 @@
}
},
"node_modules/@zoom-image/svelte": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.10.tgz",
"integrity": "sha512-t/zzAX1T5kLtWKgXz3kOw09+bNVOZn9enLV4+GaUWJPE6PuWPRx7JAaKKDwA+1IVhyo+pDys+0zFf0Rsn2G4jA==",
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.14.tgz",
"integrity": "sha512-fq1jFSLQ9mAW9NfEWg9XtMxSHYcoQfjxuvXrQWKO/4aWFvpZkyvpBT/XFTY7dKcdHqB6uIF89pBQCab2Owgryg==",
"license": "MIT",
"dependencies": {
"@zoom-image/core": "0.34.0"
"@zoom-image/core": "0.34.4"
},
"funding": {
"type": "github",
@@ -2819,9 +2743,9 @@
}
},
"node_modules/acorn": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
"integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"bin": {
"acorn": "bin/acorn"
},
@@ -2963,6 +2887,7 @@
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -3522,12 +3447,12 @@
"integrity": "sha512-3VCXVl2IpFfOyD8drv9DozcNlwmqBqxOlsgkEGyVAzadjlPk1go8YNZyy8QmTnwHPxSFpeCR9OdsStEdVK7qDA=="
},
"node_modules/core-js-compat": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz",
"integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==",
"version": "3.37.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz",
"integrity": "sha512-9TNiImhKvQqSUkOvk/mMRZzOANTiEVC7WaBNhHcKM7x+/5E1l5NvsysR19zuDQScE8k+kfQXWRN3AtS/eOSHpg==",
"dev": true,
"dependencies": {
"browserslist": "^4.22.2"
"browserslist": "^4.23.0"
},
"funding": {
"type": "opencollective",
@@ -3812,6 +3737,7 @@
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
@@ -4126,23 +4052,24 @@
}
},
"node_modules/eslint-plugin-svelte": {
"version": "2.38.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.38.0.tgz",
"integrity": "sha512-IwwxhHzitx3dr0/xo0z4jjDlb2AAHBPKt+juMyKKGTLlKi1rZfA4qixMwnveU20/JTHyipM6keX4Vr7LZFYc9g==",
"version": "2.39.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.39.0.tgz",
"integrity": "sha512-FXktBLXsrxbA+6ZvJK2z/sQOrUKyzSg3fNWK5h0reSCjr2fjAsc9ai/s/JvSl4Hgvz3nYVtTIMwarZH5RcB7BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@jridgewell/sourcemap-codec": "^1.4.15",
"debug": "^4.3.4",
"eslint-compat-utils": "^0.5.0",
"esutils": "^2.0.3",
"known-css-properties": "^0.30.0",
"known-css-properties": "^0.31.0",
"postcss": "^8.4.38",
"postcss-load-config": "^3.1.4",
"postcss-safe-parser": "^6.0.0",
"postcss-selector-parser": "^6.0.16",
"semver": "^7.6.0",
"svelte-eslint-parser": ">=0.35.0 <1.0.0"
"svelte-eslint-parser": ">=0.36.0 <1.0.0"
},
"engines": {
"node": "^14.17.0 || >=16.0.0"
@@ -4160,26 +4087,12 @@
}
}
},
"node_modules/eslint-plugin-svelte/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-svelte/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
"integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -4187,24 +4100,18 @@
"node": ">=10"
}
},
"node_modules/eslint-plugin-svelte/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/eslint-plugin-unicorn": {
"version": "52.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-52.0.0.tgz",
"integrity": "sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==",
"version": "53.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-53.0.0.tgz",
"integrity": "sha512-kuTcNo9IwwUCfyHGwQFOK/HjJAYzbODHN3wP0PgqbW+jbXqpNWxNVpVhj2tO9SixBwuAdmal8rVcWKBxwFnGuw==",
"dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
"@babel/helper-validator-identifier": "^7.24.5",
"@eslint-community/eslint-utils": "^4.4.0",
"@eslint/eslintrc": "^2.1.4",
"@eslint/eslintrc": "^3.0.2",
"ci-info": "^4.0.0",
"clean-regexp": "^1.0.0",
"core-js-compat": "^3.34.0",
"core-js-compat": "^3.37.0",
"esquery": "^1.5.0",
"indent-string": "^4.0.0",
"is-builtin-module": "^3.2.1",
@@ -4213,11 +4120,11 @@
"read-pkg-up": "^7.0.1",
"regexp-tree": "^0.1.27",
"regjsparser": "^0.10.0",
"semver": "^7.5.4",
"semver": "^7.6.1",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=16"
"node": ">=18.18"
},
"funding": {
"url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1"
@@ -4226,6 +4133,70 @@
"eslint": ">=8.56.0"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/@eslint/eslintrc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.0.2.tgz",
"integrity": "sha512-wV19ZEGEMAC1eHgrS7UQPqsdEiCIbTKTasEfcXAigzoXICcqZSjBZEHlZwNVvKg6UBCjSlos84XiLqsRJnIcIg==",
"dev": true,
"dependencies": {
"ajv": "^6.12.4",
"debug": "^4.3.2",
"espree": "^10.0.1",
"globals": "^14.0.0",
"ignore": "^5.2.0",
"import-fresh": "^3.2.1",
"js-yaml": "^4.1.0",
"minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/eslint-visitor-keys": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz",
"integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==",
"dev": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/espree": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.0.1.tgz",
"integrity": "sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww==",
"dev": true,
"dependencies": {
"acorn": "^8.11.3",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/jsesc": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
@@ -4238,26 +4209,11 @@
"node": ">=6"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
"integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
@@ -4265,12 +4221,6 @@
"node": ">=10"
}
},
"node_modules/eslint-plugin-unicorn/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@@ -4872,6 +4822,7 @@
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
@@ -5882,10 +5833,11 @@
}
},
"node_modules/known-css-properties": {
"version": "0.30.0",
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.30.0.tgz",
"integrity": "sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==",
"dev": true
"version": "0.31.0",
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.31.0.tgz",
"integrity": "sha512-sBPIUGTNF0czz0mwGGUoKKJC8Q7On1GPbCSFPfyEsfHb2DyBG0Y4QtV+EVWpINSaiGKZblDNuF5AezxSgOhesQ==",
"dev": true,
"license": "MIT"
},
"node_modules/levn": {
"version": "0.4.1",
@@ -6635,6 +6587,7 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -7694,6 +7647,7 @@
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -8042,9 +7996,10 @@
}
},
"node_modules/svelte": {
"version": "4.2.15",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.15.tgz",
"integrity": "sha512-j9KJSccHgLeRERPlhMKrCXpk2TqL2m5Z+k+OBTQhZOhIdCCd3WfqV+ylPWeipEwq17P/ekiSFWwrVQv93i3bsg==",
"version": "4.2.17",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.17.tgz",
"integrity": "sha512-N7m1YnoXtRf5wya5Gyx3TWuTddI4nAyayyIWFojiWV5IayDYNV5i2mRp/7qNGol4DtxEYxljmrbgp1HM6hUbmQ==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@jridgewell/sourcemap-codec": "^1.4.15",
@@ -8088,10 +8043,11 @@
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.35.0.tgz",
"integrity": "sha512-CtbPseajW0gjwEvHiuzYJkPDjAcHz2FaHt540j6RVYrZgnE6xWkzUBodQ4I3nV+G5AS0Svt8K6aIA/CIU9xT2Q==",
"version": "0.36.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.36.0.tgz",
"integrity": "sha512-/6YmUSr0FAVxW8dXNdIMydBnddPMHzaHirAZ7RrT21XYdgGGZMh0LQG6CZsvAFS4r2Y4ItUuCQc8TQ3urB30mQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"eslint-scope": "^7.2.2",
"eslint-visitor-keys": "^3.4.3",
@@ -8106,7 +8062,7 @@
"url": "https://github.com/sponsors/ota-meshi"
},
"peerDependencies": {
"svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.112"
"svelte": "^3.37.0 || ^4.0.0 || ^5.0.0-next.115"
},
"peerDependenciesMeta": {
"svelte": {
@@ -8138,9 +8094,10 @@
}
},
"node_modules/svelte-maplibre": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.1.tgz",
"integrity": "sha512-JuPcnncvomNMxvasgWpIXFZhpLQkVVAfHIj3BpBQZ+A/OYfH/Y/BknQ/52U2kIUtxIWYz4EFQcboIwav6SOkwQ==",
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.3.tgz",
"integrity": "sha512-yus9Sb1C/QMxbVMInlPcr7SdRpahC+I3dgF/13FUE95CHqYRrfpjaZjABKlR7dFFLvQwmeJlOynGPSX6SF5Klw==",
"license": "MIT",
"dependencies": {
"d3-geo": "^3.1.0",
"just-compare": "^2.3.0",
@@ -8758,9 +8715,9 @@
}
},
"node_modules/vite-node": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.5.3.tgz",
"integrity": "sha512-axFo00qiCpU/JLd8N1gu9iEYL3xTbMbMrbe5nDp9GL0nb6gurIdZLkkFogZXWnE8Oyy5kfSLwNVIcVsnhE7lgQ==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz",
"integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
@@ -8794,16 +8751,16 @@
}
},
"node_modules/vitest": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.5.3.tgz",
"integrity": "sha512-2oM7nLXylw3mQlW6GXnRriw+7YvZFk/YNV8AxIC3Z3MfFbuziLGWP9GPxxu/7nRlXhqyxBikpamr+lEEj1sUEw==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz",
"integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==",
"dev": true,
"dependencies": {
"@vitest/expect": "1.5.3",
"@vitest/runner": "1.5.3",
"@vitest/snapshot": "1.5.3",
"@vitest/spy": "1.5.3",
"@vitest/utils": "1.5.3",
"@vitest/expect": "1.6.0",
"@vitest/runner": "1.6.0",
"@vitest/snapshot": "1.6.0",
"@vitest/spy": "1.6.0",
"@vitest/utils": "1.6.0",
"acorn-walk": "^8.3.2",
"chai": "^4.3.10",
"debug": "^4.3.4",
@@ -8817,7 +8774,7 @@
"tinybench": "^2.5.1",
"tinypool": "^0.8.3",
"vite": "^5.0.0",
"vite-node": "1.5.3",
"vite-node": "1.6.0",
"why-is-node-running": "^2.2.2"
},
"bin": {
@@ -8832,8 +8789,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.5.3",
"@vitest/ui": "1.5.3",
"@vitest/browser": "1.6.0",
"@vitest/ui": "1.6.0",
"happy-dom": "*",
"jsdom": "*"
},
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.103.1",
"version": "1.105.1",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -31,6 +31,7 @@
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
"@types/lodash-es": "^4.17.12",
@@ -42,7 +43,7 @@
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-unicorn": "^53.0.0",
"factory.ts": "^1.4.1",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
@@ -78,6 +79,6 @@
"thumbhash": "^0.1.1"
},
"volta": {
"node": "20.12.2"
"node": "20.13.1"
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ html::-webkit-scrollbar-thumb:hover {
body {
margin: 0;
color: #5f6368;
color: #3a3a3a;
}
input:focus-visible {
@@ -1,5 +1,5 @@
import { matchesShortcut } from '$lib/actions/shortcut';
import type { ActionReturn } from 'svelte/action';
import { matchesShortcut } from './shortcut';
interface Attributes {
/** @deprecated */
+3
View File
@@ -0,0 +1,3 @@
export const initInput = (element: HTMLInputElement) => {
element.focus();
};
@@ -1,5 +1,5 @@
import { shortcuts } from '$lib/actions/shortcut';
import type { Action } from 'svelte/action';
import { shortcuts } from './shortcut';
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
const moveFocus = (direction: 'up' | 'down') => {
@@ -8,6 +8,7 @@
import { handleError } from '$lib/utils/handle-error';
import { JobCommand, JobName, sendJobCommand, type AllJobStatusResponseDto, type JobCommandDto } from '@immich/sdk';
import {
mdiContentDuplicate,
mdiFaceRecognition,
mdiFileJpgBox,
mdiFileXmlBox,
@@ -88,6 +89,12 @@
subtitle: 'Run machine learning on assets to support smart search',
disabled: !$featureFlags.smartSearch,
},
[JobName.DuplicateDetection]: {
icon: mdiContentDuplicate,
title: getJobName(JobName.DuplicateDetection),
subtitle: 'Run machine learning on assets to detect similar images. Relies on Smart Search',
disabled: !$featureFlags.duplicateDetection,
},
[JobName.FaceDetection]: {
icon: mdiFaceRecognition,
title: getJobName(JobName.FaceDetection),
@@ -0,0 +1,242 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
let isConfirmOpen = false;
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = config.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !config.oauth.mobileRedirectUri) {
config.oauth.mobileRedirectUri = window.location.origin + '/api/oauth/mobile-redirect';
}
};
const handleSave = (skipConfirm: boolean) => {
const allMethodsDisabled = !config.oauth.enabled && !config.passwordLogin.enabled;
if (allMethodsDisabled && !skipConfirm) {
isConfirmOpen = true;
return;
}
isConfirmOpen = false;
dispatch('save', { passwordLogin: config.passwordLogin, oauth: config.oauth });
};
</script>
{#if isConfirmOpen}
<ConfirmDialogue
id="disable-login-modal"
title="Disable login"
onClose={() => (isConfirmOpen = false)}
onConfirm={() => handleSave(true)}
>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
<p>
To re-enable, use a
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
Server Command</a
>.
</p>
</div>
</svelte:fragment>
</ConfirmDialogue>
{/if}
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingAccordion key="oauth" title="OAuth" subtitle="Manage OAuth login settings">
<div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer">docs</a
>.
</p>
<SettingSwitch
id="login-with-oauth"
{disabled}
title="ENABLE"
subtitle="Login with OAuth"
bind:checked={config.oauth.enabled}
/>
{#if config.oauth.enabled}
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SIGNING ALGORITHM"
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL CLAIM"
desc="Automatically set the user's storage label to the value of this claim."
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE QUOTA CLAIM"
desc="Automatically set the user's storage quota to the value of this claim."
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="DEFAULT STORAGE QUOTA (GiB)"
desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
bind:value={config.oauth.defaultStorageQuota}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
/>
<SettingSwitch
id="auto-register-new-users"
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
id="auto-launch-oauth"
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
id="mobile-redirect-uri-override"
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
bind:value={config.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)}
/>
{/if}
{/if}
</div>
</SettingAccordion>
<SettingAccordion key="password" title="Password" subtitle="Manage password login settings">
<div class="ml-4 mt-4 flex flex-col gap-4">
<div class="ml-4 mt-4 flex flex-col">
<SettingSwitch
id="enable-password-login"
title="ENABLED"
{disabled}
subtitle="Login with email and password"
bind:checked={config.passwordLogin.enabled}
/>
</div>
</div>
</SettingAccordion>
<SettingButtonsRow
showResetToDefault={!isEqual(savedConfig.passwordLogin, defaultConfig.passwordLogin) ||
!isEqual(savedConfig.oauth, defaultConfig.oauth)}
{disabled}
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['passwordLogin', 'oauth'] })}
on:save={() => handleSave(false)}
/>
</div>
</form>
</div>
</div>
@@ -1,25 +0,0 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
export let onCancel: () => void;
export let onConfirm: () => void;
</script>
<ConfirmDialogue id="disable-login-modal" title="Disable login" onClose={onCancel} {onConfirm}>
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
<p>
To re-enable, use a
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
Server Command</a
>.
</p>
</div>
</svelte:fragment>
</ConfirmDialogue>
@@ -276,6 +276,15 @@
isEdited={config.ffmpeg.accel !== savedConfig.ffmpeg.accel}
/>
<SettingSwitch
id="hardware-decoding"
title="HARDWARE DECODING"
{disabled}
subtitle="Applies only to NVENC and RKMPP. Enables end-to-end acceleration instead of only accelerating encoding. May not work on all videos."
bind:checked={config.ffmpeg.accelDecode}
isEdited={config.ffmpeg.accelDecode !== savedConfig.ffmpeg.accelDecode}
/>
<SettingSelect
label="CONSTANT QUALITY MODE"
desc="ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ."
@@ -297,6 +306,7 @@
bind:checked={config.ffmpeg.temporalAQ}
isEdited={config.ffmpeg.temporalAQ !== savedConfig.ffmpeg.temporalAQ}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="PREFERRED HARDWARE DEVICE"
@@ -11,6 +11,7 @@
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
@@ -77,6 +78,37 @@
</div>
</SettingAccordion>
<SettingAccordion
key="duplicate-detection"
title="Duplicate Detection"
subtitle="Use CLIP embeddings to find likely duplicates"
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch
id="enable-duplicate-detection"
title="ENABLED"
subtitle="If disabled, exactly identical assets will still be de-duplicated."
bind:checked={config.machineLearning.duplicateDetection.enabled}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="MAX DETECTION DISTANCE"
bind:value={config.machineLearning.duplicateDetection.maxDistance}
step="0.01"
min={0.001}
max={0.1}
desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
disabled={disabled || $featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
/>
</div>
</SettingAccordion>
<SettingAccordion
key="facial-recognition"
title="Facial Recognition"
@@ -1,213 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField, {
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
const handleToggleOverride = () => {
// click runs before bind
const previouslyEnabled = config.oauth.mobileOverrideEnabled;
if (!previouslyEnabled && !config.oauth.mobileRedirectUri) {
config.oauth.mobileRedirectUri = window.location.origin + '/api/oauth/mobile-redirect';
}
};
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
const handleSave = async () => {
if (!savedConfig.passwordLogin.enabled && savedConfig.oauth.enabled && !config.oauth.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
if (!config.oauth.mobileOverrideEnabled) {
config.oauth.mobileRedirectUri = '';
}
dispatch('save', { oauth: config.oauth });
};
</script>
{#if isConfirmOpen}
<ConfirmDisableLogin onCancel={() => handleConfirm(false)} onConfirm={() => handleConfirm(true)} />
{/if}
<div class="mt-2">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer">docs</a
>.
</p>
<SettingSwitch
id="login-with-oauth"
{disabled}
title="ENABLE"
subtitle="Login with OAuth"
bind:checked={config.oauth.enabled}
/>
{#if config.oauth.enabled}
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={config.oauth.issuerUrl}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.issuerUrl == savedConfig.oauth.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={config.oauth.clientId}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientId == savedConfig.oauth.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={config.oauth.scope}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.scope == savedConfig.oauth.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SIGNING ALGORITHM"
bind:value={config.oauth.signingAlgorithm}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.signingAlgorithm == savedConfig.oauth.signingAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE LABEL CLAIM"
desc="Automatically set the user's storage label to the value of this claim."
bind:value={config.oauth.storageLabelClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageLabelClaim == savedConfig.oauth.storageLabelClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="STORAGE QUOTA CLAIM"
desc="Automatically set the user's storage quota to the value of this claim."
bind:value={config.oauth.storageQuotaClaim}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.storageQuotaClaim == savedConfig.oauth.storageQuotaClaim)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="DEFAULT STORAGE QUOTA (GiB)"
desc="Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota)."
bind:value={config.oauth.defaultStorageQuota}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.defaultStorageQuota == savedConfig.oauth.defaultStorageQuota)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={config.oauth.buttonText}
required={false}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.buttonText == savedConfig.oauth.buttonText)}
/>
<SettingSwitch
id="auto-register-new-users"
title="AUTO REGISTER"
subtitle="Automatically register new users after signing in with OAuth"
bind:checked={config.oauth.autoRegister}
disabled={disabled || !config.oauth.enabled}
/>
<SettingSwitch
id="auto-launch-oauth"
title="AUTO LAUNCH"
subtitle="Start the OAuth login flow automatically upon navigating to the login page"
disabled={disabled || !config.oauth.enabled}
bind:checked={config.oauth.autoLaunch}
/>
<SettingSwitch
id="mobile-redirect-uri-override"
title="MOBILE REDIRECT URI OVERRIDE"
subtitle="Enable when 'app.immich:/' is an invalid redirect URI."
disabled={disabled || !config.oauth.enabled}
on:click={() => handleToggleOverride()}
bind:checked={config.oauth.mobileOverrideEnabled}
/>
{#if config.oauth.mobileOverrideEnabled}
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="MOBILE REDIRECT URI"
bind:value={config.oauth.mobileRedirectUri}
required={true}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.mobileRedirectUri == savedConfig.oauth.mobileRedirectUri)}
/>
{/if}
{/if}
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['oauth'] })}
on:save={() => handleSave()}
showResetToDefault={!isEqual(savedConfig.oauth, defaultConfig.oauth)}
{disabled}
/>
</form>
</div>
</div>
@@ -1,68 +0,0 @@
<script lang="ts">
import type { SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings';
import ConfirmDisableLogin from '../confirm-disable-login.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto;
export let config: SystemConfigDto; // this is the config that is being edited
export let disabled = false;
const dispatch = createEventDispatcher<SettingsEventType>();
let isConfirmOpen = false;
let handleConfirm: (value: boolean) => void;
const openConfirmModal = () => {
return new Promise((resolve) => {
handleConfirm = (value: boolean) => {
isConfirmOpen = false;
resolve(value);
};
isConfirmOpen = true;
});
};
async function handleSave() {
if (!savedConfig.oauth.enabled && savedConfig.passwordLogin.enabled && !config.passwordLogin.enabled) {
const confirmed = await openConfirmModal();
if (!confirmed) {
return;
}
}
dispatch('save', { passwordLogin: config.passwordLogin });
}
</script>
{#if isConfirmOpen}
<ConfirmDisableLogin onCancel={() => handleConfirm(false)} onConfirm={() => handleConfirm(true)} />
{/if}
<div>
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col">
<SettingSwitch
id="enable-password-login"
title="ENABLED"
{disabled}
subtitle="Login with email and password"
bind:checked={config.passwordLogin.enabled}
/>
<SettingButtonsRow
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['passwordLogin'] })}
on:save={() => handleSave()}
showResetToDefault={!isEqual(savedConfig.passwordLogin, defaultConfig.passwordLogin)}
{disabled}
/>
</div>
</form>
</div>
</div>
@@ -29,17 +29,19 @@
{#if group}
<div class="grid">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<p on:click={() => toggleAlbumGroupCollapsing(group.id)} class="w-fit mt-2 pt-2 pr-2 mb-2 hover:cursor-pointer">
<button
on:click={() => toggleAlbumGroupCollapsing(group.id)}
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
aria-expanded={!isCollapsed}
>
<Icon
path={mdiChevronRight}
size="24"
class="inline-block -mt-2.5 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
<span class="ml-1.5 dark:text-immich-dark-fg">({albums.length} {albums.length > 1 ? 'albums' : 'album'})</span>
</p>
<span class="ml-1.5">({albums.length} {albums.length > 1 ? 'albums' : 'album'})</span>
</button>
<hr class="dark:border-immich-dark-gray" />
</div>
{/if}
@@ -7,6 +7,7 @@
import { getShortDateRange } from '$lib/utils/date-time';
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { s } from '$lib/utils';
export let album: AlbumResponseDto;
export let showOwner = false;
@@ -61,11 +62,11 @@
</p>
{/if}
<span class="flex gap-2 text-sm" data-testid="album-details">
<span class="flex gap-2 text-sm dark:text-immich-dark-fg" data-testid="album-details">
{#if showItemCount}
<p>
{album.assetCount.toLocaleString($locale)}
{album.assetCount === 1 ? `item` : `items`}
item{s(album.assetCount)}
</p>
{/if}
@@ -1,8 +1,8 @@
<script lang="ts">
import { autoGrowHeight } from '$lib/utils/autogrow';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
export let id: string;
export let description: string;
@@ -88,7 +88,7 @@
<div class="w-full">{user.name}</div>
<div>Owner</div>
</div>
{#each album.sharedUsers as user (user.id)}
{#each album.albumUsers as { user } (user.id)}
<div class="flex items-center gap-2 py-2">
<div>
<UserAvatar {user} size="md" />
@@ -26,7 +26,7 @@
</script>
<span class="my-2 flex gap-2 text-sm font-medium text-gray-500" data-testid="album-details">
<p>{getDateRange(startDate, endDate)}</p>
<p>·</p>
<p>{album.assetCount} items</p>
<span>{getDateRange(startDate, endDate)}</span>
<span></span>
<span>{album.assetCount} items</span>
</span>
@@ -1,7 +1,7 @@
<script lang="ts">
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
export let id: string;
export let albumName: string;
@@ -14,7 +14,7 @@
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import ThemeButton from '../shared-components/theme-button.svelte';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte';
@@ -75,7 +75,7 @@
{#if sharedLink.allowUpload}
<CircleIconButton
title="Add Photos"
on:click={() => openFileUploadDialog(album.id)}
on:click={() => openFileUploadDialog({ albumId: album.id })}
icon={mdiFileImagePlusOutline}
/>
{/if}
@@ -40,24 +40,30 @@
{#each groupedAlbums as albumGroup (albumGroup.id)}
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4 hover:cursor-pointer"
<button
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
class="flex w-full mt-4 rounded-md"
aria-expanded={!isCollapsed}
>
<tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3">
<td class="text-md text-left -mb-1">
<Icon
path={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="ml-1.5">({albumGroup.albums.length} {albumGroup.albums.length > 1 ? 'albums' : 'album'})</span>
</td>
</tr>
</tbody>
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg"
>
<tr class="flex w-full place-items-center p-2 md:pl-5 md:pr-5 md:pt-3 md:pb-3">
<td class="text-md text-left -mb-1">
<Icon
path={mdiChevronRight}
size="20"
class="inline-block -mt-2 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-2xl">{albumGroup.name}</span>
<span class="ml-1.5">
({albumGroup.albums.length}
{albumGroup.albums.length > 1 ? 'albums' : 'album'})
</span>
</td>
</tr>
</tbody>
</button>
{#if !isCollapsed}
<tbody
class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg mt-4"
@@ -42,8 +42,8 @@
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// Remove the existed shared users from the album
for (const sharedUser of album.sharedUsers) {
users = users.filter((user) => user.id !== sharedUser.id);
for (const sharedUser of album.albumUsers) {
users = users.filter((user) => user.id !== sharedUser.user.id);
}
});
@@ -3,8 +3,8 @@
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { autoGrowHeight } from '$lib/utils/autogrow';
import { clickOutside } from '$lib/utils/click-outside';
import { autoGrowHeight } from '$lib/actions/autogrow';
import { clickOutside } from '$lib/actions/click-outside';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
import {
@@ -25,7 +25,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
import { locale } from '$lib/stores/preferences.store';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@@ -0,0 +1,10 @@
<script lang="ts">
import type { AlbumResponseDto } from '@immich/sdk';
export let album: AlbumResponseDto;
</script>
<span>{album.assetCount} items</span>
{#if album.shared}
<span>• Shared</span>
{/if}
@@ -3,13 +3,13 @@
import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import AlbumListItemDetails from './album-list-item-details.svelte';
const dispatch = createEventDispatcher<{
album: void;
}>();
export let album: AlbumResponseDto;
export let variant: 'simple' | 'full' = 'full';
export let searchQuery = '';
let albumNameArray: string[] = ['', '', ''];
@@ -31,7 +31,7 @@
on:click={() => dispatch('album')}
class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
<div class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId, ThumbnailFormat.Webp)}
@@ -41,18 +41,13 @@
draggable="false"
/>
{/if}
</div>
<div class="flex h-12 flex-col items-start justify-center overflow-hidden">
</span>
<span class="flex h-12 flex-col items-start justify-center overflow-hidden">
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
>
<span class="flex gap-1 text-sm">
{#if variant === 'simple'}
<span>{album.shared ? 'Shared' : ''}</span>
{:else}
<span>{album.assetCount} items</span>
<span>{album.shared ? ' · Shared' : ''} </span>
{/if}
<AlbumListItemDetails {album} />
</span>
</div>
</span>
</button>
@@ -3,9 +3,10 @@
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName } from '$lib/utils';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { AssetJobName, AssetTypeEnum, type AssetResponseDto, type AlbumResponseDto } from '@immich/sdk';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
mdiAccountCircleOutline,
mdiAlertOutline,
@@ -32,6 +33,7 @@
mdiPlaySpeed,
mdiPresentationPlay,
mdiShareVariantOutline,
mdiUpload,
} from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
@@ -49,7 +51,7 @@
export let showSlideshow = false;
export let hasStackChildren = false;
$: isOwner = asset.ownerId === $user?.id;
$: isOwner = $user && asset.ownerId === $user?.id;
type MenuItemEvent =
| 'addToAlbum'
@@ -107,7 +109,10 @@
<div class="text-white">
<CircleIconButton color="opaque" icon={mdiArrowLeft} title="Go back" on:click={() => dispatch('back')} />
</div>
<div class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white">
<div
class="flex w-[calc(100%-3rem)] justify-end gap-2 overflow-hidden text-white"
data-testid="asset-viewer-navbar-actions"
>
{#if showShareButton}
<CircleIconButton
color="opaque"
@@ -243,6 +248,11 @@
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? 'Unarchive' : 'Archive'}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text="Replace with upload"
/>
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}
@@ -14,7 +14,7 @@
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile, unstackAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import {
AssetJobName,
@@ -52,6 +52,7 @@
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
import { navigate } from '$lib/utils/navigation';
import { websocketEvents } from '$lib/stores/websocket';
export let assetStore: AssetStore | null = null;
export let asset: AssetResponseDto;
@@ -88,7 +89,7 @@
let isShowProfileImageCrop = false;
let sharedLink = getSharedLink();
let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
let shouldShowDetailButton = asset.hasMetadata;
let enableDetailPanel = asset.hasMetadata;
let shouldShowShareModal = !asset.isTrashed;
let canCopyImagesToClipboard: boolean;
let slideshowStateUnsubscribe: () => void;
@@ -98,7 +99,7 @@
let isLiked: ActivityResponseDto | null = null;
let numberOfComments: number;
let fullscreenElement: Element;
let unsubscribe: () => void;
$: isFullScreen = fullscreenElement !== null;
$: {
@@ -192,6 +193,11 @@
}
onMount(async () => {
unsubscribe = websocketEvents.on('on_upload_success', (assetUpdate) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
}
});
await navigate({ targetRoute: 'current', assetId: asset.id });
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
@@ -237,6 +243,7 @@
if (shuffleSlideshowUnsubscribe) {
shuffleSlideshowUnsubscribe();
}
unsubscribe?.();
});
$: asset.id && !sharedLink && handlePromiseError(handleGetAllAlbums()); // Update the album information when the asset ID changes
@@ -574,7 +581,7 @@
showZoomButton={asset.type === AssetTypeEnum.Image}
showMotionPlayButton={!!asset.livePhotoVideoId}
showDownloadButton={shouldShowDownloadButton}
showDetailButton={shouldShowDetailButton}
showDetailButton={enableDetailPanel}
showSlideshow={!!assetStore}
hasStackChildren={$stackAssetsStore.length > 0}
showShareButton={shouldShowShareModal}
@@ -600,7 +607,7 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] column-span-1 col-start-1 row-span-1 row-start-2 justify-self-start">
<div class="z-[1001] my-auto column-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<NavigationArea onClick={(e) => navigateAsset('previous', e)} label="View previous asset">
<Icon path={mdiChevronLeft} size="36" ariaHidden />
</NavigationArea>
@@ -633,7 +640,9 @@
{:else}
<VideoViewer
assetId={previewStackedAsset.id}
checksum={previewStackedAsset.checksum}
projectionType={previewStackedAsset.exifInfo?.projectionType}
loopVideo={true}
on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted}
@@ -654,7 +663,9 @@
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
<VideoViewer
assetId={asset.livePhotoVideoId}
checksum={asset.checksum}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
@@ -668,7 +679,9 @@
{:else}
<VideoViewer
assetId={asset.id}
checksum={asset.checksum}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
on:close={closeViewer}
on:onVideoEnded={() => navigateAsset()}
on:onVideoStarted={handleVideoStarted}
@@ -691,14 +704,14 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation}
<div class="z-[1001] col-span-1 col-start-4 row-span-1 row-start-2 justify-self-end">
<div class="z-[1001] my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NavigationArea onClick={(e) => navigateAsset('next', e)} label="View next asset">
<Icon path={mdiChevronRight} size="36" ariaHidden />
</NavigationArea>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && $isShowDetail}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
@@ -0,0 +1,62 @@
<script lang="ts">
import { autoGrowHeight } from '$lib/actions/autogrow';
import { clickOutside } from '$lib/actions/click-outside';
import { shortcut } from '$lib/actions/shortcut';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { tick } from 'svelte';
export let asset: AssetResponseDto;
export let isOwner: boolean;
let textarea: HTMLTextAreaElement;
$: description = asset.exifInfo?.description || '';
$: newDescription = description;
$: if (textarea) {
newDescription;
void tick().then(() => autoGrowHeight(textarea));
}
const handleFocusOut = async () => {
if (description === newDescription) {
return;
}
try {
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
notificationController.show({
type: NotificationType.Info,
message: 'Asset description has been updated',
});
} catch (error) {
handleError(error, 'Cannot update the description');
}
};
</script>
{#if isOwner}
<section class="px-4 mt-10">
<textarea
bind:this={textarea}
class="max-h-[500px] w-full resize-none border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
placeholder="Add a description"
on:focusout={handleFocusOut}
on:input={(e) => (newDescription = e.currentTarget.value)}
value={description}
use:clickOutside={{ onOutclick: void handleFocusOut }}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),
}}
/>
</section>
{:else if description}
<section class="px-4 mt-6">
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
</section>
{/if}
@@ -0,0 +1,87 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
export let isOwner: boolean;
export let asset: AssetResponseDto;
let isShowChangeLocation = false;
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
isShowChangeLocation = false;
try {
asset = await updateAsset({
id: asset.id,
updateAssetDto: { latitude: gps.lat, longitude: gps.lng },
});
} catch (error) {
handleError(error, 'Unable to change location');
}
}
</script>
{#if asset.exifInfo?.city}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? 'Edit location' : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<div>
<p>{asset.exifInfo.city}</p>
{#if asset.exifInfo?.state}
<div class="flex gap-2 text-sm">
<p>{asset.exifInfo.state}</p>
</div>
{/if}
{#if asset.exifInfo?.country}
<div class="flex gap-2 text-sm">
<p>{asset.exifInfo.country}</p>
</div>
{/if}
</div>
</div>
{#if isOwner}
<div>
<Icon path={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
on:click={() => (isShowChangeLocation = true)}
title="Add location"
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<p>Add a location</p>
</div>
<div class="focus:outline-none p-1">
<Icon path={mdiPencil} size="20" />
</div>
</button>
{/if}
{#if isShowChangeLocation}
<Portal>
<ChangeLocation
{asset}
on:confirm={({ detail: gps }) => handleConfirmChangeLocation(gps)}
on:cancel={() => (isShowChangeLocation = false)}
/>
</Portal>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
@@ -7,16 +8,15 @@
import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, isSharedLink, handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { autoGrowHeight } from '$lib/utils/autogrow';
import { clickOutside } from '$lib/utils/click-outside';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils';
import { delay, isFlipped } from '$lib/utils/asset-utils';
import {
ThumbnailFormat,
getAssetInfo,
updateAsset,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
} from '@immich/sdk';
import {
mdiCalendar,
@@ -26,31 +26,35 @@
mdiEyeOff,
mdiImageOutline,
mdiInformationOutline,
mdiMapMarkerOutline,
mdiPencil,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { createEventDispatcher, onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { asByteUnitString } from '../../utils/byte-units';
import { handleError } from '../../utils/handle-error';
import { asByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
import ChangeLocation from '../shared-components/change-location.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { shortcut } from '$lib/utils/shortcut';
import AlbumListItemDetails from './album-list-item-details.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
export let currentAlbum: AlbumResponseDto | null = null;
const getDimensions = (exifInfo: ExifResponseDto) => {
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
if (isFlipped(exifInfo.orientation)) {
return { width: height, height: width };
}
return { width, height };
};
let showAssetPath = false;
let textArea: HTMLTextAreaElement;
let description: string;
let originalDescription: string;
let showEditFaces = false;
let previousId: string;
@@ -67,16 +71,11 @@
$: isOwner = $user?.id === asset.ownerId;
const handleNewAsset = async (newAsset: AssetResponseDto) => {
description = newAsset?.exifInfo?.description || '';
// Get latest description from server
// TODO: check if reloading asset data is necessary
if (newAsset.id && !isSharedLink()) {
const data = await getAssetInfo({ id: asset.id });
people = data?.people || undefined;
description = data.exifInfo?.description || '';
}
originalDescription = description;
};
$: handlePromiseError(handleNewAsset(asset));
@@ -119,28 +118,10 @@
const handleRefreshPeople = async () => {
await getAssetInfo({ id: asset.id }).then((data) => {
people = data?.people || undefined;
textArea.value = data?.exifInfo?.description || '';
});
showEditFaces = false;
};
const handleFocusOut = async () => {
textArea.blur();
if (description === originalDescription) {
return;
}
originalDescription = description;
try {
await updateAsset({ id: asset.id, updateAssetDto: { description } });
notificationController.show({
type: NotificationType.Info,
message: 'Asset description has been updated',
});
} catch (error) {
handleError(error, 'Cannot update the description');
}
};
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
let isShowChangeDate = false;
@@ -153,18 +134,6 @@
handleError(error, 'Unable to change date');
}
}
let isShowChangeLocation = false;
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
isShowChangeLocation = false;
try {
await updateAsset({ id: asset.id, updateAssetDto: { latitude: gps.lat, longitude: gps.lng } });
} catch (error) {
handleError(error, 'Unable to change location');
}
}
</script>
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
@@ -187,31 +156,7 @@
</section>
{/if}
{#if isOwner}
<section class="px-4 mt-10">
{#key asset.id}
<textarea
disabled={!isOwner || isSharedLink()}
bind:this={textArea}
class="max-h-[500px]
w-full resize-none border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
placeholder={isOwner ? 'Add a description' : ''}
on:focusout={handleFocusOut}
on:input={() => autoGrowHeight(textArea)}
bind:value={description}
use:autoGrowHeight
use:clickOutside
on:outclick={handleFocusOut}
use:shortcut={{
shortcut: { key: 'Enter', ctrl: true },
onShortcut: () => handlePromiseError(handleFocusOut()),
}}
/>
{/key}
</section>
{:else if description}
<p class="px-4 break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
{/if}
<DetailPanelDescription {asset} {isOwner} />
{#if !isSharedLink() && people?.numberOfFaces && people?.numberOfFaces > 0}
<section class="px-4 py-4 text-sm">
@@ -410,8 +355,8 @@
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p>
{/if}
<p>{asset.exifInfo.exifImageHeight} x {asset.exifInfo.exifImageWidth}</p>
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if}
<p>{asByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
</div>
@@ -453,65 +398,7 @@
</div>
{/if}
{#if asset.exifInfo?.city}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4"
on:click={() => (isOwner ? (isShowChangeLocation = true) : null)}
title={isOwner ? 'Edit location' : ''}
class:hover:dark:text-immich-dark-primary={isOwner}
class:hover:text-immich-primary={isOwner}
>
<div class="flex gap-4">
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
<div>
<p>{asset.exifInfo.city}</p>
{#if asset.exifInfo?.state}
<div class="flex gap-2 text-sm">
<p>{asset.exifInfo.state}</p>
</div>
{/if}
{#if asset.exifInfo?.country}
<div class="flex gap-2 text-sm">
<p>{asset.exifInfo.country}</p>
</div>
{/if}
</div>
</div>
{#if isOwner}
<div>
<Icon path={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full text-left justify-between place-items-start gap-4 py-4 rounded-lg hover:dark:text-immich-dark-primary hover:text-immich-primary"
on:click={() => (isShowChangeLocation = true)}
title="Add location"
>
<div class="flex gap-4">
<div>
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
</div>
<p>Add a location</p>
</div>
<div class="focus:outline-none p-1">
<Icon path={mdiPencil} size="20" />
</div>
</button>
{/if}
{#if isShowChangeLocation}
<ChangeLocation
{asset}
on:confirm={({ detail: gps }) => handleConfirmChangeLocation(gps)}
on:cancel={() => (isShowChangeLocation = false)}
/>
{/if}
<DetailPanelLocation {isOwner} {asset} />
</div>
</section>
@@ -560,7 +447,7 @@
</div>
{/if}
{#if currentAlbum && currentAlbum.sharedUsers.length > 0 && asset.owner}
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
<section class="px-6 dark:text-immich-dark-fg mt-4">
<p class="text-sm">SHARED BY</p>
<div class="flex gap-4 pt-4">
@@ -597,10 +484,7 @@
<p class="dark:text-immich-dark-primary">{album.albumName}</p>
<div class="flex flex-col gap-0 text-sm">
<div>
<span>{album.assetCount} items</span>
{#if album.shared}
<span> • Shared</span>
{/if}
<AlbumListItemDetails {album} />
</div>
</div>
</div>
@@ -5,7 +5,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="group flex h-full place-items-center hover:cursor-pointer" on:click={onClick}>
<div class="my-auto group hover:cursor-pointer" on:click={onClick}>
<button
class="mx-4 rounded-full p-3 text-gray-500 transition group-hover:bg-gray-500 group-hover:text-white"
aria-label={label}
@@ -3,10 +3,10 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { downloadRequest, getAssetFileUrl, handlePromiseError } from '$lib/utils';
import { getAssetFileUrl, handlePromiseError } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
import { useZoomImageWheel } from '@zoom-image/svelte';
import { onDestroy, onMount } from 'svelte';
@@ -24,12 +24,14 @@
export let haveFadeTransition = true;
let imgElement: HTMLDivElement;
let assetData: string;
let abortController: AbortController;
let hasZoomed = false;
let assetFileUrl: string = '';
let copyImageToClipboard: (source: string) => Promise<Blob>;
let canCopyImagesToClipboard: () => boolean;
let imageLoaded: boolean = false;
let imageError: boolean = false;
// set to true when am image has been zoomed, to force loading of the original image regardless
// of app settings
let forceLoadOriginal: boolean = false;
const loadOriginalByDefault = $alwaysLoadOriginalFile && isWebCompatibleImage(asset);
@@ -40,60 +42,53 @@
});
}
$: {
preload({ preloadAssets, loadOriginal: loadOriginalByDefault });
}
$: assetFileUrl = load(asset.id, !loadOriginalByDefault || forceLoadOriginal, false, asset.checksum);
onMount(async () => {
// Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295
// TODO: Move to regular import once the package correctly supports ESM.
const module = await import('copy-image-clipboard');
copyImageToClipboard = module.copyImageToClipboard;
canCopyImagesToClipboard = module.canCopyImagesToClipboard;
imageLoaded = false;
await loadAssetData({ loadOriginal: loadOriginalByDefault });
});
onDestroy(() => {
$boundingBoxesArray = [];
abortController?.abort();
});
const loadAssetData = async ({ loadOriginal }: { loadOriginal: boolean }) => {
try {
abortController?.abort();
abortController = new AbortController();
// TODO: Use sdk once it supports signals
const { data } = await downloadRequest({
url: getAssetFileUrl(asset.id, !loadOriginal, false),
signal: abortController.signal,
});
assetData = URL.createObjectURL(data);
imageLoaded = true;
if (!preloadAssets) {
return;
const preload = ({
preloadAssets,
loadOriginal,
}: {
preloadAssets: AssetResponseDto[] | null;
loadOriginal: boolean;
}) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) {
let img = new Image();
img.src = getAssetFileUrl(preloadAsset.id, !loadOriginal, false, preloadAsset.checksum);
}
for (const preloadAsset of preloadAssets) {
if (preloadAsset.type === AssetTypeEnum.Image) {
await downloadRequest({
url: getAssetFileUrl(preloadAsset.id, !loadOriginal, false),
signal: abortController.signal,
});
}
}
} catch {
imageLoaded = false;
}
};
const load = (assetId: string, isWeb: boolean, isThumb: boolean, checksum: string) => {
const assetUrl = getAssetFileUrl(assetId, isWeb, isThumb, checksum);
// side effect, only flag imageLoaded when url is different
imageLoaded = assetFileUrl === assetUrl;
return assetUrl;
};
const doCopy = async () => {
if (!canCopyImagesToClipboard()) {
return;
}
try {
await copyImageToClipboard(assetData);
await copyImageToClipboard(assetFileUrl);
notificationController.show({
type: NotificationType.Info,
message: 'Copied image to clipboard.',
@@ -122,18 +117,14 @@
zoomImageWheelState.subscribe((state) => {
photoZoomState.set(state);
if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed && !$alwaysLoadOriginalFile) {
hasZoomed = true;
handlePromiseError(loadAssetData({ loadOriginal: true }));
}
forceLoadOriginal = state.currentZoom > 1 && isWebCompatibleImage(asset) ? true : false;
});
const onCopyShortcut = () => {
const onCopyShortcut = (event: KeyboardEvent) => {
if (window.getSelection()?.type === 'Range') {
return;
}
event.preventDefault();
handlePromiseError(doCopy());
};
</script>
@@ -142,45 +133,57 @@
on:copyImage={doCopy}
on:zoomImage={doZoomImage}
use:shortcuts={[
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut },
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
]}
/>
<div
bind:this={element}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
class="relative h-full select-none"
>
{#if imageError}
<div class="h-full flex items-center justify-center">Error loading image</div>
{/if}
<div bind:this={element} class="relative h-full select-none">
<img
style="display:none"
src={assetFileUrl}
alt={getAltText(asset)}
on:load={() => (imageLoaded = true)}
on:error={() => (imageError = imageLoaded = true)}
/>
{#if !imageLoaded}
<div class="flex h-full items-center justify-center">
<div class:hidden={imageLoaded} class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else}
<div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
{:else if !imageError}
{#key assetFileUrl}
<div
bind:this={imgElement}
class:hidden={!imageLoaded}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={assetFileUrl}
alt={getAltText(asset)}
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img
src={assetData}
bind:this={$photoViewer}
src={assetFileUrl}
alt={getAltText(asset)}
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{/if}
<img
bind:this={$photoViewer}
src={assetData}
alt={getAltText(asset)}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/>
{/each}
</div>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/>
{/each}
</div>
{/key}
{/if}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { videoViewerVolume } from '$lib/stores/preferences.store';
import { loopVideo as loopVideoPreference, videoViewerVolume, videoViewerMuted } from '$lib/stores/preferences.store';
import { getAssetFileUrl, getAssetThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { ThumbnailFormat } from '@immich/sdk';
@@ -8,18 +8,27 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let assetId: string;
export let loopVideo: boolean;
export let checksum: string;
let element: HTMLVideoElement | undefined = undefined;
let isVideoLoading = true;
let assetFileUrl: string;
$: {
const next = getAssetFileUrl(assetId, false, true, checksum);
if (assetFileUrl !== next) {
assetFileUrl = next;
element && element.load();
}
}
const dispatch = createEventDispatcher<{ onVideoEnded: void; onVideoStarted: void }>();
const handleCanPlay = async (event: Event) => {
try {
const video = event.currentTarget as HTMLVideoElement;
video.muted = true;
await video.play();
video.muted = false;
dispatch('onVideoStarted');
} catch (error) {
handleError(error, 'Unable to play video');
@@ -36,16 +45,18 @@
>
<video
bind:this={element}
loop={$loopVideoPreference && loopVideo}
autoplay
playsinline
controls
class="h-full object-contain"
on:canplay={handleCanPlay}
on:ended={() => dispatch('onVideoEnded')}
bind:muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg)}
poster={getAssetThumbnailUrl(assetId, ThumbnailFormat.Jpeg, checksum)}
>
<source src={getAssetFileUrl(assetId, false, true)} type="video/mp4" />
<source src={assetFileUrl} type="video/mp4" />
<track kind="captions" />
</video>
@@ -6,10 +6,12 @@
export let assetId: string;
export let projectionType: string | null | undefined;
export let checksum: string;
export let loopVideo: boolean;
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} />
{:else}
<VideoNativeViewer {assetId} on:onVideoEnded on:onVideoStarted />
<VideoNativeViewer {loopVideo} {checksum} {assetId} on:onVideoEnded on:onVideoStarted />
{/if}
@@ -21,7 +21,7 @@
import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
const dispatch = createEventDispatcher<{
select: { asset: AssetResponseDto };
@@ -180,7 +180,7 @@
{#if asset.resized}
<ImageThumbnail
url={getAssetThumbnailUrl(asset.id, format)}
url={getAssetThumbnailUrl(asset.id, format, asset.checksum)}
altText={getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@@ -196,7 +196,7 @@
{#if asset.type === AssetTypeEnum.Video}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.id, false, true)}
url={getAssetFileUrl(asset.id, false, true, asset.checksum)}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={timeToSeconds(asset.duration)}
@@ -208,7 +208,7 @@
{#if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full">
<VideoThumbnail
url={getAssetFileUrl(asset.livePhotoVideoId, false, true)}
url={getAssetFileUrl(asset.livePhotoVideoId, false, true, asset.checksum)}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}
@@ -43,7 +43,7 @@
'text-immich-primary dark:text-immich-dark-primary enabled:dark:hover:bg-immich-dark-primary/10 enabled:hover:bg-immich-primary/10',
'light-red': 'bg-[#F9DEDC] text-[#410E0B] enabled:hover:bg-red-50',
red: 'bg-red-500 text-white enabled:hover:bg-red-400',
green: 'bg-green-500 text-gray-800 enabled:hover:bg-green-400/90',
green: 'bg-green-400 text-gray-800 enabled:hover:bg-green-400/90',
gray: 'bg-gray-500 dark:bg-gray-200 enabled:hover:bg-gray-500/75 enabled:dark:hover:bg-gray-200/80 text-white dark:text-immich-dark-gray',
'transparent-gray':
'dark:text-immich-dark-fg enabled:hover:bg-immich-primary/5 enabled:hover:text-gray-700 enabled:hover:dark:text-immich-dark-fg enabled:dark:hover:bg-immich-dark-primary/25',
@@ -17,4 +17,9 @@
{value}
on:input={(e) => (updatedValue = e.currentTarget.value)}
on:blur={() => (value = updatedValue)}
on:keydown={(e) => {
if (e.key === 'Enter') {
value = updatedValue;
}
}}
/>
@@ -17,7 +17,7 @@
import { isEqual } from 'lodash-es';
import LinkButton from './buttons/link-button.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { fly } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
@@ -1,4 +1,5 @@
<script lang="ts">
import { initInput } from '$lib/actions/focus';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import { maximumLengthSearchPeople, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
@@ -70,10 +71,6 @@
}
};
const initInput = (element: HTMLInputElement) => {
element.focus();
};
const handleReset = () => {
reset();
onReset();
@@ -1,24 +1,46 @@
<script lang="ts" context="module">
export enum ToggleVisibilty {
HIDE_ALL = 'hide-all',
HIDE_UNNANEMD = 'hide-unnamed',
VIEW_ALL = 'view-all',
}
</script>
<script lang="ts">
import { fly } from 'svelte/transition';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { quintOut } from 'svelte/easing';
import { createEventDispatcher } from 'svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { mdiClose, mdiEye, mdiEyeOff, mdiRestart } from '@mdi/js';
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
import { locale } from '$lib/stores/preferences.store';
import Button from '$lib/components/elements/buttons/button.svelte';
const dispatch = createEventDispatcher<{
close: void;
reset: void;
change: void;
done: void;
}>();
export let showLoadingSpinner: boolean;
export let toggleVisibility: boolean;
export let toggleVisibility: ToggleVisibilty = ToggleVisibilty.VIEW_ALL;
export let screenHeight: number;
export let countTotalPeople: number;
export let onClose: () => void;
export let onReset: () => void;
export let onChange: (toggleVisibility: ToggleVisibilty) => void;
export let onDone: () => void;
const getNextVisibility = (toggleVisibility: ToggleVisibilty) => {
if (toggleVisibility === ToggleVisibilty.VIEW_ALL) {
return ToggleVisibilty.HIDE_UNNANEMD;
} else if (toggleVisibility === ToggleVisibilty.HIDE_UNNANEMD) {
return ToggleVisibilty.HIDE_ALL;
} else {
return ToggleVisibilty.VIEW_ALL;
}
};
const toggleIconOptions: Record<ToggleVisibilty, string> = {
[ToggleVisibilty.HIDE_ALL]: mdiEyeOff,
[ToggleVisibilty.HIDE_UNNANEMD]: mdiEyeSettings,
[ToggleVisibilty.VIEW_ALL]: mdiEye,
};
$: toggleIcon = toggleIconOptions[toggleVisibility];
</script>
<section
@@ -29,7 +51,7 @@
class="fixed top-0 z-10 flex h-16 w-full items-center justify-between border-b bg-white p-1 dark:border-immich-dark-gray dark:bg-black dark:text-immich-dark-fg md:p-8"
>
<div class="flex items-center">
<CircleIconButton title="Close" icon={mdiClose} on:click={() => dispatch('close')} />
<CircleIconButton title="Close" icon={mdiClose} on:click={onClose} />
<div class="flex gap-2 items-center">
<p class="ml-2">Show & hide people</p>
<p class="text-sm text-gray-400 dark:text-gray-600">({countTotalPeople.toLocaleString($locale)})</p>
@@ -37,15 +59,15 @@
</div>
<div class="flex items-center justify-end">
<div class="flex items-center md:mr-8">
<CircleIconButton title="Reset people visibility" icon={mdiRestart} on:click={() => dispatch('reset')} />
<CircleIconButton title="Reset people visibility" icon={mdiRestart} on:click={onReset} />
<CircleIconButton
title="Toggle visibility"
icon={toggleVisibility ? mdiEye : mdiEyeOff}
on:click={() => dispatch('change')}
icon={toggleIcon}
on:click={() => onChange(getNextVisibility(toggleVisibility))}
/>
</div>
{#if !showLoadingSpinner}
<Button on:click={() => dispatch('done')} size="sm" rounded="lg">Done</Button>
<Button on:click={onDone} size="sm" rounded="lg">Done</Button>
{:else}
<LoadingSpinner />
{/if}
@@ -20,6 +20,7 @@
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import FaceThumbnail from './face-thumbnail.svelte';
import PeopleList from './people-list.svelte';
import { s } from '$lib/utils';
export let assetIds: string[];
export let personAssets: PersonResponseDto;
@@ -78,7 +79,7 @@
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
notificationController.show({
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to a new person`,
message: `Re-assigned ${assetIds.length} asset${s(assetIds.length)} to a new person`,
type: NotificationType.Info,
});
} catch (error) {
@@ -98,7 +99,7 @@
if (selectedPerson) {
await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } });
notificationController.show({
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to ${
message: `Re-assigned ${assetIds.length} asset${s(assetIds.length)} to ${
selectedPerson.name || 'an existing person'
}`,
type: NotificationType.Info,
@@ -1,7 +1,7 @@
<script lang="ts">
import { copyToClipboard } from '$lib/utils';
import { mdiKeyVariant } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
@@ -11,12 +11,6 @@
done: void;
}>();
const handleDone = () => dispatch('done');
let canCopyImagesToClipboard = true;
onMount(async () => {
const module = await import('copy-image-clipboard');
canCopyImagesToClipboard = module.canCopyImagesToClipboard();
});
</script>
<FullScreenModal id="api-key-secret-modal" title="API key" icon={mdiKeyVariant} onClose={() => handleDone()}>
@@ -32,9 +26,7 @@
</div>
<svelte:fragment slot="sticky-bottom">
{#if canCopyImagesToClipboard}
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
{/if}
<Button on:click={() => copyToClipboard(secret)} fullwidth>Copy to Clipboard</Button>
<Button on:click={() => handleDone()} fullwidth>Done</Button>
</svelte:fragment>
</FullScreenModal>
@@ -40,7 +40,7 @@
folders that contain files you don't want to import, such as RAW files.
<br /><br />
Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named "Raw",
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore".
use "**/Raw/**". To ignore all files ending in ".tif", use "**/*.tif". To ignore an absolute path, use "/path/to/ignore/**".
</p>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="exclusionPattern">Pattern</label>
@@ -9,6 +9,7 @@
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { s } from '$lib/utils';
export let library: LibraryResponseDto;
@@ -56,14 +57,9 @@
type: NotificationType.Info,
});
}
} else if (failedPaths === 1) {
notificationController.show({
message: `${failedPaths} path failed validation`,
type: NotificationType.Warning,
});
} else {
notificationController.show({
message: `${failedPaths} paths failed validation`,
message: `${failedPaths} path${s(failedPaths)} failed validation`,
type: NotificationType.Warning,
});
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { LibraryType, type LibraryResponseDto } from '@immich/sdk';
import { type LibraryResponseDto } from '@immich/sdk';
import { mdiPencilOutline } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
import { handleError } from '../../utils/handle-error';
@@ -27,14 +27,14 @@
const dispatch = createEventDispatcher<{
cancel: void;
submit: { library: Partial<LibraryResponseDto>; type: LibraryType };
submit: Partial<LibraryResponseDto>;
}>();
const handleCancel = () => {
dispatch('cancel');
};
const handleSubmit = () => {
dispatch('submit', { library, type: LibraryType.External });
dispatch('submit', library);
};
const handleAddExclusionPattern = () => {
@@ -30,7 +30,12 @@
<SettingSwitch id="allow-dark-mode" title="Allow dark mode" bind:checked={settings.allowDarkMode} />
<SettingSwitch id="only-favorites" title="Only favorites" bind:checked={settings.onlyFavorites} />
<SettingSwitch id="include-archived" title="Include archived" bind:checked={settings.includeArchived} />
<SettingSwitch id="include-shared-with-me" title="Include shared with me" bind:checked={settings.withPartners} />
<SettingSwitch
id="include-shared-partner-assets"
title="Include shared partner assets"
bind:checked={settings.withPartners}
/>
<SettingSwitch id="include-shared-albums" title="Include shared albums" bind:checked={settings.withSharedAlbums} />
{#if customDateRange}
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8">
@@ -19,7 +19,7 @@
import { type Viewport } from '$lib/stores/assets.store';
import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { ThumbnailFormat, getMemoryLane, type AssetResponseDto } from '@immich/sdk';
import {
@@ -9,6 +9,7 @@
import { mdiDeleteOutline, mdiImageRemoveOutline } from '@mdi/js';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { s } from '$lib/utils';
export let album: AlbumResponseDto;
export let onRemove: ((assetIds: string[]) => void) | undefined;
@@ -33,7 +34,7 @@
const count = results.filter(({ success }) => success).length;
notificationController.show({
type: NotificationType.Info,
message: `Removed ${count} asset${count === 1 ? '' : 's'}`,
message: `Removed ${count} asset${s(count)}`,
});
clearSelect();
@@ -153,7 +153,7 @@
</div>
{/if}
<span class="truncate first-letter:capitalize" title={groupTitle}>
<span class="w-full truncate first-letter:capitalize" title={groupTitle}>
{groupTitle}
</span>
</div>
@@ -8,7 +8,7 @@
import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { deleteAssets } from '$lib/utils/actions';
import { type ShortcutOptions, shortcuts } from '$lib/utils/shortcut';
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { formatGroupTitle, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
@@ -101,6 +101,12 @@
}
};
const focusElement = () => {
if (document.activeElement === document.body) {
element.focus();
}
};
$: shortcutList = (() => {
if ($isSearchEnabled || $showAssetViewer) {
return [];
@@ -111,8 +117,8 @@
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
{ shortcut: { key: 'PageUp' }, onShortcut: () => (element.scrollTop = 0) },
{ shortcut: { key: 'PageDown' }, onShortcut: () => (element.scrollTop = viewport.height) },
{ shortcut: { key: 'PageDown' }, preventDefault: false, onShortcut: focusElement },
{ shortcut: { key: 'PageUp' }, preventDefault: false, onShortcut: focusElement },
];
if ($isMultiSelectState) {
@@ -406,7 +412,8 @@
<!-- Right margin MUST be equal to the width of immich-scrubbable-scrollbar -->
<section
id="asset-grid"
class="scrollbar-hidden h-full overflow-y-auto pb-[60px] {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
class="scrollbar-hidden h-full overflow-y-auto outline-none pb-[60px] {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
tabindex="-1"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
bind:this={element}
@@ -1,5 +1,5 @@
<script lang="ts" context="module">
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { createContext } from '$lib/utils/context';
const { get: getMenuContext, set: setContext } = createContext<() => void>();
@@ -3,6 +3,7 @@
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import Checkbox from '$lib/components/elements/checkbox.svelte';
import { s } from '$lib/utils';
export let size: number;
@@ -23,7 +24,7 @@
<ConfirmDialogue
id="permanently-delete-asset-modal"
title="Permanently delete asset{size > 1 ? 's' : ''}"
title="Permanently delete asset{s(size)}"
confirmText="Delete"
onConfirm={handleConfirm}
onClose={() => dispatch('cancel')}
@@ -6,6 +6,7 @@
import AlbumListItem from '../asset-viewer/album-list-item.svelte';
import { normalizeSearchString } from '$lib/utils/string-utils';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { initInput } from '$lib/actions/focus';
let albums: AlbumResponseDto[] = [];
let recentAlbums: AlbumResponseDto[] = [];
@@ -72,6 +73,7 @@
class="border-b-4 border-immich-bg bg-immich-bg px-6 py-2 text-2xl focus:border-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:focus:border-immich-dark-primary"
placeholder="Search"
bind:value={search}
use:initInput
/>
<div class="immich-scrollbar overflow-y-auto">
<button
@@ -89,7 +91,7 @@
{#if !shared && search.length === 0}
<p class="px-5 py-3 text-xs">RECENT</p>
{#each recentAlbums as album (album.id)}
<AlbumListItem variant="simple" {album} on:album={() => handleSelect(album)} />
<AlbumListItem {album} on:album={() => handleSelect(album)} />
{/each}
{/if}
@@ -4,13 +4,13 @@
import { timeDebounceOnSearch } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import LoadingSpinner from './loading-spinner.svelte';
import { delay } from '$lib/utils/asset-utils';
import { timeToLoadTheMap } from '$lib/constants';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import SearchBar from '../elements/search-bar.svelte';
import { listNavigation } from '$lib/utils/list-navigation';
import { listNavigation } from '$lib/actions/list-navigation';
export let asset: AssetResponseDto | undefined = undefined;
@@ -15,9 +15,9 @@
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
import { createEventDispatcher, tick } from 'svelte';
import type { FormEventHandler } from 'svelte/elements';
import { shortcuts } from '$lib/utils/shortcut';
import { clickOutside } from '$lib/utils/click-outside';
import { focusOutside } from '$lib/utils/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import { clickOutside } from '$lib/actions/click-outside';
import { focusOutside } from '$lib/actions/focus-outside';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
/**
@@ -1,7 +1,7 @@
<script lang="ts">
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
export let direction: 'left' | 'right' = 'right';
export let x = 0;
@@ -1,5 +1,5 @@
<script lang="ts">
import { shortcuts } from '$lib/utils/shortcut';
import { shortcuts } from '$lib/actions/shortcut';
import { onMount, onDestroy } from 'svelte';
let container: HTMLElement;
@@ -1,5 +1,5 @@
<script lang="ts">
import { clickOutside } from '../../utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { fade } from 'svelte/transition';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
import ModalHeader from '$lib/components/shared-components/modal-header.svelte';
@@ -6,7 +6,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { resetSavedUser, user } from '$lib/stores/user.store';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { logout } from '@immich/sdk';
import { mdiCog, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
@@ -2,15 +2,15 @@
import { AppRoute } from '$lib/constants';
import { goto } from '$app/navigation';
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterBox from './search-filter-box.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils';
import { shortcuts } from '$lib/utils/shortcut';
import { focusOutside } from '$lib/utils/focus-outside';
import { shortcuts } from '$lib/actions/shortcut';
import { focusOutside } from '$lib/actions/focus-outside';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
export let value = '';
@@ -0,0 +1,30 @@
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
// @ts-expect-error the import works but tsc check errors
import SettingInputField, { SettingInputFieldType } from './setting-input-field.svelte';
describe('SettingInputField component', () => {
it('validates number input on blur', async () => {
const { getByRole } = render(SettingInputField, {
props: {
label: 'test-number-input',
inputType: SettingInputFieldType.NUMBER,
value: 0,
min: 0,
max: 100,
step: '0.1',
},
});
const user = userEvent.setup();
const numberInput = getByRole('spinbutton') as HTMLInputElement;
expect(numberInput.value).toEqual('0');
await user.click(numberInput);
await user.keyboard('100.1');
expect(numberInput.value).toEqual('100.1');
await user.click(document.body);
expect(numberInput.value).toEqual('100');
});
});
@@ -25,9 +25,7 @@
export let isEdited = false;
export let passwordAutocomplete: string = 'current-password';
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
const validateInput = () => {
if (inputType === SettingInputFieldType.NUMBER) {
let newValue = Number(value) || 0;
if (newValue < min) {
@@ -79,7 +77,8 @@
{step}
{required}
{value}
on:input={handleInput}
on:input={(e) => (value = e.currentTarget.value)}
on:blur={validateInput}
{disabled}
{title}
/>
@@ -4,17 +4,23 @@
import { getAlbumCount, getAssetStatistics } from '@immich/sdk';
import {
mdiAccount,
mdiAccountOutline,
mdiAccountMultiple,
mdiAccountMultipleOutline,
mdiArchiveArrowDown,
mdiArchiveArrowDownOutline,
mdiHeartMultiple,
mdiHeartMultipleOutline,
mdiHeart,
mdiHeartOutline,
mdiImageAlbum,
mdiImageMultiple,
mdiImageMultipleOutline,
mdiMagnify,
mdiMap,
mdiMapOutline,
mdiTrashCan,
mdiTrashCanOutline,
mdiToolbox,
mdiToolboxOutline,
} from '@mdi/js';
import LoadingSpinner from '../loading-spinner.svelte';
import StatusBox from '../status-box.svelte';
@@ -31,9 +37,14 @@
}
};
let isArchiveSelected: boolean;
let isFavoritesSelected: boolean;
let isMapSelected: boolean;
let isPeopleSelected: boolean;
let isPhotosSelected: boolean;
let isSharingSelected: boolean;
let isTrashSelected: boolean;
let isUtilitiesSelected: boolean;
</script>
<SideBarSection>
@@ -60,11 +71,21 @@
{/if}
{#if $featureFlags.map}
<SideBarLink title="Map" routeId="/(user)/map" icon={mdiMap} />
<SideBarLink
title="Map"
routeId="/(user)/map"
bind:isSelected={isMapSelected}
icon={isMapSelected ? mdiMap : mdiMapOutline}
/>
{/if}
{#if $sidebarSettings.people}
<SideBarLink title="People" routeId="/(user)/people" icon={mdiAccount} />
<SideBarLink
title="People"
routeId="/(user)/people"
bind:isSelected={isPeopleSelected}
icon={isPeopleSelected ? mdiAccount : mdiAccountOutline}
/>
{/if}
{#if $sidebarSettings.sharing}
<SideBarLink
@@ -92,7 +113,7 @@
<SideBarLink
title="Favorites"
routeId="/(user)/favorites"
icon={isFavoritesSelected ? mdiHeartMultiple : mdiHeartMultipleOutline}
icon={isFavoritesSelected ? mdiHeart : mdiHeartOutline}
bind:isSelected={isFavoritesSelected}
>
<svelte:fragment slot="moreInformation">
@@ -118,7 +139,19 @@
</svelte:fragment>
</SideBarLink>
<SideBarLink title="Archive" routeId="/(user)/archive" icon={mdiArchiveArrowDownOutline}>
<SideBarLink
title="Utilities"
routeId="/(user)/utilities"
bind:isSelected={isUtilitiesSelected}
icon={isUtilitiesSelected ? mdiToolbox : mdiToolboxOutline}
></SideBarLink>
<SideBarLink
title="Archive"
routeId="/(user)/archive"
bind:isSelected={isArchiveSelected}
icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
>
<svelte:fragment slot="moreInformation">
{#await getStats({ isArchived: true })}
<LoadingSpinner />
@@ -132,7 +165,12 @@
</SideBarLink>
{#if $featureFlags.trash}
<SideBarLink title="Trash" routeId="/(user)/trash" icon={mdiTrashCanOutline}>
<SideBarLink
title="Trash"
routeId="/(user)/trash"
bind:isSelected={isTrashSelected}
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
>
<svelte:fragment slot="moreInformation">
{#await getStats({ isTrashed: true })}
<LoadingSpinner />
@@ -24,8 +24,8 @@
out:fade={{ duration: 100 }}
class="flex flex-col rounded-lg bg-immich-bg text-xs dark:bg-immich-dark-bg"
>
<div class="grid grid-cols-[65px_auto_auto]">
<div class="relative h-[65px]">
<div class="grid grid-cols-[65px_auto_auto] max-h-[70px]">
<div class="relative">
<div in:fade={{ duration: 250 }}>
<ImmichLogo noText class="h-[65px] w-[65px] rounded-bl-lg rounded-tl-lg object-cover p-2" />
</div>
@@ -83,18 +83,14 @@
</div>
</div>
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex h-full flex-col place-content-center place-items-center justify-items-center pr-2">
<button
on:click={() => handleRetry(uploadAsset)}
title="Retry upload"
class="flex h-full w-full place-content-center place-items-center text-sm"
>
<div class="flex h-full flex-col place-content-evenly place-items-center justify-items-center pr-2">
<button on:click={() => handleRetry(uploadAsset)} title="Retry upload" class="flex text-sm">
<span class="text-immich-dark-gray dark:text-immich-dark-fg"><Icon path={mdiRefresh} size="20" /></span>
</button>
<button
on:click={() => uploadAssetsStore.removeUploadAsset(uploadAsset.id)}
title="Dismiss error"
class="flex h-full w-full place-content-center place-items-center text-sm"
class="flex text-sm"
>
<span class="text-immich-error"><Icon path={mdiCancel} size="20" /></span>
</button>
@@ -104,7 +100,7 @@
{#if uploadAsset.state === UploadState.ERROR}
<div class="flex flex-row justify-between">
<p class="w-full rounded-md p-1 px-2 text-justify text-[10px] text-immich-error">
<p class="w-full rounded-md py-1 px-2 text-justify text-[10px] text-immich-error">
{uploadAsset.error}
</p>
</div>
@@ -8,6 +8,7 @@
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { mdiCog, mdiWindowMinimize, mdiCancel, mdiCloudUploadOutline } from '@mdi/js';
import { s } from '$lib/utils';
let showDetail = false;
let showOptions = false;
@@ -36,7 +37,7 @@
on:outroend={() => {
if ($errorCounter > 0) {
notificationController.show({
message: `Upload completed with ${$errorCounter} error${$errorCounter > 1 ? 's' : ''}, refresh the page to see new upload assets.`,
message: `Upload completed with ${$errorCounter} error${s($errorCounter)}, refresh the page to see new upload assets.`,
type: NotificationType.Warning,
});
} else if ($successCounter > 0) {
@@ -47,7 +48,7 @@
}
if ($duplicateCounter > 0) {
notificationController.show({
message: `Skipped ${$duplicateCounter} duplicate asset${$duplicateCounter > 1 ? 's' : ''}`,
message: `Skipped ${$duplicateCounter} duplicate asset${s($duplicateCounter)}`,
type: NotificationType.Warning,
});
}
@@ -3,9 +3,15 @@
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { fallbackLocale, locales } from '$lib/constants';
import { sidebarSettings } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, playVideoThumbnailOnHover, showDeleteModal } from '$lib/stores/preferences.store';
import { colorTheme, locale } from '$lib/stores/preferences.store';
import {
alwaysLoadOriginalFile,
colorTheme,
locale,
loopVideo,
playVideoThumbnailOnHover,
showDeleteModal,
sidebarSettings,
} from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@@ -117,6 +123,15 @@
on:toggle={() => ($playVideoThumbnailOnHover = !$playVideoThumbnailOnHover)}
/>
</div>
<div class="ml-4">
<SettingSwitch
id="loop-video"
title="Loop videos"
subtitle="Enable to automatically loop a video in the detail viewer."
bind:checked={$loopVideo}
on:toggle={() => ($loopVideo = !$loopVideo)}
/>
</div>
<div class="ml-4">
<SettingSwitch
@@ -0,0 +1,133 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { ThumbnailFormat, type AssetResponseDto, type DuplicateResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiCheck, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { s } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { sortBy } from 'lodash-es';
export let duplicate: DuplicateResponseDto;
export let onResolve: (duplicateAssetIds: string[], trashIds: string[]) => void;
let selectedAssetIds = new Set<string>();
$: trashCount = duplicate.assets.length - selectedAssetIds.size;
onMount(() => {
const suggestedAsset = sortBy(duplicate.assets, (asset) => asset.exifInfo?.fileSizeInByte).pop();
if (!suggestedAsset) {
selectedAssetIds = new Set(duplicate.assets[0].id);
return;
}
selectedAssetIds.add(suggestedAsset.id);
selectedAssetIds = selectedAssetIds;
});
const onSelectAsset = (asset: AssetResponseDto) => {
if (selectedAssetIds.has(asset.id)) {
selectedAssetIds.delete(asset.id);
} else {
selectedAssetIds.add(asset.id);
}
selectedAssetIds = selectedAssetIds;
};
const handleResolve = () => {
const trashIds = duplicate.assets.map((asset) => asset.id).filter((id) => !selectedAssetIds.has(id));
const duplicateAssetIds = duplicate.assets.map((asset) => asset.id);
onResolve(duplicateAssetIds, trashIds);
};
</script>
<div class="pt-4 rounded-3xl border dark:border-2 border-gray-300 dark:border-gray-700 max-w-[900px] m-auto mb-16">
<div class="flex flex-wrap gap-1 place-items-center place-content-center px-4 pt-4">
{#each duplicate.assets as asset, index (index)}
{@const isSelected = selectedAssetIds.has(asset.id)}
{@const isFromExternalLibrary = !!asset.libraryId}
{@const assetData = JSON.stringify(asset, null, 2)}
<div class="relative">
<button on:click={() => onSelectAsset(asset)} class="block relative">
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id, ThumbnailFormat.Webp)}
alt={asset.id}
title={`${assetData}`}
class={`w-[250px] h-[250px] object-cover rounded-t-xl border-t-[4px] border-l-[4px] border-r-[4px] border-gray-300 ${isSelected ? 'border-immich-primary dark:border-immich-dark-primary' : 'dark:border-gray-800'} transition-all`}
draggable="false"
/>
<!-- OVERLAY CHIP -->
<div
class={`absolute bottom-2 right-3 ${isSelected ? 'bg-green-400/90' : 'bg-red-300/90'} px-4 py-1 rounded-xl text-xs font-semibold`}
>
{isSelected ? 'Keep' : 'Trash'}
</div>
<!-- EXTERNAL LIBRARY CHIP-->
{#if isFromExternalLibrary}
<div
class="absolute top-2 right-3 bg-immich-primary/90 px-4 py-1 rounded-xl text-xs font-semibold text-white"
>
External
</div>
{/if}
</button>
<!-- ASSET INFO-->
<table
class={`text-xs w-full rounded-b-xl font-semibold ${isSelected ? 'bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-black' : 'bg-gray-200 dark:bg-gray-800 dark:text-white'} mt-0 transition-all`}
>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>{asset.originalFileName}</td>
</tr>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center`}
>
<td>{getAssetResolution(asset)} - {getFileSize(asset)}</td>
</tr>
<tr
class={`h-[32px] ${isSelected ? 'border-immich-primary rounded-xl dark:border-immich-dark-primary' : 'border-gray-300'} text-center `}
>
<td>
{#await getAllAlbums({ assetId: asset.id })}
Scanning for album...
{:then albums}
{#if albums.length === 0}
Not in any album
{:else}
In {albums.length} album{s(albums.length)}
{/if}
{/await}
</td>
</tr>
</table>
</div>
{/each}
</div>
<!-- CONFIRM BUTTONS -->
<div class="flex gap-4 my-4 border-transparent w-full justify-end p-4 h-[85px]">
{#if trashCount === 0}
<Button size="sm" color="primary" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiCheck} size="20" />Keep All
</Button>
{:else}
<Button size="sm" color="red" class="flex place-items-center gap-2" on:click={handleResolve}
><Icon path={mdiTrashCanOutline} size="20" />{trashCount === duplicate.assets.length
? 'Trash All'
: `Trash ${trashCount}`}
</Button>
{/if}
</div>
</div>
@@ -0,0 +1,18 @@
<script lang="ts">
import { mdiContentDuplicate } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
</script>
<a href={AppRoute.DUPLICATES}>
<div class="border border-gray-300 dark:border-immich-dark-gray rounded-3xl pt-1 pb-6 dark:text-white">
<p class="text-xs font-medium p-4">ORGANIZE YOUR LIBRARY</p>
<button class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex gap-4 p-4">
<span
><Icon path={mdiContentDuplicate} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
Review duplicates
</button>
</div>
</a>
+3
View File
@@ -39,6 +39,9 @@ export enum AppRoute {
AUTH_REGISTER = '/auth/register',
AUTH_CHANGE_PASSWORD = '/auth/change-password',
AUTH_ONBOARDING = '/auth/onboarding',
UTILITIES = '/utilities',
DUPLICATES = '/utilities/duplicates',
}
export enum ProjectionType {
+1
View File
@@ -10,6 +10,7 @@ export type UploadAsset = {
id: string;
file: File;
albumId?: string;
assetId?: string;
progress?: number;
state?: UploadState;
startDate?: number;
+5
View File
@@ -47,6 +47,7 @@ export interface MapSettings {
includeArchived: boolean;
onlyFavorites: boolean;
withPartners: boolean;
withSharedAlbums: boolean;
relativeDate: string;
dateAfter: string;
dateBefore: string;
@@ -57,12 +58,14 @@ export const mapSettings = persisted<MapSettings>('map-settings', {
includeArchived: false,
onlyFavorites: false,
withPartners: false,
withSharedAlbums: false,
relativeDate: '',
dateAfter: '',
dateBefore: '',
});
export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {});
export const videoViewerMuted = persisted<boolean>('video-viewer-muted', false, {});
export const isShowDetail = persisted<boolean>('info-opened', false, {});
@@ -135,3 +138,5 @@ export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true,
export const alwaysLoadOriginalFile = persisted<boolean>('always-load-original-file', false, {});
export const playVideoThumbnailOnHover = persisted<boolean>('play-video-thumbnail-on-hover', true, {});
export const loopVideo = persisted<boolean>('loop-video', true, {});
@@ -6,6 +6,7 @@ export type FeatureFlags = ServerFeaturesDto & { loaded: boolean };
export const featureFlags = writable<FeatureFlags>({
loaded: false,
smartSearch: true,
duplicateDetection: false,
facialRecognition: true,
sidecar: true,
map: true,
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ServerInfoResponseDto } from '@immich/sdk';
import type { ServerStorageResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store';
export const serverInfo = writable<ServerInfoResponseDto>();
export const serverInfo = writable<ServerStorageResponseDto>();
+2 -1
View File
@@ -6,7 +6,8 @@ import { user } from './user.store';
export interface ReleaseEvent {
isAvailable: boolean;
checkedAt: Date;
/** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
+25 -11
View File
@@ -5,8 +5,8 @@ import {
AssetJobName,
JobName,
ThumbnailFormat,
defaults,
finishOAuth,
getBaseUrl,
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
@@ -25,6 +25,7 @@ interface DownloadRequestOptions<T = unknown> {
interface UploadRequestOptions {
url: string;
method?: 'POST' | 'PUT';
data: FormData;
onUploadProgress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
}
@@ -64,7 +65,7 @@ export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{
xhr.upload.addEventListener('progress', (event) => onProgress(event));
}
xhr.open('POST', url);
xhr.open(options.method || 'POST', url);
xhr.responseType = 'json';
xhr.send(data);
});
@@ -116,6 +117,7 @@ export const getJobName = (jobName: JobName) => {
[JobName.MetadataExtraction]: 'Extract Metadata',
[JobName.Sidecar]: 'Sidecar Metadata',
[JobName.SmartSearch]: 'Smart Search',
[JobName.DuplicateDetection]: 'Duplicate Detection',
[JobName.FaceDetection]: 'Face Detection',
[JobName.FacialRecognition]: 'Facial Recognition',
[JobName.VideoConversion]: 'Transcode Videos',
@@ -154,26 +156,36 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
const url = new URL(path, 'https://example.com');
url.search = searchParameters.toString();
return defaults.baseUrl + url.pathname + url.search + url.hash;
return getBaseUrl() + url.pathname + url.search + url.hash;
};
export const getAssetFileUrl = (...[assetId, isWeb, isThumb]: [string, boolean, boolean]) => {
export const getAssetFileUrl = (
...[assetId, isWeb, isThumb, checksum]:
| [assetId: string, isWeb: boolean, isThumb: boolean]
| [assetId: string, isWeb: boolean, isThumb: boolean, checksum: string]
) => {
const path = `/asset/file/${assetId}`;
return createUrl(path, { isThumb, isWeb, key: getKey() });
return createUrl(path, { isThumb, isWeb, key: getKey(), c: checksum });
};
export const getAssetThumbnailUrl = (...[assetId, format]: [string, ThumbnailFormat | undefined]) => {
export const getAssetThumbnailUrl = (
...[assetId, format, checksum]:
| [assetId: string, format: ThumbnailFormat | undefined]
| [assetId: string, format: ThumbnailFormat | undefined, checksum: string]
) => {
// checksum (optional) is used as a cache-buster param, since thumbs are
// served with static resource cache headers
const path = `/asset/thumbnail/${assetId}`;
return createUrl(path, { format, key: getKey() });
return createUrl(path, { format, key: getKey(), c: checksum });
};
export const getProfileImageUrl = (...[userId]: [string]) => {
const path = `/user/profile-image/${userId}`;
export const getProfileImageUrl = (userId: string) => {
const path = `/users/${userId}/profile-image`;
return createUrl(path);
};
export const getPeopleThumbnailUrl = (personId: string) => {
const path = `/person/${personId}/thumbnail`;
const path = `/people/${personId}/thumbnail`;
return createUrl(path);
};
@@ -278,4 +290,6 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
promise.catch((error) => console.error(`[utils.ts]:handlePromiseError ${error}`, error));
};
export const memoryLaneTitle = (yearsAgo: number) => `${yearsAgo} ${yearsAgo ? 'years' : 'year'} ago`;
export const s = (count: number) => (count === 1 ? '' : 's');
export const memoryLaneTitle = (yearsAgo: number) => `year${s(yearsAgo)} ago`;
+29 -9
View File
@@ -5,12 +5,13 @@ import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
import { downloadManager } from '$lib/stores/download';
import { downloadRequest, getKey } from '$lib/utils';
import { downloadRequest, getKey, s } from '$lib/utils';
import { createAlbum } from '$lib/utils/album-utils';
import { asByteUnitString } from '$lib/utils/byte-units';
import { encodeHTMLSpecialChars } from '$lib/utils/string-utils';
import {
addAssetsToAlbum as addAssets,
defaults,
getBaseUrl,
getDownloadInfo,
updateAssets,
type AlbumResponseDto,
@@ -38,7 +39,7 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[]) => {
timeout: 5000,
message:
count > 0
? `Added ${count} asset${count === 1 ? '' : 's'} to the album`
? `Added ${count} asset${s(count)} to the album`
: `Asset${assetIds.length === 1 ? ' was' : 's were'} already part of the album`,
button: {
text: 'View Album',
@@ -58,7 +59,7 @@ export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[])
notificationController.show({
type: NotificationType.Info,
timeout: 5000,
message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`,
message: `Added ${assetIds.length} asset${s(assetIds.length)} to ${displayName}`,
html: true,
button: {
text: 'View Album',
@@ -121,7 +122,7 @@ export const downloadArchive = async (fileName: string, options: DownloadInfoDto
// TODO use sdk once it supports progress events
const { data } = await downloadRequest({
method: 'POST',
url: defaults.baseUrl + '/download/archive' + (key ? `?key=${key}` : ''),
url: getBaseUrl() + '/download/archive' + (key ? `?key=${key}` : ''),
data: { assetIds: archive.assetIds },
signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded),
@@ -177,7 +178,7 @@ export const downloadFile = async (asset: AssetResponseDto) => {
// TODO use sdk once it supports progress events
const { data } = await downloadRequest({
method: 'POST',
url: defaults.baseUrl + `/download/asset/${id}` + (key ? `?key=${key}` : ''),
url: getBaseUrl() + `/download/asset/${id}` + (key ? `?key=${key}` : ''),
signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded, event.total),
});
@@ -218,14 +219,33 @@ function isRotated270CW(orientation: number) {
return orientation === 7 || orientation === 8 || orientation === -90;
}
export function isFlipped(orientation?: string | null) {
const value = Number(orientation);
return value && (isRotated270CW(value) || isRotated90CW(value));
}
export function getFileSize(asset: AssetResponseDto): string {
const size = asset.exifInfo?.fileSizeInByte || 0;
return size > 0 ? asByteUnitString(size, undefined, 4) : 'Invalid Data';
}
export function getAssetResolution(asset: AssetResponseDto): string {
const { width, height } = getAssetRatio(asset);
if (width === 235 && height === 235) {
return 'Invalid Data';
}
return `${width} x ${height}`;
}
/**
* Returns aspect ratio for the asset
*/
export function getAssetRatio(asset: AssetResponseDto) {
let height = asset.exifInfo?.exifImageHeight || 235;
let width = asset.exifInfo?.exifImageWidth || 235;
const orientation = Number(asset.exifInfo?.orientation);
if (orientation && (isRotated90CW(orientation) || isRotated270CW(orientation))) {
if (isFlipped(asset.exifInfo?.orientation)) {
[width, height] = [height, width];
}
return { width, height };
@@ -263,7 +283,7 @@ export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserRespo
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
if (numberOfIssues > 0) {
notificationController.show({
message: `Can't change metadata of ${numberOfIssues} asset${numberOfIssues > 1 ? 's' : ''}`,
message: `Can't change metadata of ${numberOfIssues} asset${s(numberOfIssues)}`,
type: NotificationType.Warning,
});
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { browser } from '$app/environment';
import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store';
import { getMyUserInfo, getServerInfo } from '@immich/sdk';
import { getMyUserInfo, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { AppRoute } from '../constants';
@@ -58,7 +58,7 @@ export const authenticate = async (options?: AuthOptions) => {
export const requestServerInfo = async () => {
if (get(user)) {
const data = await getServerInfo();
const data = await getStorage();
serverInfo.set(data);
}
};
+58 -35
View File
@@ -5,10 +5,12 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { ExecutorQueue } from '$lib/utils/executor-queue';
import {
Action,
AssetMediaStatus,
checkBulkUpload,
defaults,
getBaseUrl,
getSupportedMediaTypes,
type AssetFileUploadResponseDto,
type AssetMediaResponseDto,
} from '@immich/sdk';
import { tick } from 'svelte';
import { getServerErrorMessage, handleError } from './handle-error';
@@ -25,7 +27,12 @@ const getExtensions = async () => {
return _extensions;
};
export const openFileUploadDialog = async (albumId?: string | undefined) => {
type FileUploadParam = { multiple?: boolean } & (
| { albumId?: string; assetId?: never }
| { albumId?: never; assetId?: string }
);
export const openFileUploadDialog = async (options?: FileUploadParam) => {
const { albumId, multiple, assetId } = options || { multiple: true };
const extensions = await getExtensions();
return new Promise<(string | undefined)[]>((resolve, reject) => {
@@ -33,7 +40,7 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => {
const fileSelector = document.createElement('input');
fileSelector.type = 'file';
fileSelector.multiple = true;
fileSelector.multiple = !!multiple;
fileSelector.accept = extensions.join(',');
fileSelector.addEventListener('change', (e: Event) => {
const target = e.target as HTMLInputElement;
@@ -42,7 +49,7 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => {
}
const files = Array.from(target.files);
resolve(fileUploadHandler(files, albumId));
resolve(fileUploadHandler(files, albumId, assetId));
});
fileSelector.click();
@@ -53,14 +60,14 @@ export const openFileUploadDialog = async (albumId?: string | undefined) => {
});
};
export const fileUploadHandler = async (files: File[], albumId: string | undefined = undefined): Promise<string[]> => {
export const fileUploadHandler = async (files: File[], albumId?: string, assetId?: string): Promise<string[]> => {
const extensions = await getExtensions();
const promises = [];
for (const file of files) {
const name = file.name.toLowerCase();
if (extensions.some((extension) => name.endsWith(extension))) {
uploadAssetsStore.addNewUploadAsset({ id: getDeviceAssetId(file), file, albumId });
promises.push(uploadExecutionQueue.addTask(() => fileUploader(file, albumId)));
uploadAssetsStore.addNewUploadAsset({ id: getDeviceAssetId(file), file, albumId, assetId });
promises.push(uploadExecutionQueue.addTask(() => fileUploader(file, albumId, assetId)));
}
}
@@ -73,9 +80,9 @@ function getDeviceAssetId(asset: File) {
}
// TODO: should probably use the @api SDK
async function fileUploader(asset: File, albumId: string | undefined = undefined): Promise<string | undefined> {
const fileCreatedAt = new Date(asset.lastModified).toISOString();
const deviceAssetId = getDeviceAssetId(asset);
async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?: string): Promise<string | undefined> {
const fileCreatedAt = new Date(assetFile.lastModified).toISOString();
const deviceAssetId = getDeviceAssetId(assetFile);
uploadAssetsStore.markStarted(deviceAssetId);
@@ -85,21 +92,21 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
deviceAssetId,
deviceId: 'WEB',
fileCreatedAt,
fileModifiedAt: new Date(asset.lastModified).toISOString(),
fileModifiedAt: new Date(assetFile.lastModified).toISOString(),
isFavorite: 'false',
duration: '0:00:00.000000',
assetData: new File([asset], asset.name),
assetData: new File([assetFile], assetFile.name),
})) {
formData.append(key, value);
}
let responseData: AssetFileUploadResponseDto | undefined;
let responseData: AssetMediaResponseDto | undefined;
const key = getKey();
if (crypto?.subtle?.digest && !key) {
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Hashing...' });
await tick();
try {
const bytes = await asset.arrayBuffer();
const bytes = await assetFile.arrayBuffer();
const hash = await crypto.subtle.digest('SHA-1', bytes);
const checksum = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
@@ -107,48 +114,64 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
const {
results: [checkUploadResult],
} = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: asset.name, checksum }] } });
} = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } });
if (checkUploadResult.action === Action.Reject && checkUploadResult.assetId) {
responseData = { duplicate: true, id: checkUploadResult.assetId };
responseData = { status: AssetMediaStatus.Duplicate, id: checkUploadResult.assetId };
}
} catch (error) {
console.error(`Error calculating sha1 file=${asset.name})`, error);
console.error(`Error calculating sha1 file=${assetFile.name})`, error);
}
}
let status;
let id;
if (!responseData) {
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Uploading...' });
const response = await uploadRequest<AssetFileUploadResponseDto>({
url: defaults.baseUrl + '/asset/upload' + (key ? `?key=${key}` : ''),
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
if (![200, 201].includes(response.status)) {
throw new Error('Failed to upload file');
if (replaceAssetId) {
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + '/asset/' + replaceAssetId + '/file' + (key ? `?key=${key}` : ''),
method: 'PUT',
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
({ status, id } = response.data);
} else {
const response = await uploadRequest<AssetFileUploadResponseDto>({
url: getBaseUrl() + '/asset/upload' + (key ? `?key=${key}` : ''),
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});
if (![200, 201].includes(response.status)) {
throw new Error('Failed to upload file');
}
if (response.data.duplicate) {
status = AssetMediaStatus.Duplicate;
} else {
id = response.data.id;
}
}
responseData = response.data;
}
const { duplicate, id: assetId } = responseData;
if (duplicate) {
if (status === AssetMediaStatus.Duplicate) {
uploadAssetsStore.duplicateCounter.update((count) => count + 1);
} else {
uploadAssetsStore.successCounter.update((c) => c + 1);
if (albumId && id) {
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Adding to album...' });
await addAssetsToAlbum(albumId, [id]);
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Added to album' });
}
}
if (albumId && assetId) {
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Adding to album...' });
await addAssetsToAlbum(albumId, [assetId]);
uploadAssetsStore.updateAsset(deviceAssetId, { message: 'Added to album' });
}
uploadAssetsStore.updateAsset(deviceAssetId, { state: duplicate ? UploadState.DUPLICATED : UploadState.DONE });
uploadAssetsStore.updateAsset(deviceAssetId, {
state: status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE,
});
setTimeout(() => {
uploadAssetsStore.removeUploadAsset(deviceAssetId);
}, 1000);
return assetId;
return id;
} catch (error) {
handleError(error, 'Unable to upload file');
const reason = getServerErrorMessage(error) || error;
@@ -42,9 +42,9 @@
import { locale } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { handlePromiseError, s } from '$lib/utils';
import { downloadAlbum } from '$lib/utils/asset-utils';
import { clickOutside } from '$lib/utils/click-outside';
import { clickOutside } from '$lib/actions/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
@@ -136,7 +136,7 @@
assetGridWidth = isShowActivity ? globalWidth - (globalWidth < 768 ? 360 : 460) : globalWidth;
}
$: showActivityStatus =
album.sharedUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0);
$: isEditor =
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
@@ -158,7 +158,7 @@
backUrl = url || AppRoute.ALBUMS;
if (backUrl === AppRoute.SHARING && album.sharedUsers.length === 0 && !album.hasSharedLink) {
if (backUrl === AppRoute.SHARING && album.albumUsers.length === 0 && !album.hasSharedLink) {
isCreatingSharedAlbum = true;
}
});
@@ -229,7 +229,7 @@
isShowActivity = !isShowActivity;
};
$: if (album.sharedUsers.length > 0) {
$: if (album.albumUsers.length > 0) {
handlePromiseError(getFavorite());
handlePromiseError(getNumberOfComments());
}
@@ -291,7 +291,7 @@
const count = results.filter(({ success }) => success).length;
notificationController.show({
type: NotificationType.Info,
message: `Added ${count} asset${count === 1 ? '' : 's'}`,
message: `Added ${count} asset${s(count)}`,
});
await refreshAlbum();
@@ -314,7 +314,7 @@
};
const handleSelectFromComputer = async () => {
await openFileUploadDialog(album.id);
await openFileUploadDialog({ albumId: album.id });
timelineInteractionStore.clearMultiselect();
viewMode = ViewMode.VIEW;
};
@@ -342,7 +342,7 @@
try {
await refreshAlbum();
viewMode = album.sharedUsers.length > 0 ? ViewMode.VIEW_USERS : ViewMode.VIEW;
viewMode = album.albumUsers.length > 0 ? ViewMode.VIEW_USERS : ViewMode.VIEW;
} catch (error) {
handleError(error, 'Error deleting shared user');
}
@@ -482,7 +482,7 @@
{/if}
{/if}
{#if isCreatingSharedAlbum && album.sharedUsers.length === 0}
{#if isCreatingSharedAlbum && album.albumUsers.length === 0}
<Button
size="sm"
rounded="lg"
@@ -546,7 +546,7 @@
{album}
{assetStore}
{assetInteractionStore}
isShared={album.sharedUsers.length > 0}
isShared={album.albumUsers.length > 0}
isSelectionMode={viewMode === ViewMode.SELECT_THUMBNAIL}
singleSelect={viewMode === ViewMode.SELECT_THUMBNAIL}
showArchiveIcon
@@ -563,7 +563,7 @@
{/if}
<!-- ALBUM SHARING -->
{#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)}
{#if album.albumUsers.length > 0 || (album.hasSharedLink && isOwned)}
<div class="my-3 flex gap-x-1">
<!-- link -->
{#if album.hasSharedLink && isOwned}
@@ -649,7 +649,7 @@
{/key}
</main>
</div>
{#if album.sharedUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
{#if album.albumUsers.length > 0 && album && isShowActivity && $user && !$showAssetViewer}
<div class="flex">
<div
transition:fly={{ duration: 150 }}
@@ -47,7 +47,7 @@
}
abortController = new AbortController();
const { includeArchived, onlyFavorites, withPartners } = $mapSettings;
const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings;
const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates();
return await getMapMarkers(
@@ -57,6 +57,7 @@
fileCreatedAfter: fileCreatedAfter || undefined,
fileCreatedBefore,
withPartners: withPartners || undefined,
withSharedAlbums: withSharedAlbums || undefined,
},
{
signal: abortController.signal,
+30 -14
View File
@@ -7,7 +7,7 @@
import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte';
import PeopleCard from '$lib/components/faces-page/people-card.svelte';
import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte';
import ShowHide from '$lib/components/faces-page/show-hide.svelte';
import ShowHide, { ToggleVisibilty } from '$lib/components/faces-page/show-hide.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import {
@@ -17,7 +17,7 @@
import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
import {
getPerson,
mergePerson,
@@ -48,7 +48,7 @@
let searchName = '';
let showLoadingSpinner = false;
let toggleVisibility = false;
let toggleVisibility: ToggleVisibilty = ToggleVisibilty.VIEW_ALL;
let showChangeNameModal = false;
let showSetBirthDateModal = false;
@@ -62,7 +62,7 @@
let handleSearchPeople: (force?: boolean, name?: string) => Promise<void>;
let showPeople: PersonResponseDto[] = [];
let countVisiblePeople: number;
let changeNameInputEl: HTMLInputElement | null;
let innerHeight: number;
for (const person of people) {
@@ -104,7 +104,7 @@
// Reset variables used on the "Show & hide people" modal
showLoadingSpinner = false;
selectHidden = false;
toggleVisibility = false;
toggleVisibility = ToggleVisibilty.VIEW_ALL;
};
const handleResetVisibility = () => {
@@ -116,10 +116,17 @@
people = people;
};
const handleToggleVisibility = () => {
toggleVisibility = !toggleVisibility;
const handleToggleVisibility = (toggleVisibility: ToggleVisibilty) => {
for (const person of people) {
person.isHidden = toggleVisibility;
if (toggleVisibility == ToggleVisibilty.HIDE_ALL) {
person.isHidden = true;
}
if (toggleVisibility == ToggleVisibilty.VIEW_ALL) {
person.isHidden = false;
}
if (toggleVisibility == ToggleVisibilty.HIDE_UNNANEMD && !person.name) {
person.isHidden = true;
}
}
// trigger reactivity
@@ -172,7 +179,7 @@
// Reset variables used on the "Show & hide people" modal
showLoadingSpinner = false;
selectHidden = false;
toggleVisibility = false;
toggleVisibility = ToggleVisibilty.VIEW_ALL;
};
const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => {
@@ -237,6 +244,8 @@
personName = detail.name;
personMerge1 = detail;
edittingPerson = detail;
setTimeout(() => changeNameInputEl?.focus(), 100);
};
const handleSetBirthDate = (detail: PersonResponseDto) => {
@@ -448,7 +457,14 @@
<form on:submit|preventDefault={submitNameChange} autocomplete="off" id="change-name-form">
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} />
<input
class="immich-form-input"
id="name"
name="name"
type="text"
bind:value={personName}
bind:this={changeNameInputEl}
/>
</div>
</form>
<svelte:fragment slot="sticky-bottom">
@@ -474,10 +490,10 @@
</UserPageLayout>
{#if selectHidden}
<ShowHide
on:done={handleDoneClick}
on:close={handleCloseClick}
on:reset={handleResetVisibility}
on:change={handleToggleVisibility}
onDone={handleDoneClick}
onClose={handleCloseClick}
onReset={handleResetVisibility}
onChange={handleToggleVisibility}
bind:showLoadingSpinner
bind:toggleVisibility
{countTotalPeople}
@@ -31,8 +31,8 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { clickOutside } from '$lib/utils/click-outside';
import { getPeopleThumbnailUrl, handlePromiseError, s } from '$lib/utils';
import { clickOutside } from '$lib/actions/click-outside';
import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation';
import {
@@ -55,7 +55,7 @@
} from '@mdi/js';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { listNavigation } from '$lib/utils/list-navigation';
import { listNavigation } from '$lib/actions/list-navigation';
export let data: PageData;
@@ -482,7 +482,7 @@
{#if data.person.name}
<p class="w-40 sm:w-72 font-medium truncate">{data.person.name}</p>
<p class="absolute w-fit text-sm text-gray-500 dark:text-immich-gray bottom-0">
{`${numberOfAssets} asset${numberOfAssets > 1 ? 's' : ''}`}
{`${numberOfAssets} asset${s(numberOfAssets)}`}
</p>
{:else}
<p class="font-medium">Add a name</p>
@@ -19,7 +19,7 @@
import { AppRoute, QueryParameter } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { preventRaceConditionSearchBar } from '$lib/stores/search.store';
import { shortcut } from '$lib/utils/shortcut';
import { shortcut } from '$lib/actions/shortcut';
import {
type AssetResponseDto,
searchSmart,
@@ -3,7 +3,7 @@
</script>
<svelte:head>
<title>Opps! Error - Immich</title>
<title>Oops! Error - Immich</title>
</svelte:head>
<section class="flex flex-col px-4 h-screen w-screen place-content-center place-items-center">
@@ -0,0 +1,15 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import type { PageData } from './$types';
import UtilitiesMenu from '$lib/components/utilities-page/utilities-menu.svelte';
export let data: PageData;
</script>
<UserPageLayout title={data.meta.title}>
<div class="w-full max-w-xl m-auto">
<div class="mt-5">
<UtilitiesMenu />
</div>
</div>
</UserPageLayout>
+15
View File
@@ -0,0 +1,15 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
await authenticate();
const asset = await getAssetInfoFromParam(params);
return {
asset,
meta: {
title: 'Utilities',
},
};
}) satisfies PageLoad;
@@ -0,0 +1,66 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import DuplicatesCompareControl from '$lib/components/utilities-page/duplicates/duplicates-compare-control.svelte';
import type { PageData } from './$types';
import { handleError } from '$lib/utils/handle-error';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { s } from '$lib/utils';
import { deleteAssets, getConfig, updateAssets } from '@immich/sdk';
export let data: PageData;
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
try {
const { trash } = await getConfig();
// TODO - Create showConfirmDialog controller to show native confirm.
if (
!trash.enabled &&
trashIds.length > 0 &&
!confirm('Are you sure you want to permanently delete these duplicates?')
) {
return;
}
await updateAssets({ assetBulkUpdateDto: { ids: duplicateAssetIds, duplicateId: null } });
await deleteAssets({ assetBulkDeleteDto: { ids: trashIds, force: !trash.enabled } });
data.duplicates = data.duplicates.filter((duplicate) => duplicate.duplicateId !== duplicateId);
if (trashIds.length === 0) {
return;
}
notificationController.show({
message: `Moved ${trashIds.length} asset${s(trashIds.length)} to trash`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to resolve duplicate');
}
};
</script>
<UserPageLayout title={data.meta.title + ` (${data.duplicates.length})`} scrollbar={true}>
<div class="mt-4">
{#if data.duplicates && data.duplicates.length > 0}
<div class="mb-4 text-sm dark:text-white">
<p>Resolve each group by indicating which, if any, are duplicates.</p>
</div>
{#key data.duplicates[0].duplicateId}
<DuplicatesCompareControl
duplicate={data.duplicates[0]}
onResolve={(duplicateAssetIds, trashIds) =>
handleResolve(data.duplicates[0].duplicateId, duplicateAssetIds, trashIds)}
/>
{/key}
{:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
No duplicates were found.
</p>
{/if}
</div>
</UserPageLayout>
@@ -0,0 +1,18 @@
import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getAssetDuplicates } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
await authenticate();
const asset = await getAssetInfoFromParam(params);
const duplicates = await getAssetDuplicates();
return {
asset,
duplicates,
meta: {
title: 'Duplicates',
},
};
}) satisfies PageLoad;
@@ -24,7 +24,6 @@
getAllLibraries,
getLibraryStatistics,
getUserById,
LibraryType,
removeOfflineFiles,
scanLibrary,
updateLibrary,
@@ -32,7 +31,7 @@
type LibraryStatsResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { mdiDatabase, mdiDotsVertical, mdiPlusBoxOutline, mdiSync, mdiUpload } from '@mdi/js';
import { mdiDatabase, mdiDotsVertical, mdiPlusBoxOutline, mdiSync } from '@mdi/js';
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte';
@@ -108,7 +107,7 @@
};
async function readLibraryList() {
libraries = await getAllLibraries({ $type: LibraryType.External });
libraries = await getAllLibraries();
dropdownOpen.length = libraries.length;
for (let index = 0; index < libraries.length; index++) {
@@ -119,10 +118,7 @@
const handleCreate = async (ownerId: string) => {
try {
const createdLibrary = await createLibrary({
createLibraryDto: { ownerId, type: LibraryType.External },
});
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
notificationController.show({
message: `Created library: ${createdLibrary.name}`,
type: NotificationType.Info,
@@ -135,14 +131,14 @@
}
};
const handleUpdate = async (event: Partial<LibraryResponseDto>) => {
const handleUpdate = async (library: Partial<LibraryResponseDto>) => {
if (updateLibraryIndex === null) {
return;
}
try {
const libraryId = libraries[updateLibraryIndex].id;
await updateLibrary({ id: libraryId, updateLibraryDto: { ...event } });
await updateLibrary({ id: libraryId, updateLibraryDto: library });
closeAll();
await readLibraryList();
} catch (error) {
@@ -177,9 +173,7 @@
const handleScanAll = async () => {
try {
for (const library of libraries) {
if (library.type === LibraryType.External) {
await scanLibrary({ id: library.id, scanLibraryDto: {} });
}
await scanLibrary({ id: library.id, scanLibraryDto: {} });
}
notificationController.show({
message: `Refreshing all libraries`,
@@ -361,12 +355,8 @@
}`}
>
<td class=" px-10 text-sm">
{#if library.type === LibraryType.External}
<Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" />
{:else if library.type === LibraryType.Upload}
<Icon path={mdiUpload} size="40" title="Upload library (created on {library.createdAt})" />
{/if}</td
>
<Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" />
</td>
<td class=" text-ellipsis px-4 text-sm">{library.name}</td>
<td class=" text-ellipsis px-4 text-sm">
@@ -400,7 +390,7 @@
<ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
<MenuOption on:click={() => onRenameClicked()} text={`Rename`} />
{#if selectedLibrary && selectedLibrary.type === LibraryType.External}
{#if selectedLibrary}
<MenuOption on:click={() => onEditImportPathClicked()} text="Edit Import Paths" />
<MenuOption on:click={() => onScanSettingClicked()} text="Scan Settings" />
<hr />
@@ -448,7 +438,7 @@
<div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
<LibraryScanSettingsForm
{library}
on:submit={({ detail }) => handleUpdate(detail.library)}
on:submit={({ detail: library }) => handleUpdate(library)}
on:cancel={() => (editScanSettings = null)}
/>
</div>
+1 -1
View File
@@ -250,7 +250,7 @@
<div class="px-3">
<p>OFFLINE PATHS {orphans.length > 0 ? `(${orphans.length})` : ''}</p>
<p class="text-gray-600 dark:text-gray-300 mt-1">
These results may be due to manual deletion of files in the default upload library
These results may be due to manual deletion of files that are not part of an external library.
</p>
</div>
</th>
@@ -1,34 +1,33 @@
<script lang="ts">
import AdminSettings from '$lib/components/admin-page/settings/admin-settings.svelte';
import AuthSettings from '$lib/components/admin-page/settings/auth/auth-settings.svelte';
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
import ImageSettings from '$lib/components/admin-page/settings/image/image-settings.svelte';
import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
import LibrarySettings from '$lib/components/admin-page/settings/library-settings/library-settings.svelte';
import LoggingSettings from '$lib/components/admin-page/settings/logging-settings/logging-settings.svelte';
import MachineLearningSettings from '$lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte';
import MapSettings from '$lib/components/admin-page/settings/map-settings/map-settings.svelte';
import NewVersionCheckSettings from '$lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte';
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
import ServerSettings from '$lib/components/admin-page/settings/server/server-settings.svelte';
import NotificationSettings from '$lib/components/admin-page/settings/notification-settings/notification-settings.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import ServerSettings from '$lib/components/admin-page/settings/server/server-settings.svelte';
import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte';
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
import ImageSettings from '$lib/components/admin-page/settings/image/image-settings.svelte';
import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte';
import UserSettings from '$lib/components/admin-page/settings/user-settings/user-settings.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import SettingAccordionState from '$lib/components/shared-components/settings/setting-accordion-state.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import { QueryParameter } from '$lib/constants';
import { downloadManager } from '$lib/stores/download';
import { featureFlags } from '$lib/stores/server-config.store';
import { copyToClipboard } from '$lib/utils';
import { downloadBlob } from '$lib/utils/asset-utils';
import type { SystemConfigDto } from '@immich/sdk';
import { mdiAlert, mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
import type { PageData } from './$types';
import SettingAccordionState from '$lib/components/shared-components/settings/setting-accordion-state.svelte';
import { QueryParameter } from '$lib/constants';
import type { SystemConfigDto } from '@immich/sdk';
export let data: PageData;
@@ -36,13 +35,12 @@
let handleSave: (update: Partial<SystemConfigDto>) => Promise<void>;
type Settings =
| typeof AuthSettings
| typeof JobSettings
| typeof LibrarySettings
| typeof LoggingSettings
| typeof MachineLearningSettings
| typeof MapSettings
| typeof OAuthSettings
| typeof PasswordLoginSettings
| typeof ServerSettings
| typeof StorageTemplateSettings
| typeof ThemeSettings
@@ -82,6 +80,12 @@
subtitle: string;
key: string;
}> = [
{
item: AuthSettings,
title: 'Authentication Settings',
subtitle: 'Manage password, OAuth, and other authentication settings',
key: 'image',
},
{
item: ImageSettings,
title: 'Image Settings',
@@ -124,18 +128,6 @@
subtitle: 'Manage notification settings, including email',
key: 'notifications',
},
{
item: OAuthSettings,
title: 'OAuth Authentication',
subtitle: 'Manage the login with OAuth settings',
key: 'oauth',
},
{
item: PasswordLoginSettings,
title: 'Password Authentication',
subtitle: 'Manage the login with password settings',
key: 'password',
},
{
item: ServerSettings,
title: 'Server Settings',
+3 -2
View File
@@ -1,9 +1,10 @@
import { AppRoute } from '$lib/constants';
import { getServerConfig } from '@immich/sdk';
import { defaults, getServerConfig } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
export const load = (async ({ fetch }) => {
defaults.fetch = fetch;
const { isInitialized } = await getServerConfig();
if (!isInitialized) {
// Admin not registered
@@ -15,7 +15,6 @@ export const albumFactory = Sync.makeFactory<AlbumResponseDto>({
ownerId: Sync.each(() => faker.string.uuid()),
owner: userFactory.build(),
shared: false,
sharedUsers: [],
albumUsers: [],
hasSharedLink: false,
isActivityEnabled: true,