merge main
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
v20.12
|
||||
20.13
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Generated
+245
-288
@@ -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
@@ -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
@@ -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 */
|
||||
@@ -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"
|
||||
|
||||
+32
@@ -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>
|
||||
-68
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type UploadAsset = {
|
||||
id: string;
|
||||
file: File;
|
||||
albumId?: string;
|
||||
assetId?: string;
|
||||
progress?: number;
|
||||
state?: UploadState;
|
||||
startDate?: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
+12
-12
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
+4
-4
@@ -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>
|
||||
@@ -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;
|
||||
+66
@@ -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>
|
||||
+18
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user