Merge branch 'immich-app:main' into feat/notification-email-template
2
.devcontainer/Dockerfile
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22
|
||||||
|
FROM ${BASEIMAGE}
|
20
.devcontainer/devcontainer.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Immich devcontainers",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"args": {
|
||||||
|
"BASEIMAGE": "mcr.microsoft.com/devcontainers/typescript-node:22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"svelte.svelte-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forwardPorts": [],
|
||||||
|
"postCreateCommand": "make install-all",
|
||||||
|
"remoteUser": "node"
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: PR Conventional Commit Validation
|
- name: PR Conventional Commit Validation
|
||||||
uses: ytanikin/PRConventionalCommits@1.2.0
|
uses: ytanikin/PRConventionalCommits@1.3.0
|
||||||
with:
|
with:
|
||||||
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
|
task_types: '["feat","fix","docs","test","ci","refactor","perf","chore","revert"]'
|
||||||
add_label: 'false'
|
add_label: 'false'
|
||||||
|
18
Makefile
@ -39,7 +39,7 @@ attach-server:
|
|||||||
renovate:
|
renovate:
|
||||||
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
|
||||||
|
|
||||||
MODULES = e2e server web cli sdk
|
MODULES = e2e server web cli sdk docs
|
||||||
|
|
||||||
audit-%:
|
audit-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
|
||||||
@ -48,11 +48,9 @@ install-%:
|
|||||||
build-cli: build-sdk
|
build-cli: build-sdk
|
||||||
build-web: build-sdk
|
build-web: build-sdk
|
||||||
build-%: install-%
|
build-%: install-%
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'build' >/dev/null \
|
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build
|
||||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run build || true
|
|
||||||
format-%:
|
format-%:
|
||||||
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run | grep 'format:fix' >/dev/null \
|
npm --prefix $* run format:fix
|
||||||
&& npm --prefix $(subst sdk,open-api/typescript-sdk,$*) run format:fix || true
|
|
||||||
lint-%:
|
lint-%:
|
||||||
npm --prefix $* run lint:fix
|
npm --prefix $* run lint:fix
|
||||||
check-%:
|
check-%:
|
||||||
@ -79,14 +77,14 @@ test-medium:
|
|||||||
test-medium-dev:
|
test-medium-dev:
|
||||||
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
docker exec -it immich_server /bin/sh -c "npm run test:medium"
|
||||||
|
|
||||||
build-all: $(foreach M,$(MODULES),build-$M) ;
|
build-all: $(foreach M,$(filter-out e2e,$(MODULES)),build-$M) ;
|
||||||
install-all: $(foreach M,$(MODULES),install-$M) ;
|
install-all: $(foreach M,$(MODULES),install-$M) ;
|
||||||
check-all: $(foreach M,$(MODULES),check-$M) ;
|
check-all: $(foreach M,$(filter-out sdk cli docs,$(MODULES)),check-$M) ;
|
||||||
lint-all: $(foreach M,$(MODULES),lint-$M) ;
|
lint-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),lint-$M) ;
|
||||||
format-all: $(foreach M,$(MODULES),format-$M) ;
|
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
|
||||||
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
audit-all: $(foreach M,$(MODULES),audit-$M) ;
|
||||||
hygiene-all: lint-all format-all check-all sql audit-all;
|
hygiene-all: lint-all format-all check-all sql audit-all;
|
||||||
test-all: $(foreach M,$(MODULES),test-$M) ;
|
test-all: $(foreach M,$(filter-out sdk docs,$(MODULES)),test-$M) ;
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS core
|
FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82 AS core
|
||||||
|
|
||||||
WORKDIR /usr/src/open-api/typescript-sdk
|
WORKDIR /usr/src/open-api/typescript-sdk
|
||||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||||
|
10
cli/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.30",
|
"version": "2.2.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.30",
|
"version": "2.2.31",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
@ -24,7 +24,7 @@
|
|||||||
"@types/cli-progress": "^3.11.0",
|
"@types/cli-progress": "^3.11.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^2.0.5",
|
||||||
@ -52,14 +52,14 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.30",
|
"version": "2.2.31",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
@ -20,7 +20,7 @@
|
|||||||
"@types/cli-progress": "^3.11.0",
|
"@types/cli-progress": "^3.11.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^2.0.5",
|
||||||
|
@ -103,7 +103,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@ -94,7 +94,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:378f4e03703557d1c6419e6caccf922f96e6d88a530f7431d66a4c4f4b1000fe
|
image: prom/prometheus@sha256:3b9b2a15d376334da8c286d995777d3b9315aa666d2311170ada6059a517b74f
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
@ -48,7 +48,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
@ -58,7 +58,7 @@ docker compose up -d # Start remainder of Immich apps
|
|||||||
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
|
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
|
||||||
|
|
||||||
```powershell title='Backup'
|
```powershell title='Backup'
|
||||||
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | Set-Content -Encoding utf8 "C:\path\to\backup\dump.sql"
|
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres))
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell title='Restore'
|
```powershell title='Restore'
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
# PR Checklist
|
# PR Checklist
|
||||||
|
|
||||||
|
A minimal devcontainer is supplied with this repository. All commands can be executed directly inside this container to avoid tedious installation of the environment.
|
||||||
|
:::warning
|
||||||
|
The provided devcontainer isn't complete at the moment. At least all dockerized steps in the Makefile won't work (`make dev`, ....). Feel free to contribute!
|
||||||
|
:::
|
||||||
When contributing code through a pull request, please check the following:
|
When contributing code through a pull request, please check the following:
|
||||||
|
|
||||||
## Web Checks
|
## Web Checks
|
||||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 9.4 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 127 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 128 KiB |
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 63 KiB |
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 12 KiB |
BIN
docs/docs/install/img/truenas10.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/docs/install/img/truenas11.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
BIN
docs/docs/install/img/truenas12.png
Normal file
After Width: | Height: | Size: 19 KiB |
@ -7,7 +7,9 @@ sidebar_position: 80
|
|||||||
:::note
|
:::note
|
||||||
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
|
||||||
|
|
||||||
**Please report issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
|
||||||
|
|
||||||
|
**Please report app issues to the corresponding [Github Repository](https://github.com/truenas/charts/tree/master/community/immich).**
|
||||||
:::
|
:::
|
||||||
|
|
||||||
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
|
Immich can easily be installed on TrueNAS SCALE via the **Community** train application.
|
||||||
@ -20,18 +22,26 @@ TrueNAS SCALE makes installing and updating Immich easy, but you must use the Im
|
|||||||
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
|
The Immich app in TrueNAS SCALE installs, completes the initial configuration, then starts the Immich web portal.
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, SCALE alerts and provides easy updates.
|
||||||
|
|
||||||
Before installing the Immich app in SCALE, review the [Environment Variables](/docs/install/environment-variables.md) documentation to see if you want to configure any during installation.
|
Before installing the Immich app in SCALE, review the [Environment Variables](#environment-variables) documentation to see if you want to configure any during installation.
|
||||||
You can configure environment variables at any time after deploying the application.
|
You may also configure environment variables at any time after deploying the application.
|
||||||
|
|
||||||
You can allow SCALE to create the datasets Immich requires automatically during app installation.
|
### Setting up Storage Datasets
|
||||||
Or before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
|
|
||||||
Immich requires seven datasets: **library**, **pgBackup**, **pgData**, **profile**, **thumbs**, **uploads**, and **video**.
|
Before beginning app installation, [create the datasets](https://www.truenas.com/docs/scale/scaletutorials/storage/datasets/datasetsscale/) to use in the **Storage Configuration** section during installation.
|
||||||
You can organize these as one parent with seven child datasets, for example `mnt/tank/immich/library`, `mnt/tank/immich/pgBackup`, and so on.
|
Immich requires seven datasets: `library`, `upload`, `thumbs`, `profile`, `video`, `backups`, and `pgData`.
|
||||||
|
You can organize these as one parent with seven child datasets, for example `/mnt/tank/immich/library`, `/mnt/tank/immich/upload`, and so on.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas12.png').default}
|
||||||
|
width="30%"
|
||||||
|
alt="Immich App Widget"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
:::info Permissions
|
:::info Permissions
|
||||||
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
|
||||||
|
|
||||||
The **library** dataset must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command.
|
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Installing the Immich Application
|
## Installing the Immich Application
|
||||||
@ -47,6 +57,8 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Click on the widget to open the **Immich** application details screen.
|
Click on the widget to open the **Immich** application details screen.
|
||||||
|
|
||||||
|
<br/><br/>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={require('./img/truenas02.png').default}
|
src={require('./img/truenas02.png').default}
|
||||||
width="100%"
|
width="100%"
|
||||||
@ -56,9 +68,13 @@ className="border rounded-xl"
|
|||||||
|
|
||||||
Click **Install** to open the Immich application configuration screen.
|
Click **Install** to open the Immich application configuration screen.
|
||||||
|
|
||||||
|
<br/><br/>
|
||||||
|
|
||||||
Application configuration settings are presented in several sections, each explained below.
|
Application configuration settings are presented in several sections, each explained below.
|
||||||
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
|
To find specific fields click in the **Search Input Fields** search field, scroll down to a particular section or click on the section heading on the navigation area in the upper-right corner.
|
||||||
|
|
||||||
|
### Application Name and Version
|
||||||
|
|
||||||
<img
|
<img
|
||||||
src={require('./img/truenas03.png').default}
|
src={require('./img/truenas03.png').default}
|
||||||
width="100%"
|
width="100%"
|
||||||
@ -66,21 +82,123 @@ alt="Install Immich Screen"
|
|||||||
className="border rounded-xl"
|
className="border rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
Accept the default values in **Application Name** and **Version**.
|
Accept the default value or enter a name in **Application Name** field.
|
||||||
|
In most cases use the default name, but if adding a second deployment of the application you must change this name.
|
||||||
|
|
||||||
|
Accept the default version number in **Version**.
|
||||||
|
When a new version becomes available, the application has an update badge.
|
||||||
|
The **Installed Applications** screen shows the option to update applications.
|
||||||
|
|
||||||
|
### Immich Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas05.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configuration Settings"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
Accept the default value in **Timezone** or change to match your local timezone.
|
Accept the default value in **Timezone** or change to match your local timezone.
|
||||||
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
||||||
|
|
||||||
Accept the default port in **Web Port**.
|
Untick **Enable Machine Learning** if you will not use face recognition, image search, and smart duplicate detection.
|
||||||
|
|
||||||
|
Accept the default option or select the **Machine Learning Image Type** for your hardware based on the [Hardware-Accelerated Machine Learning Supported Backends](/docs/features/ml-hardware-acceleration.md#supported-backends).
|
||||||
|
|
||||||
|
Immich's default is `postgres` but you should consider setting the **Database Password** to a custom value using only the characters `A-Za-z0-9`.
|
||||||
|
|
||||||
|
The **Redis Password** should be set to a custom value using only the characters `A-Za-z0-9`.
|
||||||
|
|
||||||
|
Accept the **Log Level** default of **Log**.
|
||||||
|
|
||||||
|
Leave **Hugging Face Endpoint** blank. (This is for downloading ML models from a different source.)
|
||||||
|
|
||||||
|
Leave **Additional Environment Variables** blank or see [Environment Variables](#environment-variables) to set before installing.
|
||||||
|
|
||||||
|
### Network Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas06.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Networking Settings"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Accept the default port `30041` in **WebUI Port** or enter a custom port number.
|
||||||
|
:::info Allowed Port Numbers
|
||||||
|
Only numbers within the range 9000-65535 may be used on SCALE versions below TrueNAS Scale 24.10 Electric Eel.
|
||||||
|
|
||||||
|
Regardless of version, to avoid port conflicts, don't use [ports on this list](https://www.truenas.com/docs/references/defaultports/).
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Storage Configuration
|
||||||
|
|
||||||
Immich requires seven storage datasets.
|
Immich requires seven storage datasets.
|
||||||
You can allow SCALE to create them for you, or use the dataset(s) created in [First Steps](#first-steps).
|
|
||||||
Select the storage options you want to use for **Immich Uploads Storage**, **Immich Library Storage**, **Immich Thumbs Storage**, **Immich Profile Storage**, **Immich Video Storage**, **Immich Postgres Data Storage**, **Immich Postgres Backup Storage**.
|
|
||||||
Select **ixVolume (dataset created automatically by the system)** in **Type** to let SCALE create the dataset or select **Host Path** to use the existing datasets created on the system.
|
|
||||||
|
|
||||||
Accept the defaults in Resources or change the CPU and memory limits to suit your use case.
|
<img
|
||||||
|
src={require('./img/truenas07.png').default}
|
||||||
|
width="20%"
|
||||||
|
alt="Configure Storage ixVolumes"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
Click **Install**.
|
:::note Default Setting (Not recommended)
|
||||||
|
The default setting for datasets is **ixVolume (dataset created automatically by the system)** but this results in your data being harder to access manually and can result in data loss if you delete the immich app. (Not recommended)
|
||||||
|
:::
|
||||||
|
|
||||||
|
For each Storage option select **Host Path (Path that already exists on the system)** and then select the matching dataset [created before installing the app](#setting-up-storage-datasets): **Immich Library Storage**: `library`, **Immich Uploads Storage**: `upload`, **Immich Thumbs Storage**: `thumbs`, **Immich Profile Storage**: `profile`, **Immich Video Storage**: `video`, **Immich Backups Storage**: `backups`, **Postgres Data Storage**: `pgData`.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas08.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configure Storage Host Paths"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
The image above has example values.
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
### Additional Storage [(External Libraries)](/docs/features/libraries)
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas10.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Configure Storage Host Paths"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
|
||||||
|
The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich.
|
||||||
|
The **Host Path** is the location on the TrueNAS SCALE server where your external library is located.
|
||||||
|
|
||||||
|
<!-- A section for Labels would go here but I don't know what they do. -->
|
||||||
|
|
||||||
|
### Resources Configuration
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas09.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Resource Limits"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
Accept the default **CPU** limit of `2` threads or specify the number of threads (CPUs with Multi-/Hyper-threading have 2 threads per core).
|
||||||
|
|
||||||
|
Accept the default **Memory** limit of `4096` MB or specify the number of MB of RAM. If you're using Machine Learning you should probably set this above 8000 MB.
|
||||||
|
|
||||||
|
:::info Older SCALE Versions
|
||||||
|
Before TrueNAS SCALE version 24.10 Electric Eel:
|
||||||
|
|
||||||
|
The **CPU** value was specified in a different format with a default of `4000m` which is 4 threads.
|
||||||
|
|
||||||
|
The **Memory** value was specified in a different format with a default of `8Gi` which is 8 GiB of RAM. The value was specified in bytes or a number with a measurement suffix. Examples: `129M`, `123Mi`, `1000000000`
|
||||||
|
:::
|
||||||
|
|
||||||
|
Enable **GPU Configuration** options if you have a GPU that you will use for [Hardware Transcoding](/docs/features/hardware-transcoding) and/or [Hardware-Accelerated Machine Learning](/docs/features/ml-hardware-acceleration.md). More info: [GPU Passtrough Docs for TrueNAS Apps](https://www.truenas.com/docs/truenasapps/#gpu-passthrough)
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
Finally, click **Install**.
|
||||||
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
|
The system opens the **Installed Applications** screen with the Immich app in the **Deploying** state.
|
||||||
When the installation completes it changes to **Running**.
|
When the installation completes it changes to **Running**.
|
||||||
|
|
||||||
@ -97,102 +215,41 @@ Click **Web Portal** on the **Application Info** widget to open the Immich web i
|
|||||||
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
|
For more information on how to use the application once installed, please refer to the [Post Install](/docs/install/post-install.mdx) guide.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Editing Environment Variables
|
## Edit App Settings
|
||||||
|
|
||||||
Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
||||||
Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
|
- Click **Edit** on the **Application Info** widget to open the **Edit Immich** screen.
|
||||||
The settings on the edit screen are the same as on the install screen.
|
- Change any settings you would like to change.
|
||||||
You cannot edit **Storage Configuration** paths after the initial app install.
|
- The settings on the edit screen are the same as on the install screen.
|
||||||
|
- Click **Update** at the very bottom of the page to save changes.
|
||||||
|
- TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated settings.
|
||||||
|
|
||||||
Click **Update** to save changes.
|
## Environment Variables
|
||||||
TrueNAS automatically updates, recreates, and redeploys the Immich container with the updated environment variables.
|
|
||||||
|
You can set [Environment Variables](/docs/install/environment-variables) by clicking **Add** on the **Additional Environment Variables** option and filling in the **Name** and **Value**.
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={require('./img/truenas11.png').default}
|
||||||
|
width="40%"
|
||||||
|
alt="Environment Variables"
|
||||||
|
className="border rounded-xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::info
|
||||||
|
Some Environment Variables are not available for the TrueNAS SCALE app. This is mainly because they can be configured through GUI options in the [Edit Immich screen](#edit-app-settings).
|
||||||
|
|
||||||
|
Some examples are: `IMMICH_VERSION`, `UPLOAD_LOCATION`, `DB_DATA_LOCATION`, `TZ`, `IMMICH_LOG_LEVEL`, `DB_PASSWORD`, `REDIS_PASSWORD`.
|
||||||
|
:::
|
||||||
|
|
||||||
## Updating the App
|
## Updating the App
|
||||||
|
|
||||||
When updates become available, SCALE alerts and provides easy updates.
|
When updates become available, SCALE alerts and provides easy updates.
|
||||||
To update the app to the latest version, click **Update** on the **Application Info** widget from the **Installed Applications** screen.
|
To update the app to the latest version:
|
||||||
|
|
||||||
Update opens an update window for the application that includes two selectable options, Images (to be updated) and Changelog. Click on the down arrow to see the options available for each.
|
- Go to the **Installed Applications** screen and select Immich from the list of installed applications.
|
||||||
|
- Click **Update** on the **Application Info** widget from the **Installed Applications** screen.
|
||||||
Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress. When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
|
- This opens an update window with some options
|
||||||
|
- You may select an Image update too.
|
||||||
## Understanding Immich Settings in TrueNAS SCALE
|
- You may view the Changelog.
|
||||||
|
- Click **Upgrade** to begin the process and open a counter dialog that shows the upgrade progress.
|
||||||
Accept the default value or enter a name in **Application Name** field.
|
- When complete, the update badge and buttons disappear and the application Update state on the Installed screen changes from Update Available to Up to date.
|
||||||
In most cases use the default name, but if adding a second deployment of the application you must change this name.
|
|
||||||
|
|
||||||
Accept the default version number in **Version**.
|
|
||||||
When a new version becomes available, the application has an update badge.
|
|
||||||
The **Installed Applications** screen shows the option to update applications.
|
|
||||||
|
|
||||||
### Immich Configuration Settings
|
|
||||||
|
|
||||||
You can accept the defaults in the **Immich Configuration** settings, or enter the settings you want to use.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas05.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configuration Settings"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Accept the default setting in **Timezone** or change to match your local timezone.
|
|
||||||
**Timezone** is only used by the Immich `exiftool` microservice if it cannot be determined from the image metadata.
|
|
||||||
|
|
||||||
You can enter a **Public Login Message** to display on the login page, or leave it blank.
|
|
||||||
|
|
||||||
### Networking Settings
|
|
||||||
|
|
||||||
Accept the default port numbers in **Web Port**.
|
|
||||||
The SCALE Immich app listens on port **30041**.
|
|
||||||
|
|
||||||
Refer to the TrueNAS [default port list](https://www.truenas.com/docs/references/defaultports/) for a list of assigned port numbers.
|
|
||||||
To change the port numbers, enter a number within the range 9000-65535.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas06.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Networking Settings"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Storage Settings
|
|
||||||
|
|
||||||
You can install Immich using the default setting **ixVolume (dataset created automatically by the system)** or use the host path option with datasets [created before installing the app](#first-steps).
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas07.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configure Storage ixVolumes"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
Select **Host Path (Path that already exists on the system)** to browse to and select the datasets.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas08.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Configure Storage Host Paths"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
### Resource Configuration Settings
|
|
||||||
|
|
||||||
Accept the default values in **Resources Configuration** or enter new CPU and memory values
|
|
||||||
By default, this application is limited to use no more than 4 CPU cores and 8 Gigabytes available memory. The application might use considerably less system resources.
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={require('./img/truenas09.png').default}
|
|
||||||
width="100%"
|
|
||||||
alt="Resource Limits"
|
|
||||||
className="border rounded-xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
To customize the CPU and memory allocated to the container Immich uses, enter new CPU values as a plain integer value followed by the suffix m (milli).
|
|
||||||
Default is 4000m.
|
|
||||||
|
|
||||||
Accept the default value 8Gi allocated memory or enter a new limit in bytes.
|
|
||||||
Enter a plain integer followed by the measurement suffix, for example 129M or 123Mi.
|
|
||||||
|
|
||||||
Systems with compatible GPU(s) display devices in **GPU Configuration**.
|
|
||||||
See [Managing GPUs](https://www.truenas.com/docs/scale/scaletutorials/systemsettings/advanced/managegpuscale/) for more information about allocating isolated GPU devices in TrueNAS SCALE.
|
|
||||||
|
4
docs/static/archived-versions.json
vendored
@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.120.2",
|
||||||
|
"url": "https://v1.120.2.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.120.1",
|
"label": "v1.120.1",
|
||||||
"url": "https://v1.120.1.archive.immich.app"
|
"url": "https://v1.120.1.archive.immich.app"
|
||||||
|
@ -34,7 +34,7 @@ services:
|
|||||||
- 2285:2285
|
- 2285:2285
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5
|
image: redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||||
|
14
e2e/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
"@eslint/eslintrc": "^3.1.0",
|
||||||
@ -15,7 +15,7 @@
|
|||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"@types/oidc-provider": "^8.5.1",
|
"@types/oidc-provider": "^8.5.1",
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
@ -45,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
"../cli": {
|
"../cli": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.30",
|
"version": "2.2.31",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -64,7 +64,7 @@
|
|||||||
"@types/cli-progress": "^3.11.0",
|
"@types/cli-progress": "^3.11.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
"@typescript-eslint/parser": "^8.0.0",
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^2.0.5",
|
||||||
@ -92,14 +92,14 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"@types/oidc-provider": "^8.5.1",
|
"@types/oidc-provider": "^8.5.1",
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
|
8
machine-learning/poetry.lock
generated
@ -747,14 +747,14 @@ files = [
|
|||||||
test = ["pytest (>=6)"]
|
test = ["pytest (>=6)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-slim"
|
name = "fastapi"
|
||||||
version = "0.115.4"
|
version = "0.115.4"
|
||||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "fastapi_slim-0.115.4-py3-none-any.whl", hash = "sha256:8947515618c21665590a1673a0bfe4c721db4267999c149d5301c3c0f7b3d9ce"},
|
{file = "fastapi-0.115.4-py3-none-any.whl", hash = "sha256:0b504a063ffb3cf96a5e27dc1bc32c80ca743a2528574f9cdc77daa2d31b4742"},
|
||||||
{file = "fastapi_slim-0.115.4.tar.gz", hash = "sha256:6d37987e4d1f6adefb8c7119c9b804e59c9b3f1a488be5425994d52308e2f958"},
|
{file = "fastapi-0.115.4.tar.gz", hash = "sha256:db653475586b091cb8b2fec2ac54a680ac6a158e07406e1abae31679e8826349"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -3778,4 +3778,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = ">=3.10,<4.0"
|
python-versions = ">=3.10,<4.0"
|
||||||
content-hash = "f95dddfd343a4b2f4d19ffee71ce6b2f5137e5514a60765424164259c4dc1044"
|
content-hash = "b690d5fbd141da3947f4f1dc029aba1b95e7faafd723166f2c4bdc47a66c095e"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "machine-learning"
|
name = "machine-learning"
|
||||||
version = "1.120.1"
|
version = "1.120.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@ -11,7 +11,7 @@ python = ">=3.10,<4.0"
|
|||||||
insightface = ">=0.7.3,<1.0"
|
insightface = ">=0.7.3,<1.0"
|
||||||
opencv-python-headless = ">=4.7.0.72,<5.0"
|
opencv-python-headless = ">=4.7.0.72,<5.0"
|
||||||
pillow = ">=9.5.0,<11.0"
|
pillow = ">=9.5.0,<11.0"
|
||||||
fastapi-slim = ">=0.95.2,<1.0"
|
fastapi = ">=0.95.2,<1.0"
|
||||||
uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
|
uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
|
||||||
pydantic = "^2.0.0"
|
pydantic = "^2.0.0"
|
||||||
pydantic-settings = "^2.5.2"
|
pydantic-settings = "^2.5.2"
|
||||||
|
32
mobile/android/app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
##---------------Begin: proguard configuration for Gson ----------
|
||||||
|
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||||
|
# removes such information by default, so configure it to keep all of it.
|
||||||
|
-keepattributes Signature
|
||||||
|
|
||||||
|
# For using GSON @Expose annotation
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
|
# Gson specific classes
|
||||||
|
-dontwarn sun.misc.**
|
||||||
|
#-keep class com.google.gson.stream.** { *; }
|
||||||
|
|
||||||
|
# Application classes that will be serialized/deserialized over Gson
|
||||||
|
-keep class com.google.gson.examples.android.model.** { <fields>; }
|
||||||
|
|
||||||
|
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
|
||||||
|
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
|
||||||
|
-keep class * extends com.google.gson.TypeAdapter
|
||||||
|
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||||
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
|
|
||||||
|
# Prevent R8 from leaving Data object members always null
|
||||||
|
-keepclassmembers,allowobfuscation class * {
|
||||||
|
@com.google.gson.annotations.SerializedName <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
|
||||||
|
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
|
||||||
|
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
##---------------End: proguard configuration for Gson ----------
|
@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 166,
|
"android.injected.version.code" => 167,
|
||||||
"android.injected.version.name" => "1.120.1",
|
"android.injected.version.name" => "1.120.2",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
@ -401,7 +401,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@ -543,7 +543,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@ -571,7 +571,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 183;
|
CURRENT_PROJECT_VERSION = 184;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
@ -58,11 +58,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.120.1</string>
|
<string>1.120.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>183</string>
|
<string>184</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Release"
|
desc "iOS Release"
|
||||||
lane :release do
|
lane :release do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.120.1"
|
version_number: "1.120.2"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
@ -19,6 +19,8 @@ const String defaultColorPresetName = "indigo";
|
|||||||
|
|
||||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||||
|
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
||||||
|
const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
|
||||||
|
|
||||||
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
||||||
ImmichColorPreset.indigo: ImmichTheme(
|
ImmichColorPreset.indigo: ImmichTheme(
|
||||||
|
@ -41,6 +41,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
_ref;
|
_ref;
|
||||||
final _log = Logger("AuthenticationNotifier");
|
final _log = Logger("AuthenticationNotifier");
|
||||||
|
|
||||||
|
static const Duration _timeoutDuration = Duration(seconds: 7);
|
||||||
|
|
||||||
Future<bool> login(
|
Future<bool> login(
|
||||||
String email,
|
String email,
|
||||||
String password,
|
String password,
|
||||||
@ -102,12 +104,15 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
|
|
||||||
await _apiService.authenticationApi
|
await _apiService.authenticationApi
|
||||||
.logout()
|
.logout()
|
||||||
|
.timeout(_timeoutDuration)
|
||||||
.then((_) => log.info("Logout was successful for $userEmail"))
|
.then((_) => log.info("Logout was successful for $userEmail"))
|
||||||
.onError(
|
.onError(
|
||||||
(error, stackTrace) =>
|
(error, stackTrace) =>
|
||||||
log.severe("Logout failed for $userEmail", error, stackTrace),
|
log.severe("Logout failed for $userEmail", error, stackTrace),
|
||||||
);
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
log.severe('Logout failed', e, stack);
|
||||||
|
} finally {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
clearAssetsAndAlbums(_db),
|
clearAssetsAndAlbums(_db),
|
||||||
Store.delete(StoreKey.currentUser),
|
Store.delete(StoreKey.currentUser),
|
||||||
@ -125,8 +130,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
shouldChangePassword: false,
|
shouldChangePassword: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
);
|
);
|
||||||
} catch (e, stack) {
|
|
||||||
log.severe('Logout failed', e, stack);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,10 +171,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||||||
UserPreferencesResponseDto? userPreferences;
|
UserPreferencesResponseDto? userPreferences;
|
||||||
try {
|
try {
|
||||||
final responses = await Future.wait([
|
final responses = await Future.wait([
|
||||||
_apiService.usersApi.getMyUser().timeout(const Duration(seconds: 7)),
|
_apiService.usersApi.getMyUser().timeout(_timeoutDuration),
|
||||||
_apiService.usersApi
|
_apiService.usersApi.getMyPreferences().timeout(_timeoutDuration),
|
||||||
.getMyPreferences()
|
|
||||||
.timeout(const Duration(seconds: 7)),
|
|
||||||
]);
|
]);
|
||||||
userResponse = responses[0] as UserAdminResponseDto;
|
userResponse = responses[0] as UserAdminResponseDto;
|
||||||
userPreferences = responses[1] as UserPreferencesResponseDto;
|
userPreferences = responses[1] as UserPreferencesResponseDto;
|
||||||
|
@ -7,10 +7,10 @@ import 'package:immich_mobile/providers/app_settings.provider.dart';
|
|||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
|
||||||
class ImmichTheme {
|
class ImmichTheme {
|
||||||
ColorScheme light;
|
final ColorScheme light;
|
||||||
ColorScheme dark;
|
final ColorScheme dark;
|
||||||
|
|
||||||
ImmichTheme({required this.light, required this.dark});
|
const ImmichTheme({required this.light, required this.dark});
|
||||||
}
|
}
|
||||||
|
|
||||||
ImmichTheme? _immichDynamicTheme;
|
ImmichTheme? _immichDynamicTheme;
|
||||||
@ -151,7 +151,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) {
|
|||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
brightness: colorScheme.brightness,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
primaryColor: primaryColor,
|
primaryColor: primaryColor,
|
||||||
hintColor: colorScheme.onSurfaceSecondary,
|
hintColor: colorScheme.onSurfaceSecondary,
|
||||||
|
@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||||
@ -327,39 +328,51 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
child: Column(
|
child: DecoratedBox(
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
Visibility(
|
gradient: LinearGradient(
|
||||||
visible: showVideoPlayerControls,
|
begin: Alignment.bottomCenter,
|
||||||
child: const VideoControls(),
|
end: Alignment.topCenter,
|
||||||
|
colors: [blackOpacity90, Colors.transparent],
|
||||||
),
|
),
|
||||||
BottomNavigationBar(
|
),
|
||||||
backgroundColor: Colors.black.withOpacity(0.4),
|
position: DecorationPosition.background,
|
||||||
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
child: Padding(
|
||||||
selectedIconTheme: const IconThemeData(color: Colors.white),
|
padding: EdgeInsets.only(top: 40.0),
|
||||||
unselectedLabelStyle: const TextStyle(
|
child: Column(
|
||||||
color: Colors.white,
|
children: [
|
||||||
fontWeight: FontWeight.w500,
|
if (showVideoPlayerControls) const VideoControls(),
|
||||||
height: 2.3,
|
BottomNavigationBar(
|
||||||
),
|
elevation: 0.0,
|
||||||
selectedLabelStyle: const TextStyle(
|
backgroundColor: Colors.transparent,
|
||||||
color: Colors.white,
|
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
fontWeight: FontWeight.w500,
|
selectedIconTheme: const IconThemeData(color: Colors.white),
|
||||||
height: 2.3,
|
unselectedLabelStyle: const TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
unselectedFontSize: 14,
|
fontWeight: FontWeight.w500,
|
||||||
selectedFontSize: 14,
|
height: 2.3,
|
||||||
selectedItemColor: Colors.white,
|
),
|
||||||
unselectedItemColor: Colors.white,
|
selectedLabelStyle: const TextStyle(
|
||||||
showSelectedLabels: true,
|
color: Colors.white,
|
||||||
showUnselectedLabels: true,
|
fontWeight: FontWeight.w500,
|
||||||
items:
|
height: 2.3,
|
||||||
albumActions.map((e) => e.keys.first).toList(growable: false),
|
),
|
||||||
onTap: (index) {
|
unselectedFontSize: 14,
|
||||||
albumActions[index].values.first.call(index);
|
selectedFontSize: 14,
|
||||||
},
|
selectedItemColor: Colors.white,
|
||||||
|
unselectedItemColor: Colors.white,
|
||||||
|
showSelectedLabels: true,
|
||||||
|
showUnselectedLabels: true,
|
||||||
|
items: albumActions
|
||||||
|
.map((e) => e.keys.first)
|
||||||
|
.toList(growable: false),
|
||||||
|
onTap: (index) {
|
||||||
|
albumActions[index].values.first.call(index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
33
mobile/lib/widgets/asset_viewer/formatted_duration.dart
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
String _formatDuration(Duration position) {
|
||||||
|
final seconds = position.inSeconds.remainder(60).toString().padLeft(2, "0");
|
||||||
|
final minutes = position.inMinutes.remainder(60).toString().padLeft(2, "0");
|
||||||
|
if (position.inHours == 0) {
|
||||||
|
return "$minutes:$seconds";
|
||||||
|
}
|
||||||
|
final hours = position.inHours.toString().padLeft(2, '0');
|
||||||
|
return "$hours:$minutes:$seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
class FormattedDuration extends StatelessWidget {
|
||||||
|
final Duration data;
|
||||||
|
const FormattedDuration(this.data, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
|
||||||
|
child: Text(
|
||||||
|
_formatDuration(data),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14.0,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,125 +1,20 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
|
|
||||||
/// The video controls for the [videPlayerControlsProvider]
|
/// The video controls for the [videoPlayerControlsProvider]
|
||||||
class VideoControls extends ConsumerWidget {
|
class VideoControls extends ConsumerWidget {
|
||||||
const VideoControls({super.key});
|
const VideoControls({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final duration =
|
final isPortrait =
|
||||||
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
|
MediaQuery.orientationOf(context) == Orientation.portrait;
|
||||||
final position =
|
return isPortrait
|
||||||
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
|
? const VideoPosition()
|
||||||
|
: const Padding(
|
||||||
return AnimatedOpacity(
|
padding: EdgeInsets.symmetric(horizontal: 60.0),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
child: VideoPosition(),
|
||||||
duration: const Duration(milliseconds: 100),
|
);
|
||||||
child: OrientationBuilder(
|
|
||||||
builder: (context, orientation) => Container(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
|
|
||||||
),
|
|
||||||
color: Colors.black.withOpacity(0.4),
|
|
||||||
child: Padding(
|
|
||||||
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
|
||||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
|
||||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatDuration(position),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Slider(
|
|
||||||
value: duration == Duration.zero
|
|
||||||
? 0.0
|
|
||||||
: min(
|
|
||||||
position.inMicroseconds /
|
|
||||||
duration.inMicroseconds *
|
|
||||||
100,
|
|
||||||
100,
|
|
||||||
),
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
thumbColor: Colors.white,
|
|
||||||
activeColor: Colors.white,
|
|
||||||
inactiveColor: Colors.white.withOpacity(0.75),
|
|
||||||
onChanged: (position) {
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).position =
|
|
||||||
position;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatDuration(duration),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14.0,
|
|
||||||
color: Colors.white.withOpacity(.75),
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
ref.watch(
|
|
||||||
videoPlayerControlsProvider.select((value) => value.mute),
|
|
||||||
)
|
|
||||||
? Icons.volume_off
|
|
||||||
: Icons.volume_up,
|
|
||||||
),
|
|
||||||
onPressed: () => ref
|
|
||||||
.read(videoPlayerControlsProvider.notifier)
|
|
||||||
.toggleMute(),
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(Duration position) {
|
|
||||||
final ms = position.inMilliseconds;
|
|
||||||
|
|
||||||
int seconds = ms ~/ 1000;
|
|
||||||
final int hours = seconds ~/ 3600;
|
|
||||||
seconds = seconds % 3600;
|
|
||||||
final minutes = seconds ~/ 60;
|
|
||||||
seconds = seconds % 60;
|
|
||||||
|
|
||||||
final hoursString = hours >= 10
|
|
||||||
? '$hours'
|
|
||||||
: hours == 0
|
|
||||||
? '00'
|
|
||||||
: '0$hours';
|
|
||||||
|
|
||||||
final minutesString = minutes >= 10
|
|
||||||
? '$minutes'
|
|
||||||
: minutes == 0
|
|
||||||
? '00'
|
|
||||||
: '0$minutes';
|
|
||||||
|
|
||||||
final secondsString = seconds >= 10
|
|
||||||
? '$seconds'
|
|
||||||
: seconds == 0
|
|
||||||
? '00'
|
|
||||||
: '0$seconds';
|
|
||||||
|
|
||||||
final formattedTime =
|
|
||||||
'${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
|
|
||||||
|
|
||||||
return formattedTime;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
110
mobile/lib/widgets/asset_viewer/video_position.dart
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||||
|
|
||||||
|
class VideoPosition extends HookConsumerWidget {
|
||||||
|
const VideoPosition({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final (position, duration) = ref.watch(
|
||||||
|
videoPlaybackValueProvider.select((v) => (v.position, v.duration)),
|
||||||
|
);
|
||||||
|
final wasPlaying = useRef<bool>(true);
|
||||||
|
return duration == Duration.zero
|
||||||
|
? const _VideoPositionPlaceholder()
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
// align with slider's inherent padding
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
FormattedDuration(position),
|
||||||
|
FormattedDuration(duration),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: min(
|
||||||
|
position.inMicroseconds / duration.inMicroseconds * 100,
|
||||||
|
100,
|
||||||
|
),
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
onChangeStart: (value) {
|
||||||
|
final state =
|
||||||
|
ref.read(videoPlaybackValueProvider).state;
|
||||||
|
wasPlaying.value = state != VideoPlaybackState.paused;
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
},
|
||||||
|
onChangeEnd: (value) {
|
||||||
|
if (wasPlaying.value) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChanged: (position) {
|
||||||
|
ref
|
||||||
|
.read(videoPlayerControlsProvider.notifier)
|
||||||
|
.position = position;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPositionPlaceholder extends StatelessWidget {
|
||||||
|
const _VideoPositionPlaceholder();
|
||||||
|
|
||||||
|
static void _onChangedDummy(_) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
FormattedDuration(Duration.zero),
|
||||||
|
FormattedDuration(Duration.zero),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: 0.0,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
thumbColor: Colors.white,
|
||||||
|
activeColor: Colors.white,
|
||||||
|
inactiveColor: whiteOpacity75,
|
||||||
|
onChanged: _onChangedDummy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -28,6 +28,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
bool isHorizontal = !context.isMobile;
|
bool isHorizontal = !context.isMobile;
|
||||||
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isLoggingOut = useState(false);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
@ -63,11 +64,16 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildActionButton(IconData icon, String text, Function() onTap) {
|
buildActionButton(
|
||||||
|
IconData icon,
|
||||||
|
String text,
|
||||||
|
Function() onTap, {
|
||||||
|
Widget? trailing,
|
||||||
|
}) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.standard,
|
visualDensity: VisualDensity.standard,
|
||||||
contentPadding: const EdgeInsets.only(left: 30),
|
contentPadding: const EdgeInsets.only(left: 30, right: 30),
|
||||||
minLeadingWidth: 40,
|
minLeadingWidth: 40,
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@ -83,6 +89,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
).tr(),
|
).tr(),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
trailing: trailing,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +114,10 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
Icons.logout_rounded,
|
Icons.logout_rounded,
|
||||||
"profile_drawer_sign_out",
|
"profile_drawer_sign_out",
|
||||||
() async {
|
() async {
|
||||||
|
if (isLoggingOut.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext ctx) {
|
builder: (BuildContext ctx) {
|
||||||
@ -115,7 +126,11 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
content: "app_bar_signout_dialog_content",
|
content: "app_bar_signout_dialog_content",
|
||||||
ok: "app_bar_signout_dialog_ok",
|
ok: "app_bar_signout_dialog_ok",
|
||||||
onOk: () async {
|
onOk: () async {
|
||||||
await ref.read(authenticationProvider.notifier).logout();
|
isLoggingOut.value = true;
|
||||||
|
await ref
|
||||||
|
.read(authenticationProvider.notifier)
|
||||||
|
.logout()
|
||||||
|
.whenComplete(() => isLoggingOut.value = false);
|
||||||
|
|
||||||
ref.read(manualUploadProvider.notifier).cancelBackup();
|
ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||||
ref.read(backupProvider.notifier).cancelBackup();
|
ref.read(backupProvider.notifier).cancelBackup();
|
||||||
@ -127,6 +142,12 @@ class ImmichAppBarDialog extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
trailing: isLoggingOut.value
|
||||||
|
? SizedBox.square(
|
||||||
|
dimension: 20,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
2
mobile/openapi/README.md
generated
@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.120.1
|
- API version: 1.120.2
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.120.1+166
|
version: 1.120.2+167
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
@ -7436,7 +7436,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
6
open-api/typescript-sdk/package-lock.json
generated
@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
@ -19,7 +19,7 @@
|
|||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.120.1
|
* 1.120.2
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:20241105@sha256:99eec44db9e281e30eb9c50161cfb8e810f06e4338896b900fb5cafd09e82cd5 AS dev
|
FROM ghcr.io/immich-app/base-server-dev:20241112@sha256:889647c747b3f999b05e387eff414bcec5e42477958b267930e58ac58dadcfc7 AS dev
|
||||||
|
|
||||||
RUN apt-get install --no-install-recommends -yqq tini
|
RUN apt-get install --no-install-recommends -yqq tini
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
|
|||||||
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
|
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
|
||||||
|
|
||||||
# web build
|
# web build
|
||||||
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS web
|
FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82 AS web
|
||||||
|
|
||||||
WORKDIR /usr/src/open-api/typescript-sdk
|
WORKDIR /usr/src/open-api/typescript-sdk
|
||||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||||
@ -42,7 +42,7 @@ RUN npm run build
|
|||||||
|
|
||||||
|
|
||||||
# prod build
|
# prod build
|
||||||
FROM ghcr.io/immich-app/base-server-prod:20241105@sha256:dbe566f5c53f36640da910ca86a7c5575a26e9b9f6bc8d90ae0a53b8bc3a1f73
|
FROM ghcr.io/immich-app/base-server-prod:20241112@sha256:26a209563689f52b9a63feeedde9a16a8e0e558483cd3feb5c936423e55c7eea
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
6
server/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^10.0.1",
|
"@nestjs/bullmq": "^10.0.1",
|
||||||
@ -83,7 +83,7 @@
|
|||||||
"@types/lodash": "^4.14.197",
|
"@types/lodash": "^4.14.197",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/picomatch": "^3.0.0",
|
"@types/picomatch": "^3.0.0",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@ -108,7 +108,7 @@
|
|||||||
"@types/lodash": "^4.14.197",
|
"@types/lodash": "^4.14.197",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/picomatch": "^3.0.0",
|
"@types/picomatch": "^3.0.0",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
|
@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
|||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||||
|
import { getAssetDateTime } from 'src/utils/date-time';
|
||||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class AlbumInfoDto {
|
export class AlbumInfoDto {
|
||||||
@ -164,8 +165,8 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt
|
|||||||
const hasSharedLink = entity.sharedLinks?.length > 0;
|
const hasSharedLink = entity.sharedLinks?.length > 0;
|
||||||
const hasSharedUser = sharedUsers.length > 0;
|
const hasSharedUser = sharedUsers.length > 0;
|
||||||
|
|
||||||
let startDate = assets.at(0)?.fileCreatedAt || undefined;
|
let startDate = getAssetDateTime(assets.at(0));
|
||||||
let endDate = assets.at(-1)?.fileCreatedAt || undefined;
|
let endDate = getAssetDateTime(assets.at(-1));
|
||||||
// Swap dates if start date is greater than end date.
|
// Swap dates if start date is greater than end date.
|
||||||
if (startDate && endDate && startDate > endDate) {
|
if (startDate && endDate && startDate > endDate) {
|
||||||
[startDate, endDate] = [endDate, startDate];
|
[startDate, endDate] = [endDate, startDate];
|
||||||
|
@ -114,7 +114,12 @@ export interface ImageBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecSWConfig {
|
export interface VideoCodecSWConfig {
|
||||||
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
|
getCommand(
|
||||||
|
target: TranscodeTarget,
|
||||||
|
videoStream: VideoStreamInfo,
|
||||||
|
audioStream: AudioStreamInfo,
|
||||||
|
format?: VideoFormat,
|
||||||
|
): TranscodeCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
|
||||||
|
@ -196,5 +196,35 @@ describe(BackupService.name, () => {
|
|||||||
expect(storageMock.unlink).toHaveBeenCalled();
|
expect(storageMock.unlink).toHaveBeenCalled();
|
||||||
expect(result).toBe(JobStatus.FAILED);
|
expect(result).toBe(JobStatus.FAILED);
|
||||||
});
|
});
|
||||||
|
it.each`
|
||||||
|
postgresVersion | expectedVersion
|
||||||
|
${'14.10'} | ${14}
|
||||||
|
${'14.10.3'} | ${14}
|
||||||
|
${'14.10 (Debian 14.10-1.pgdg120+1)'} | ${14}
|
||||||
|
${'15.3.3'} | ${15}
|
||||||
|
${'16.4.2'} | ${16}
|
||||||
|
${'17.15.1'} | ${17}
|
||||||
|
`(
|
||||||
|
`should use pg_dumpall $expectedVersion with postgres version $postgresVersion`,
|
||||||
|
async ({ postgresVersion, expectedVersion }) => {
|
||||||
|
databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||||
|
await sut.handleBackupDatabase();
|
||||||
|
expect(processMock.spawn).toHaveBeenCalledWith(
|
||||||
|
`/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`,
|
||||||
|
expect.any(Array),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each`
|
||||||
|
postgresVersion
|
||||||
|
${'13.99.99'}
|
||||||
|
${'18.0.0'}
|
||||||
|
`(`should fail if postgres version $postgresVersion is not supported`, async ({ postgresVersion }) => {
|
||||||
|
databaseMock.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||||
|
const result = await sut.handleBackupDatabase();
|
||||||
|
expect(processMock.spawn).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(JobStatus.FAILED);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { default as path } from 'node:path';
|
import { default as path } from 'node:path';
|
||||||
|
import semver from 'semver';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { ImmichWorker, StorageFolder } from 'src/enum';
|
import { ImmichWorker, StorageFolder } from 'src/enum';
|
||||||
@ -101,14 +102,29 @@ export class BackupService extends BaseService {
|
|||||||
`immich-db-backup-${Date.now()}.sql.gz.tmp`,
|
`immich-db-backup-${Date.now()}.sql.gz.tmp`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const databaseVersion = await this.databaseRepository.getPostgresVersion();
|
||||||
|
const databaseSemver = semver.coerce(databaseVersion);
|
||||||
|
const databaseMajorVersion = databaseSemver?.major;
|
||||||
|
|
||||||
|
if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <18.0.0')) {
|
||||||
|
this.logger.error(`Database Backup Failure: Unsupported PostgreSQL version: ${databaseVersion}`);
|
||||||
|
return JobStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const pgdump = this.processRepository.spawn(`pg_dumpall`, databaseParams, {
|
const pgdump = this.processRepository.spawn(
|
||||||
env: {
|
`/usr/lib/postgresql/${databaseMajorVersion}/bin/pg_dumpall`,
|
||||||
PATH: process.env.PATH,
|
databaseParams,
|
||||||
PGPASSWORD: isUrlConnection ? undefined : config.password,
|
{
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
PGPASSWORD: isUrlConnection ? undefined : config.password,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
// NOTE: `--rsyncable` is only supported in GNU gzip
|
// NOTE: `--rsyncable` is only supported in GNU gzip
|
||||||
const gzip = this.processRepository.spawn(`gzip`, ['--rsyncable']);
|
const gzip = this.processRepository.spawn(`gzip`, ['--rsyncable']);
|
||||||
@ -169,7 +185,7 @@ export class BackupService extends BaseService {
|
|||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`Database Backup Success`);
|
this.logger.log(`Database Backup Success`);
|
||||||
await this.cleanupDatabaseBackups();
|
await this.cleanupDatabaseBackups();
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
@ -487,6 +487,22 @@ describe(MediaService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('should not skip intra frames for MTS file', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamMTS);
|
||||||
|
assetMock.getById.mockResolvedValue(assetStub.video);
|
||||||
|
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||||
|
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||||
|
expect.objectContaining({
|
||||||
|
inputOptions: ['-sws_flags accurate_rnd+full_chroma_int'],
|
||||||
|
outputOptions: expect.any(Array),
|
||||||
|
progress: expect.any(Object),
|
||||||
|
twoPass: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
|
@ -214,7 +214,7 @@ export class MediaService extends BaseService {
|
|||||||
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
|
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
|
||||||
const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true';
|
const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true';
|
||||||
|
|
||||||
const orientation = Number(asset.exifInfo?.orientation) || undefined;
|
const orientation = useExtracted && asset.exifInfo?.orientation ? Number(asset.exifInfo.orientation) : undefined;
|
||||||
const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size, orientation };
|
const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size, orientation };
|
||||||
const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions);
|
const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions);
|
||||||
|
|
||||||
@ -239,7 +239,7 @@ export class MediaService extends BaseService {
|
|||||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||||
this.storageCore.ensureFolders(previewPath);
|
this.storageCore.ensureFolders(previewPath);
|
||||||
|
|
||||||
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||||
const mainVideoStream = this.getMainStream(videoStreams);
|
const mainVideoStream = this.getMainStream(videoStreams);
|
||||||
if (!mainVideoStream) {
|
if (!mainVideoStream) {
|
||||||
throw new Error(`No video streams found for asset ${asset.id}`);
|
throw new Error(`No video streams found for asset ${asset.id}`);
|
||||||
@ -248,9 +248,14 @@ export class MediaService extends BaseService {
|
|||||||
|
|
||||||
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
||||||
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
||||||
|
const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream, format);
|
||||||
const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
|
const thumbnailOptions = thumbnailConfig.getCommand(
|
||||||
const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
|
TranscodeTarget.VIDEO,
|
||||||
|
mainVideoStream,
|
||||||
|
mainAudioStream,
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
this.logger.error(format.formatName);
|
||||||
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
||||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
||||||
|
|
||||||
|
5
server/src/utils/date-time.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
|
||||||
|
export const getAssetDateTime = (asset: AssetEntity | undefined) => {
|
||||||
|
return asset?.exifInfo?.dateTimeOriginal || asset?.fileCreatedAt;
|
||||||
|
};
|
@ -6,6 +6,7 @@ import {
|
|||||||
TranscodeCommand,
|
TranscodeCommand,
|
||||||
VideoCodecHWConfig,
|
VideoCodecHWConfig,
|
||||||
VideoCodecSWConfig,
|
VideoCodecSWConfig,
|
||||||
|
VideoFormat,
|
||||||
VideoStreamInfo,
|
VideoStreamInfo,
|
||||||
} from 'src/interfaces/media.interface';
|
} from 'src/interfaces/media.interface';
|
||||||
|
|
||||||
@ -77,9 +78,14 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
return handler;
|
return handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
getCommand(
|
||||||
|
target: TranscodeTarget,
|
||||||
|
videoStream: VideoStreamInfo,
|
||||||
|
audioStream?: AudioStreamInfo,
|
||||||
|
format?: VideoFormat,
|
||||||
|
) {
|
||||||
const options = {
|
const options = {
|
||||||
inputOptions: this.getBaseInputOptions(videoStream),
|
inputOptions: this.getBaseInputOptions(videoStream, format),
|
||||||
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
|
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
|
||||||
twoPass: this.eligibleForTwoPass(),
|
twoPass: this.eligibleForTwoPass(),
|
||||||
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
|
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
|
||||||
@ -101,7 +107,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
getBaseInputOptions(videoStream: VideoStreamInfo): string[] {
|
getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
|
||||||
return this.getInputThreadOptions();
|
return this.getInputThreadOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,8 +383,11 @@ export class ThumbnailConfig extends BaseConfig {
|
|||||||
return new ThumbnailConfig(config);
|
return new ThumbnailConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseInputOptions(): string[] {
|
getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] {
|
||||||
return ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
|
// skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details.
|
||||||
|
return format?.formatName === 'mpegts'
|
||||||
|
? ['-sws_flags accurate_rnd+full_chroma_int']
|
||||||
|
: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
|
||||||
}
|
}
|
||||||
|
|
||||||
getBaseOutputOptions() {
|
getBaseOutputOptions() {
|
||||||
|
7
server/test/fixtures/media.stub.ts
vendored
@ -95,6 +95,13 @@ export const probeStub = {
|
|||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
|
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
|
||||||
}),
|
}),
|
||||||
|
videoStreamMTS: Object.freeze<VideoInfo>({
|
||||||
|
...probeStubDefault,
|
||||||
|
format: {
|
||||||
|
...probeStubDefaultFormat,
|
||||||
|
formatName: 'mpegts',
|
||||||
|
},
|
||||||
|
}),
|
||||||
videoStreamHDR: Object.freeze<VideoInfo>({
|
videoStreamHDR: Object.freeze<VideoInfo>({
|
||||||
...probeStubDefault,
|
...probeStubDefault,
|
||||||
videoStreams: [
|
videoStreams: [
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3
|
FROM node:22.11.0-alpine3.20@sha256:dc8ba2f61dd86c44e43eb25a7812ad03c5b1b224a19fc6f77e1eb9e5669f0b82
|
||||||
|
|
||||||
RUN apk add --no-cache tini
|
RUN apk add --no-cache tini
|
||||||
USER node
|
USER node
|
||||||
|
14
web/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.7.8",
|
"@formatjs/icu-messageformat-parser": "^2.7.8",
|
||||||
@ -36,7 +36,7 @@
|
|||||||
"@faker-js/faker": "^9.0.0",
|
"@faker-js/faker": "^9.0.0",
|
||||||
"@socket.io/component-emitter": "^3.1.0",
|
"@socket.io/component-emitter": "^3.1.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
"@sveltejs/enhanced-img": "^0.3.0",
|
"@sveltejs/enhanced-img": "^0.3.9",
|
||||||
"@sveltejs/kit": "^2.7.2",
|
"@sveltejs/kit": "^2.7.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
@ -53,7 +53,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.43.0",
|
"eslint-plugin-svelte": "^2.45.1",
|
||||||
"eslint-plugin-unicorn": "^55.0.0",
|
"eslint-plugin-unicorn": "^55.0.0",
|
||||||
"factory.ts": "^1.4.1",
|
"factory.ts": "^1.4.1",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
@ -68,19 +68,19 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.4.4",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.8.6",
|
"@types/node": "^22.9.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.120.1",
|
"version": "1.120.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||||
@ -28,7 +28,7 @@
|
|||||||
"@faker-js/faker": "^9.0.0",
|
"@faker-js/faker": "^9.0.0",
|
||||||
"@socket.io/component-emitter": "^3.1.0",
|
"@socket.io/component-emitter": "^3.1.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.5",
|
||||||
"@sveltejs/enhanced-img": "^0.3.0",
|
"@sveltejs/enhanced-img": "^0.3.9",
|
||||||
"@sveltejs/kit": "^2.7.2",
|
"@sveltejs/kit": "^2.7.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
@ -45,7 +45,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.43.0",
|
"eslint-plugin-svelte": "^2.45.1",
|
||||||
"eslint-plugin-unicorn": "^55.0.0",
|
"eslint-plugin-unicorn": "^55.0.0",
|
||||||
"factory.ts": "^1.4.1",
|
"factory.ts": "^1.4.1",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
@ -60,7 +60,7 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.4.4",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^2.0.5"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
|
|
||||||
export let show: boolean;
|
interface Props {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { show = $bindable() }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button type="button" on:click={() => (show = true)}>Open</button>
|
<button type="button" onclick={() => (show = true)}>Open</button>
|
||||||
|
|
||||||
{#if show}
|
{#if show}
|
||||||
<div use:focusTrap>
|
<div use:focusTrap>
|
||||||
<div>
|
<div>
|
||||||
<span>text</span>
|
<span>text</span>
|
||||||
<button data-testid="one" type="button" on:click={() => (show = false)}>Close</button>
|
<button data-testid="one" type="button" onclick={() => (show = false)}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
<input data-testid="two" disabled />
|
<input data-testid="two" disabled />
|
||||||
<input data-testid="three" />
|
<input data-testid="three" />
|
||||||
|
@ -1,7 +1,19 @@
|
|||||||
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
|
import { tick } from 'svelte';
|
||||||
if (!textarea) {
|
import type { Action } from 'svelte/action';
|
||||||
return;
|
|
||||||
}
|
type Parameters = {
|
||||||
textarea.style.height = height;
|
height?: string;
|
||||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
value: string; // added to enable reactivity
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoGrowHeight: Action<HTMLTextAreaElement, Parameters> = (textarea, { height = 'auto' }) => {
|
||||||
|
const update = () => {
|
||||||
|
void tick().then(() => {
|
||||||
|
textarea.style.height = height;
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
return { update };
|
||||||
};
|
};
|
||||||
|
@ -10,7 +10,7 @@ interface Options {
|
|||||||
/**
|
/**
|
||||||
* The container element that with direct children that should be navigated.
|
* The container element that with direct children that should be navigated.
|
||||||
*/
|
*/
|
||||||
container: HTMLElement;
|
container?: HTMLElement;
|
||||||
/**
|
/**
|
||||||
* Indicates if the dropdown is open.
|
* Indicates if the dropdown is open.
|
||||||
*/
|
*/
|
||||||
@ -52,7 +52,11 @@ export const contextMenuNavigation: Action<HTMLElement, Options> = (node, option
|
|||||||
await tick();
|
await tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = Array.from(container.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,15 @@ import type { Action } from 'svelte/action';
|
|||||||
* @param node Element which listens for keyboard events
|
* @param node Element which listens for keyboard events
|
||||||
* @param container Element containing the list of elements
|
* @param container Element containing the list of elements
|
||||||
*/
|
*/
|
||||||
export const listNavigation: Action<HTMLElement, HTMLElement> = (node, container: HTMLElement) => {
|
export const listNavigation: Action<HTMLElement, HTMLElement | undefined> = (
|
||||||
|
node: HTMLElement,
|
||||||
|
container?: HTMLElement,
|
||||||
|
) => {
|
||||||
const moveFocus = (direction: 'up' | 'down') => {
|
const moveFocus = (direction: 'up' | 'down') => {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const children = Array.from(container?.children);
|
const children = Array.from(container?.children);
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
@ -7,13 +7,17 @@
|
|||||||
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
|
import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
interface Props {
|
||||||
export let onSuccess: () => void;
|
user: UserResponseDto;
|
||||||
export let onFail: () => void;
|
onSuccess: () => void;
|
||||||
export let onCancel: () => void;
|
onFail: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
let forceDelete = false;
|
let { user, onSuccess, onFail, onCancel }: Props = $props();
|
||||||
let deleteButtonDisabled = false;
|
|
||||||
|
let forceDelete = $state(false);
|
||||||
|
let deleteButtonDisabled = $state(false);
|
||||||
let userIdInput: string = '';
|
let userIdInput: string = '';
|
||||||
|
|
||||||
const handleDeleteUser = async () => {
|
const handleDeleteUser = async () => {
|
||||||
@ -47,12 +51,14 @@
|
|||||||
{onCancel}
|
{onCancel}
|
||||||
disabled={deleteButtonDisabled}
|
disabled={deleteButtonDisabled}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
{#snippet promptSnippet()}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
{#if forceDelete}
|
{#if forceDelete}
|
||||||
<p>
|
<p>
|
||||||
<FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }} let:message>
|
<FormatMessage key="admin.user_delete_immediately" values={{ user: user.name }}>
|
||||||
<b>{message}</b>
|
{#snippet children({ message })}
|
||||||
|
<b>{message}</b>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
@ -60,9 +66,10 @@
|
|||||||
<FormatMessage
|
<FormatMessage
|
||||||
key="admin.user_delete_delay"
|
key="admin.user_delete_delay"
|
||||||
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
|
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
|
||||||
let:message
|
|
||||||
>
|
>
|
||||||
<b>{message}</b>
|
{#snippet children({ message })}
|
||||||
|
<b>{message}</b>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@ -73,7 +80,7 @@
|
|||||||
label={$t('admin.user_delete_immediately_checkbox')}
|
label={$t('admin.user_delete_immediately_checkbox')}
|
||||||
labelClass="text-sm dark:text-immich-dark-fg"
|
labelClass="text-sm dark:text-immich-dark-fg"
|
||||||
bind:checked={forceDelete}
|
bind:checked={forceDelete}
|
||||||
on:change={() => {
|
onchange={() => {
|
||||||
deleteButtonDisabled = forceDelete;
|
deleteButtonDisabled = forceDelete;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -92,9 +99,9 @@
|
|||||||
aria-describedby="confirm-user-desc"
|
aria-describedby="confirm-user-desc"
|
||||||
name="confirm-user-id"
|
name="confirm-user-id"
|
||||||
type="text"
|
type="text"
|
||||||
on:input={handleConfirm}
|
oninput={handleConfirm}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
<script lang="ts" context="module">
|
<script lang="ts" module>
|
||||||
export type Colors = 'light-gray' | 'gray' | 'dark-gray';
|
export type Colors = 'light-gray' | 'gray' | 'dark-gray';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let color: Colors;
|
import type { Snippet } from 'svelte';
|
||||||
export let disabled = false;
|
|
||||||
|
interface Props {
|
||||||
|
color: Colors;
|
||||||
|
disabled?: boolean;
|
||||||
|
children?: Snippet;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { color, disabled = false, onClick = () => {}, children }: Props = $props();
|
||||||
|
|
||||||
const colorClasses: Record<Colors, string> = {
|
const colorClasses: Record<Colors, string> = {
|
||||||
'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
|
'light-gray': 'bg-gray-300/80 dark:bg-gray-700',
|
||||||
@ -23,7 +31,7 @@
|
|||||||
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
|
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
|
||||||
color
|
color
|
||||||
]} {hoverClasses}"
|
]} {hoverClasses}"
|
||||||
on:click
|
onclick={onClick}
|
||||||
>
|
>
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
<script lang="ts" context="module">
|
<script lang="ts" module>
|
||||||
export type Color = 'success' | 'warning';
|
export type Color = 'success' | 'warning';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let color: Color;
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
color: Color;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { color, children }: Props = $props();
|
||||||
|
|
||||||
const colorClasses: Record<Color, string> = {
|
const colorClasses: Record<Color, string> = {
|
||||||
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
|
success: 'bg-green-500/70 text-gray-900 dark:bg-green-700/90 dark:text-gray-100',
|
||||||
@ -12,5 +19,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full p-2 text-center text-sm {colorClasses[color]}">
|
<div class="w-full p-2 text-center text-sm {colorClasses[color]}">
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,22 +19,37 @@
|
|||||||
import JobTileButton from './job-tile-button.svelte';
|
import JobTileButton from './job-tile-button.svelte';
|
||||||
import JobTileStatus from './job-tile-status.svelte';
|
import JobTileStatus from './job-tile-status.svelte';
|
||||||
|
|
||||||
export let title: string;
|
interface Props {
|
||||||
export let subtitle: string | undefined;
|
title: string;
|
||||||
export let description: Component | undefined;
|
subtitle: string | undefined;
|
||||||
export let jobCounts: JobCountsDto;
|
description: Component | undefined;
|
||||||
export let queueStatus: QueueStatusDto;
|
jobCounts: JobCountsDto;
|
||||||
export let icon: string;
|
queueStatus: QueueStatusDto;
|
||||||
export let disabled = false;
|
icon: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
allText: string | undefined;
|
||||||
|
refreshText: string | undefined;
|
||||||
|
missingText: string;
|
||||||
|
onCommand: (command: JobCommandDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export let allText: string | undefined;
|
let {
|
||||||
export let refreshText: string | undefined;
|
title,
|
||||||
export let missingText: string;
|
subtitle,
|
||||||
export let onCommand: (command: JobCommandDto) => void;
|
description,
|
||||||
|
jobCounts,
|
||||||
|
queueStatus,
|
||||||
|
icon,
|
||||||
|
disabled = false,
|
||||||
|
allText,
|
||||||
|
refreshText,
|
||||||
|
missingText,
|
||||||
|
onCommand,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
$: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
|
let waitingCount = $derived(jobCounts.waiting + jobCounts.paused + jobCounts.delayed);
|
||||||
$: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
|
let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused);
|
||||||
$: multipleButtons = allText || refreshText;
|
let multipleButtons = $derived(allText || refreshText);
|
||||||
|
|
||||||
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
|
const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6';
|
||||||
</script>
|
</script>
|
||||||
@ -67,7 +82,7 @@
|
|||||||
title={$t('clear_message')}
|
title={$t('clear_message')}
|
||||||
size="12"
|
size="12"
|
||||||
padding="1"
|
padding="1"
|
||||||
on:click={() => onCommand({ command: JobCommand.ClearFailed, force: false })}
|
onclick={() => onCommand({ command: JobCommand.ClearFailed, force: false })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -87,8 +102,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if description}
|
{#if description}
|
||||||
|
{@const SvelteComponent = description}
|
||||||
<div class="text-sm dark:text-white">
|
<div class="text-sm dark:text-white">
|
||||||
<svelte:component this={description} />
|
<SvelteComponent />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@ -118,7 +134,7 @@
|
|||||||
<JobTileButton
|
<JobTileButton
|
||||||
disabled={true}
|
disabled={true}
|
||||||
color="light-gray"
|
color="light-gray"
|
||||||
on:click={() => onCommand({ command: JobCommand.Start, force: false })}
|
onClick={() => onCommand({ command: JobCommand.Start, force: false })}
|
||||||
>
|
>
|
||||||
<Icon path={mdiAlertCircle} size="36" />
|
<Icon path={mdiAlertCircle} size="36" />
|
||||||
{$t('disabled').toUpperCase()}
|
{$t('disabled').toUpperCase()}
|
||||||
@ -127,20 +143,20 @@
|
|||||||
|
|
||||||
{#if !disabled && !isIdle}
|
{#if !disabled && !isIdle}
|
||||||
{#if waitingCount > 0}
|
{#if waitingCount > 0}
|
||||||
<JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Empty, force: false })}>
|
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Empty, force: false })}>
|
||||||
<Icon path={mdiClose} size="24" />
|
<Icon path={mdiClose} size="24" />
|
||||||
{$t('clear').toUpperCase()}
|
{$t('clear').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
{#if queueStatus.isPaused}
|
{#if queueStatus.isPaused}
|
||||||
{@const size = waitingCount > 0 ? '24' : '48'}
|
{@const size = waitingCount > 0 ? '24' : '48'}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Resume, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Resume, force: false })}>
|
||||||
<!-- size property is not reactive, so have to use width and height -->
|
<!-- size property is not reactive, so have to use width and height -->
|
||||||
<Icon path={mdiFastForward} {size} />
|
<Icon path={mdiFastForward} {size} />
|
||||||
{$t('resume').toUpperCase()}
|
{$t('resume').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{:else}
|
{:else}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Pause, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Pause, force: false })}>
|
||||||
<Icon path={mdiPause} size="24" />
|
<Icon path={mdiPause} size="24" />
|
||||||
{$t('pause').toUpperCase()}
|
{$t('pause').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
@ -149,25 +165,25 @@
|
|||||||
|
|
||||||
{#if !disabled && multipleButtons && isIdle}
|
{#if !disabled && multipleButtons && isIdle}
|
||||||
{#if allText}
|
{#if allText}
|
||||||
<JobTileButton color="dark-gray" on:click={() => onCommand({ command: JobCommand.Start, force: true })}>
|
<JobTileButton color="dark-gray" onClick={() => onCommand({ command: JobCommand.Start, force: true })}>
|
||||||
<Icon path={mdiAllInclusive} size="24" />
|
<Icon path={mdiAllInclusive} size="24" />
|
||||||
{allText}
|
{allText}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
{#if refreshText}
|
{#if refreshText}
|
||||||
<JobTileButton color="gray" on:click={() => onCommand({ command: JobCommand.Start, force: undefined })}>
|
<JobTileButton color="gray" onClick={() => onCommand({ command: JobCommand.Start, force: undefined })}>
|
||||||
<Icon path={mdiImageRefreshOutline} size="24" />
|
<Icon path={mdiImageRefreshOutline} size="24" />
|
||||||
{refreshText}
|
{refreshText}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
|
||||||
<Icon path={mdiSelectionSearch} size="24" />
|
<Icon path={mdiSelectionSearch} size="24" />
|
||||||
{missingText}
|
{missingText}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !disabled && !multipleButtons && isIdle}
|
{#if !disabled && !multipleButtons && isIdle}
|
||||||
<JobTileButton color="light-gray" on:click={() => onCommand({ command: JobCommand.Start, force: false })}>
|
<JobTileButton color="light-gray" onClick={() => onCommand({ command: JobCommand.Start, force: false })}>
|
||||||
<Icon path={mdiPlay} size="48" />
|
<Icon path={mdiPlay} size="48" />
|
||||||
{$t('start').toUpperCase()}
|
{$t('start').toUpperCase()}
|
||||||
</JobTileButton>
|
</JobTileButton>
|
||||||
|
@ -25,7 +25,11 @@
|
|||||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let jobs: AllJobStatusResponseDto;
|
interface Props {
|
||||||
|
jobs: AllJobStatusResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { jobs = $bindable() }: Props = $props();
|
||||||
|
|
||||||
interface JobDetails {
|
interface JobDetails {
|
||||||
title: string;
|
title: string;
|
||||||
@ -56,8 +60,7 @@
|
|||||||
await handleCommand(jobId, dto);
|
await handleCommand(jobId, dto);
|
||||||
};
|
};
|
||||||
|
|
||||||
// svelte-ignore reactive_declaration_non_reactive_property
|
let jobDetails: Partial<Record<JobName, JobDetails>> = {
|
||||||
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{
|
|
||||||
[JobName.ThumbnailGeneration]: {
|
[JobName.ThumbnailGeneration]: {
|
||||||
icon: mdiFileJpgBox,
|
icon: mdiFileJpgBox,
|
||||||
title: $getJobName(JobName.ThumbnailGeneration),
|
title: $getJobName(JobName.ThumbnailGeneration),
|
||||||
@ -142,7 +145,8 @@
|
|||||||
missingText: $t('missing'),
|
missingText: $t('missing'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
$: jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
|
|
||||||
|
let jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
|
||||||
|
|
||||||
async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
|
async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
|
||||||
const title = jobDetails[jobId]?.title;
|
const title = jobDetails[jobId]?.title;
|
||||||
|
@ -7,12 +7,13 @@
|
|||||||
<FormatMessage
|
<FormatMessage
|
||||||
key="admin.storage_template_migration_description"
|
key="admin.storage_template_migration_description"
|
||||||
values={{ template: $t('admin.storage_template_settings') }}
|
values={{ template: $t('admin.storage_template_settings') }}
|
||||||
let:message
|
|
||||||
>
|
>
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
|
<a
|
||||||
class="text-immich-primary dark:text-immich-dark-primary"
|
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
|
||||||
>
|
class="text-immich-primary dark:text-immich-dark-primary"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
|
@ -5,10 +5,14 @@
|
|||||||
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
|
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
interface Props {
|
||||||
export let onSuccess: () => void;
|
user: UserResponseDto;
|
||||||
export let onFail: () => void;
|
onSuccess: () => void;
|
||||||
export let onCancel: () => void;
|
onFail: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { user, onSuccess, onFail, onCancel }: Props = $props();
|
||||||
|
|
||||||
const handleRestoreUser = async () => {
|
const handleRestoreUser = async () => {
|
||||||
try {
|
try {
|
||||||
@ -32,11 +36,13 @@
|
|||||||
onConfirm={handleRestoreUser}
|
onConfirm={handleRestoreUser}
|
||||||
{onCancel}
|
{onCancel}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
{#snippet promptSnippet()}
|
||||||
<p>
|
<p>
|
||||||
<FormatMessage key="admin.user_restore_description" values={{ user: user.name }} let:message>
|
<FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
|
||||||
<b>{message}</b>
|
{#snippet children({ message })}
|
||||||
|
<b>{message}</b>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
@ -7,14 +7,20 @@
|
|||||||
import StatsCard from './stats-card.svelte';
|
import StatsCard from './stats-card.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let stats: ServerStatsResponseDto = {
|
interface Props {
|
||||||
photos: 0,
|
stats?: ServerStatsResponseDto;
|
||||||
videos: 0,
|
}
|
||||||
usage: 0,
|
|
||||||
usageByUser: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
$: zeros = (value: number) => {
|
let {
|
||||||
|
stats = {
|
||||||
|
photos: 0,
|
||||||
|
videos: 0,
|
||||||
|
usage: 0,
|
||||||
|
usageByUser: [],
|
||||||
|
},
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const zeros = (value: number) => {
|
||||||
const maxLength = 13;
|
const maxLength = 13;
|
||||||
const valueLength = value.toString().length;
|
const valueLength = value.toString().length;
|
||||||
const zeroLength = maxLength - valueLength;
|
const zeroLength = maxLength - valueLength;
|
||||||
@ -23,7 +29,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TiB = 1024 ** 4;
|
const TiB = 1024 ** 4;
|
||||||
$: [statsUsage, statsUsageUnit] = getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0);
|
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
@ -2,18 +2,22 @@
|
|||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { ByteUnit } from '$lib/utils/byte-units';
|
import { ByteUnit } from '$lib/utils/byte-units';
|
||||||
|
|
||||||
export let icon: string;
|
interface Props {
|
||||||
export let title: string;
|
icon: string;
|
||||||
export let value: number;
|
title: string;
|
||||||
export let unit: ByteUnit | undefined = undefined;
|
value: number;
|
||||||
|
unit?: ByteUnit | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
$: zeros = () => {
|
let { icon, title, value, unit = undefined }: Props = $props();
|
||||||
|
|
||||||
|
const zeros = $derived(() => {
|
||||||
const maxLength = 13;
|
const maxLength = 13;
|
||||||
const valueLength = value.toString().length;
|
const valueLength = value.toString().length;
|
||||||
const zeroLength = maxLength - valueLength;
|
const zeroLength = maxLength - valueLength;
|
||||||
|
|
||||||
return '0'.repeat(zeroLength);
|
return '0'.repeat(zeroLength);
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
|
<div class="flex h-[140px] w-[250px] flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
<svelte:options accessors />
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@ -13,12 +11,17 @@
|
|||||||
import type { SettingsResetOptions } from './admin-settings';
|
import type { SettingsResetOptions } from './admin-settings';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let config: SystemConfigDto;
|
interface Props {
|
||||||
|
config: SystemConfigDto;
|
||||||
|
children: import('svelte').Snippet<[{ savedConfig: SystemConfigDto; defaultConfig: SystemConfigDto }]>;
|
||||||
|
}
|
||||||
|
|
||||||
let savedConfig: SystemConfigDto;
|
let { config = $bindable(), children }: Props = $props();
|
||||||
let defaultConfig: SystemConfigDto;
|
|
||||||
|
|
||||||
const handleReset = async (options: SettingsResetOptions) => {
|
let savedConfig: SystemConfigDto | undefined = $state();
|
||||||
|
let defaultConfig: SystemConfigDto | undefined = $state();
|
||||||
|
|
||||||
|
export const handleReset = async (options: SettingsResetOptions) => {
|
||||||
await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
|
await (options.default ? resetToDefault(options.configKeys) : reset(options.configKeys));
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -26,7 +29,8 @@
|
|||||||
let systemConfigDto = {
|
let systemConfigDto = {
|
||||||
...savedConfig,
|
...savedConfig,
|
||||||
...update,
|
...update,
|
||||||
};
|
} as SystemConfigDto;
|
||||||
|
|
||||||
if (isEqual(systemConfigDto, savedConfig)) {
|
if (isEqual(systemConfigDto, savedConfig)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -59,6 +63,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
|
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
|
||||||
|
if (!defaultConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of configKeys) {
|
for (const key of configKeys) {
|
||||||
config = { ...config, [key]: defaultConfig[key] };
|
config = { ...config, [key]: defaultConfig[key] };
|
||||||
}
|
}
|
||||||
@ -75,5 +83,5 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if savedConfig && defaultConfig}
|
{#if savedConfig && defaultConfig}
|
||||||
<slot {handleReset} {handleSave} {savedConfig} {defaultConfig} />
|
{@render children({ savedConfig, defaultConfig })}
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -2,9 +2,7 @@
|
|||||||
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
|
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { type SystemConfigDto } from '@immich/sdk';
|
import { type SystemConfigDto } from '@immich/sdk';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
@ -12,15 +10,20 @@
|
|||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
let isConfirmOpen = false;
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
let isConfirmOpen = $state(false);
|
||||||
|
|
||||||
const handleToggleOverride = () => {
|
const handleToggleOverride = () => {
|
||||||
// click runs before bind
|
// click runs before bind
|
||||||
@ -48,29 +51,31 @@
|
|||||||
onCancel={() => (isConfirmOpen = false)}
|
onCancel={() => (isConfirmOpen = false)}
|
||||||
onConfirm={() => handleSave(true)}
|
onConfirm={() => handleSave(true)}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="prompt">
|
{#snippet promptSnippet()}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<p>{$t('admin.authentication_settings_disable_all')}</p>
|
<p>{$t('admin.authentication_settings_disable_all')}</p>
|
||||||
<p>
|
<p>
|
||||||
<FormatMessage key="admin.authentication_settings_reenable" let:message>
|
<FormatMessage key="admin.authentication_settings_reenable">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://immich.app/docs/administration/server-commands"
|
<a
|
||||||
rel="noreferrer"
|
href="https://immich.app/docs/administration/server-commands"
|
||||||
target="_blank"
|
rel="noreferrer"
|
||||||
class="underline"
|
target="_blank"
|
||||||
>
|
class="underline"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
|
||||||
<div class="ml-4 mt-4 flex flex-col">
|
<div class="ml-4 mt-4 flex flex-col">
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
key="oauth"
|
key="oauth"
|
||||||
@ -79,15 +84,17 @@
|
|||||||
>
|
>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.oauth_settings_more_details" let:message>
|
<FormatMessage key="admin.oauth_settings_more_details">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://immich.app/docs/administration/oauth"
|
<a
|
||||||
class="underline"
|
href="https://immich.app/docs/administration/oauth"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -147,7 +154,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
|
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()}
|
||||||
desc={$t('admin.oauth_profile_signing_algorithm_description')}
|
description={$t('admin.oauth_profile_signing_algorithm_description')}
|
||||||
bind:value={config.oauth.profileSigningAlgorithm}
|
bind:value={config.oauth.profileSigningAlgorithm}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@ -157,7 +164,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
|
label={$t('admin.oauth_storage_label_claim').toUpperCase()}
|
||||||
desc={$t('admin.oauth_storage_label_claim_description')}
|
description={$t('admin.oauth_storage_label_claim_description')}
|
||||||
bind:value={config.oauth.storageLabelClaim}
|
bind:value={config.oauth.storageLabelClaim}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@ -167,7 +174,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
|
label={$t('admin.oauth_storage_quota_claim').toUpperCase()}
|
||||||
desc={$t('admin.oauth_storage_quota_claim_description')}
|
description={$t('admin.oauth_storage_quota_claim_description')}
|
||||||
bind:value={config.oauth.storageQuotaClaim}
|
bind:value={config.oauth.storageQuotaClaim}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@ -177,7 +184,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
|
label={$t('admin.oauth_storage_quota_default').toUpperCase()}
|
||||||
desc={$t('admin.oauth_storage_quota_default_description')}
|
description={$t('admin.oauth_storage_quota_default_description')}
|
||||||
bind:value={config.oauth.defaultStorageQuota}
|
bind:value={config.oauth.defaultStorageQuota}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
@ -213,7 +220,7 @@
|
|||||||
values: { callback: 'app.immich:///oauth-callback' },
|
values: { callback: 'app.immich:///oauth-callback' },
|
||||||
})}
|
})}
|
||||||
disabled={disabled || !config.oauth.enabled}
|
disabled={disabled || !config.oauth.enabled}
|
||||||
on:click={() => handleToggleOverride()}
|
onToggle={() => handleToggleOverride()}
|
||||||
bind:checked={config.oauth.mobileOverrideEnabled}
|
bind:checked={config.oauth.mobileOverrideEnabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -3,33 +3,40 @@
|
|||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
$: cronExpressionOptions = [
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
let cronExpressionOptions = $derived([
|
||||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||||
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
|
{ text: $t('interval.night_at_twoam'), value: '0 02 * * *' },
|
||||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.backup_database_enable_description')}
|
title={$t('admin.backup_database_enable_description')}
|
||||||
@ -53,21 +60,23 @@
|
|||||||
bind:value={config.backup.database.cronExpression}
|
bind:value={config.backup.database.cronExpression}
|
||||||
isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
|
isEdited={config.backup.database.cronExpression !== savedConfig.backup.database.cronExpression}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="desc">
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.cron_expression_description" let:message>
|
<FormatMessage key="admin.cron_expression_description">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
|
<a
|
||||||
class="underline"
|
href="https://crontab.guru/#{config.backup.database.cronExpression.replaceAll(' ', '_')}"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
<br />
|
{message}
|
||||||
</a>
|
<br />
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</SettingInputField>
|
</SettingInputField>
|
||||||
|
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
|
@ -15,44 +15,53 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
|
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<Icon path={mdiHelpCircleOutline} class="inline" size="15" />
|
<Icon path={mdiHelpCircleOutline} class="inline" size="15" />
|
||||||
<FormatMessage key="admin.transcoding_codecs_learn_more" let:tag let:message>
|
<FormatMessage key="admin.transcoding_codecs_learn_more">
|
||||||
{#if tag === 'h264-link'}
|
{#snippet children({ tag, message })}
|
||||||
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
|
{#if tag === 'h264-link'}
|
||||||
{message}
|
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
|
||||||
</a>
|
{message}
|
||||||
{:else if tag === 'hevc-link'}
|
</a>
|
||||||
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
|
{:else if tag === 'hevc-link'}
|
||||||
{message}
|
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
|
||||||
</a>
|
{message}
|
||||||
{:else if tag === 'vp9-link'}
|
</a>
|
||||||
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
|
{:else if tag === 'vp9-link'}
|
||||||
{message}
|
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
|
||||||
</a>
|
{message}
|
||||||
{/if}
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -60,7 +69,7 @@
|
|||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.transcoding_constant_rate_factor')}
|
label={$t('admin.transcoding_constant_rate_factor')}
|
||||||
desc={$t('admin.transcoding_constant_rate_factor_description')}
|
description={$t('admin.transcoding_constant_rate_factor_description')}
|
||||||
bind:value={config.ffmpeg.crf}
|
bind:value={config.ffmpeg.crf}
|
||||||
required={true}
|
required={true}
|
||||||
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
|
isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf}
|
||||||
@ -186,7 +195,7 @@
|
|||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.transcoding_max_bitrate')}
|
label={$t('admin.transcoding_max_bitrate')}
|
||||||
desc={$t('admin.transcoding_max_bitrate_description')}
|
description={$t('admin.transcoding_max_bitrate_description')}
|
||||||
bind:value={config.ffmpeg.maxBitrate}
|
bind:value={config.ffmpeg.maxBitrate}
|
||||||
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
|
isEdited={config.ffmpeg.maxBitrate !== savedConfig.ffmpeg.maxBitrate}
|
||||||
/>
|
/>
|
||||||
@ -195,7 +204,7 @@
|
|||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.transcoding_threads')}
|
label={$t('admin.transcoding_threads')}
|
||||||
desc={$t('admin.transcoding_threads_description')}
|
description={$t('admin.transcoding_threads_description')}
|
||||||
bind:value={config.ffmpeg.threads}
|
bind:value={config.ffmpeg.threads}
|
||||||
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
|
isEdited={config.ffmpeg.threads !== savedConfig.ffmpeg.threads}
|
||||||
/>
|
/>
|
||||||
@ -329,7 +338,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.transcoding_preferred_hardware_device')}
|
label={$t('admin.transcoding_preferred_hardware_device')}
|
||||||
desc={$t('admin.transcoding_preferred_hardware_device_description')}
|
description={$t('admin.transcoding_preferred_hardware_device_description')}
|
||||||
bind:value={config.ffmpeg.preferredHwDevice}
|
bind:value={config.ffmpeg.preferredHwDevice}
|
||||||
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
|
isEdited={config.ffmpeg.preferredHwDevice !== savedConfig.ffmpeg.preferredHwDevice}
|
||||||
{disabled}
|
{disabled}
|
||||||
@ -346,7 +355,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.transcoding_max_b_frames')}
|
label={$t('admin.transcoding_max_b_frames')}
|
||||||
desc={$t('admin.transcoding_max_b_frames_description')}
|
description={$t('admin.transcoding_max_b_frames_description')}
|
||||||
bind:value={config.ffmpeg.bframes}
|
bind:value={config.ffmpeg.bframes}
|
||||||
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
|
isEdited={config.ffmpeg.bframes !== savedConfig.ffmpeg.bframes}
|
||||||
{disabled}
|
{disabled}
|
||||||
@ -355,7 +364,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.transcoding_reference_frames')}
|
label={$t('admin.transcoding_reference_frames')}
|
||||||
desc={$t('admin.transcoding_reference_frames_description')}
|
description={$t('admin.transcoding_reference_frames_description')}
|
||||||
bind:value={config.ffmpeg.refs}
|
bind:value={config.ffmpeg.refs}
|
||||||
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
|
isEdited={config.ffmpeg.refs !== savedConfig.ffmpeg.refs}
|
||||||
{disabled}
|
{disabled}
|
||||||
@ -364,7 +373,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.transcoding_max_keyframe_interval')}
|
label={$t('admin.transcoding_max_keyframe_interval')}
|
||||||
desc={$t('admin.transcoding_max_keyframe_interval_description')}
|
description={$t('admin.transcoding_max_keyframe_interval_description')}
|
||||||
bind:value={config.ffmpeg.gopSize}
|
bind:value={config.ffmpeg.gopSize}
|
||||||
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
|
isEdited={config.ffmpeg.gopSize !== savedConfig.ffmpeg.gopSize}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
@ -7,24 +7,39 @@
|
|||||||
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
export let openByDefault = false;
|
onSave: SettingsSaveEvent;
|
||||||
|
openByDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
savedConfig,
|
||||||
|
defaultConfig,
|
||||||
|
config = $bindable(),
|
||||||
|
disabled = false,
|
||||||
|
onReset,
|
||||||
|
onSave,
|
||||||
|
openByDefault = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
key="thumbnail-settings"
|
key="thumbnail-settings"
|
||||||
@ -65,7 +80,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.image_quality')}
|
label={$t('admin.image_quality')}
|
||||||
desc={$t('admin.image_thumbnail_quality_description')}
|
description={$t('admin.image_thumbnail_quality_description')}
|
||||||
bind:value={config.image.thumbnail.quality}
|
bind:value={config.image.thumbnail.quality}
|
||||||
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
|
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
|
||||||
{disabled}
|
{disabled}
|
||||||
@ -110,7 +125,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.image_quality')}
|
label={$t('admin.image_quality')}
|
||||||
desc={$t('admin.image_preview_quality_description')}
|
description={$t('admin.image_preview_quality_description')}
|
||||||
bind:value={config.image.preview.quality}
|
bind:value={config.image.preview.quality}
|
||||||
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
|
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
@ -5,17 +5,20 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
const jobNames = [
|
const jobNames = [
|
||||||
JobName.ThumbnailGeneration,
|
JobName.ThumbnailGeneration,
|
||||||
@ -34,11 +37,15 @@
|
|||||||
function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
|
function isSystemConfigJobDto(jobName: any): jobName is keyof SystemConfigJobDto {
|
||||||
return jobName in config.job;
|
return jobName in config.job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
{#each jobNames as jobName}
|
{#each jobNames as jobName}
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
{#if isSystemConfigJobDto(jobName)}
|
{#if isSystemConfigJobDto(jobName)}
|
||||||
@ -46,7 +53,7 @@
|
|||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
||||||
desc=""
|
description=""
|
||||||
bind:value={config.job[jobName].concurrency}
|
bind:value={config.job[jobName].concurrency}
|
||||||
required={true}
|
required={true}
|
||||||
isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
|
isEdited={!(config.job[jobName].concurrency == savedConfig.job[jobName].concurrency)}
|
||||||
@ -55,7 +62,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
label={$t('admin.job_concurrency', { values: { job: $getJobName(jobName) } })}
|
||||||
desc=""
|
description=""
|
||||||
value="1"
|
value="1"
|
||||||
disabled={true}
|
disabled={true}
|
||||||
title={$t('admin.job_not_concurrency_safe')}
|
title={$t('admin.job_not_concurrency_safe')}
|
||||||
|
@ -4,34 +4,49 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
export let openByDefault = false;
|
onSave: SettingsSaveEvent;
|
||||||
|
openByDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
$: cronExpressionOptions = [
|
let {
|
||||||
|
savedConfig,
|
||||||
|
defaultConfig,
|
||||||
|
config = $bindable(),
|
||||||
|
disabled = false,
|
||||||
|
onReset,
|
||||||
|
onSave,
|
||||||
|
openByDefault = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let cronExpressionOptions = $derived([
|
||||||
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
{ text: $t('interval.night_at_midnight'), value: '0 0 * * *' },
|
||||||
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
|
{ text: $t('interval.night_at_twoam'), value: '0 2 * * *' },
|
||||||
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
{ text: $t('interval.day_at_onepm'), value: '0 13 * * *' },
|
||||||
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
{ text: $t('interval.hours', { values: { hours: 6 } }), value: '0 */6 * * *' },
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
key="library-watching"
|
key="library-watching"
|
||||||
@ -77,20 +92,22 @@
|
|||||||
bind:value={config.library.scan.cronExpression}
|
bind:value={config.library.scan.cronExpression}
|
||||||
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
|
isEdited={config.library.scan.cronExpression !== savedConfig.library.scan.cronExpression}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="desc">
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.cron_expression_description" let:message>
|
<FormatMessage key="admin.cron_expression_description">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
|
<a
|
||||||
class="underline"
|
href="https://crontab.guru/#{config.library.scan.cronExpression.replaceAll(' ', '_')}"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
</SettingInputField>
|
</SettingInputField>
|
||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
@ -8,17 +8,25 @@
|
|||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.logging_enable_description')}
|
title={$t('admin.logging_enable_description')}
|
||||||
|
@ -5,26 +5,33 @@
|
|||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
|
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.machine_learning_enabled')}
|
title={$t('admin.machine_learning_enabled')}
|
||||||
@ -38,7 +45,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('url')}
|
label={$t('url')}
|
||||||
desc={$t('admin.machine_learning_url_description')}
|
description={$t('admin.machine_learning_url_description')}
|
||||||
bind:value={config.machineLearning.url}
|
bind:value={config.machineLearning.url}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.machineLearning.enabled}
|
disabled={disabled || !config.machineLearning.enabled}
|
||||||
@ -69,11 +76,15 @@
|
|||||||
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
|
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.clip.enabled}
|
||||||
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
|
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
|
||||||
>
|
>
|
||||||
<p slot="desc" class="immich-form-label pb-2 text-sm">
|
{#snippet descriptionSnippet()}
|
||||||
<FormatMessage key="admin.machine_learning_clip_model_description" let:message>
|
<p class="immich-form-label pb-2 text-sm">
|
||||||
<a href="https://huggingface.co/immich-app"><u>{message}</u></a>
|
<FormatMessage key="admin.machine_learning_clip_model_description">
|
||||||
</FormatMessage>
|
{#snippet children({ message })}
|
||||||
</p>
|
<a href="https://huggingface.co/immich-app"><u>{message}</u></a>
|
||||||
|
{/snippet}
|
||||||
|
</FormatMessage>
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
</SettingInputField>
|
</SettingInputField>
|
||||||
</div>
|
</div>
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
@ -100,7 +111,7 @@
|
|||||||
step="0.0005"
|
step="0.0005"
|
||||||
min={0.001}
|
min={0.001}
|
||||||
max={0.1}
|
max={0.1}
|
||||||
desc={$t('admin.machine_learning_max_detection_distance_description')}
|
description={$t('admin.machine_learning_max_detection_distance_description')}
|
||||||
disabled={disabled || !$featureFlags.duplicateDetection}
|
disabled={disabled || !$featureFlags.duplicateDetection}
|
||||||
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
|
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
|
||||||
savedConfig.machineLearning.duplicateDetection.maxDistance}
|
savedConfig.machineLearning.duplicateDetection.maxDistance}
|
||||||
@ -142,7 +153,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.machine_learning_min_detection_score')}
|
label={$t('admin.machine_learning_min_detection_score')}
|
||||||
desc={$t('admin.machine_learning_min_detection_score_description')}
|
description={$t('admin.machine_learning_min_detection_score_description')}
|
||||||
bind:value={config.machineLearning.facialRecognition.minScore}
|
bind:value={config.machineLearning.facialRecognition.minScore}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min={0.1}
|
min={0.1}
|
||||||
@ -155,7 +166,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.machine_learning_max_recognition_distance')}
|
label={$t('admin.machine_learning_max_recognition_distance')}
|
||||||
desc={$t('admin.machine_learning_max_recognition_distance_description')}
|
description={$t('admin.machine_learning_max_recognition_distance_description')}
|
||||||
bind:value={config.machineLearning.facialRecognition.maxDistance}
|
bind:value={config.machineLearning.facialRecognition.maxDistance}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min={0.1}
|
min={0.1}
|
||||||
@ -168,7 +179,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.machine_learning_min_recognized_faces')}
|
label={$t('admin.machine_learning_min_recognized_faces')}
|
||||||
desc={$t('admin.machine_learning_min_recognized_faces_description')}
|
description={$t('admin.machine_learning_min_recognized_faces_description')}
|
||||||
bind:value={config.machineLearning.facialRecognition.minFaces}
|
bind:value={config.machineLearning.facialRecognition.minFaces}
|
||||||
step="1"
|
step="1"
|
||||||
min={1}
|
min={1}
|
||||||
|
@ -6,23 +6,30 @@
|
|||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.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 SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
|
<SettingAccordion key="map" title={$t('admin.map_settings')} subtitle={$t('admin.map_settings_description')}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
@ -38,7 +45,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.map_light_style')}
|
label={$t('admin.map_light_style')}
|
||||||
desc={$t('admin.map_style_description')}
|
description={$t('admin.map_style_description')}
|
||||||
bind:value={config.map.lightStyle}
|
bind:value={config.map.lightStyle}
|
||||||
disabled={disabled || !config.map.enabled}
|
disabled={disabled || !config.map.enabled}
|
||||||
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
|
isEdited={config.map.lightStyle !== savedConfig.map.lightStyle}
|
||||||
@ -46,7 +53,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.map_dark_style')}
|
label={$t('admin.map_dark_style')}
|
||||||
desc={$t('admin.map_style_description')}
|
description={$t('admin.map_style_description')}
|
||||||
bind:value={config.map.darkStyle}
|
bind:value={config.map.darkStyle}
|
||||||
disabled={disabled || !config.map.enabled}
|
disabled={disabled || !config.map.enabled}
|
||||||
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
|
isEdited={config.map.darkStyle !== savedConfig.map.darkStyle}
|
||||||
@ -55,20 +62,22 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
|
<SettingAccordion key="reverse-geocoding" title={$t('admin.map_reverse_geocoding_settings')}>
|
||||||
<svelte:fragment slot="subtitle">
|
{#snippet subtitleSnippet()}
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.map_manage_reverse_geocoding_settings" let:message>
|
<FormatMessage key="admin.map_manage_reverse_geocoding_settings">
|
||||||
<a
|
{#snippet children({ message })}
|
||||||
href="https://immich.app/docs/features/reverse-geocoding"
|
<a
|
||||||
class="underline"
|
href="https://immich.app/docs/features/reverse-geocoding"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</svelte:fragment>
|
{/snippet}
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.map_reverse_geocoding_enable_description')}
|
title={$t('admin.map_reverse_geocoding_enable_description')}
|
||||||
|
@ -7,17 +7,25 @@
|
|||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault class="mx-4 mt-4">
|
<form autocomplete="off" {onsubmit} class="mx-4 mt-4">
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.metadata_faces_import_setting')}
|
title={$t('admin.metadata_faces_import_setting')}
|
||||||
|
@ -7,17 +7,25 @@
|
|||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4">
|
<div class="ml-4 mt-4">
|
||||||
<SettingSwitch
|
<SettingSwitch
|
||||||
title={$t('admin.version_check_enabled_description')}
|
title={$t('admin.version_check_enabled_description')}
|
||||||
|
@ -3,9 +3,7 @@
|
|||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
@ -18,15 +16,20 @@
|
|||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
let isSending = false;
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
let isSending = $state(false);
|
||||||
|
|
||||||
const handleSendTestEmail = async () => {
|
const handleSendTestEmail = async () => {
|
||||||
if (isSending) {
|
if (isSending) {
|
||||||
@ -65,11 +68,15 @@
|
|||||||
isSending = false;
|
isSending = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault class="mt-4">
|
<form autocomplete="off" {onsubmit} class="mt-4">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
|
<SettingAccordion key="email" title={$t('email')} subtitle={$t('admin.notification_email_setting_description')}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
@ -85,7 +92,7 @@
|
|||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
required
|
required
|
||||||
label={$t('host')}
|
label={$t('host')}
|
||||||
desc={$t('admin.notification_email_host_description')}
|
description={$t('admin.notification_email_host_description')}
|
||||||
disabled={disabled || !config.notifications.smtp.enabled}
|
disabled={disabled || !config.notifications.smtp.enabled}
|
||||||
bind:value={config.notifications.smtp.transport.host}
|
bind:value={config.notifications.smtp.transport.host}
|
||||||
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
|
isEdited={config.notifications.smtp.transport.host !== savedConfig.notifications.smtp.transport.host}
|
||||||
@ -95,7 +102,7 @@
|
|||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
required
|
required
|
||||||
label={$t('port')}
|
label={$t('port')}
|
||||||
desc={$t('admin.notification_email_port_description')}
|
description={$t('admin.notification_email_port_description')}
|
||||||
disabled={disabled || !config.notifications.smtp.enabled}
|
disabled={disabled || !config.notifications.smtp.enabled}
|
||||||
bind:value={config.notifications.smtp.transport.port}
|
bind:value={config.notifications.smtp.transport.port}
|
||||||
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
|
isEdited={config.notifications.smtp.transport.port !== savedConfig.notifications.smtp.transport.port}
|
||||||
@ -104,7 +111,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('username')}
|
label={$t('username')}
|
||||||
desc={$t('admin.notification_email_username_description')}
|
description={$t('admin.notification_email_username_description')}
|
||||||
disabled={disabled || !config.notifications.smtp.enabled}
|
disabled={disabled || !config.notifications.smtp.enabled}
|
||||||
bind:value={config.notifications.smtp.transport.username}
|
bind:value={config.notifications.smtp.transport.username}
|
||||||
isEdited={config.notifications.smtp.transport.username !==
|
isEdited={config.notifications.smtp.transport.username !==
|
||||||
@ -114,7 +121,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.PASSWORD}
|
inputType={SettingInputFieldType.PASSWORD}
|
||||||
label={$t('password')}
|
label={$t('password')}
|
||||||
desc={$t('admin.notification_email_password_description')}
|
description={$t('admin.notification_email_password_description')}
|
||||||
disabled={disabled || !config.notifications.smtp.enabled}
|
disabled={disabled || !config.notifications.smtp.enabled}
|
||||||
bind:value={config.notifications.smtp.transport.password}
|
bind:value={config.notifications.smtp.transport.password}
|
||||||
isEdited={config.notifications.smtp.transport.password !==
|
isEdited={config.notifications.smtp.transport.password !==
|
||||||
@ -134,14 +141,14 @@
|
|||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
required
|
required
|
||||||
label={$t('admin.notification_email_from_address')}
|
label={$t('admin.notification_email_from_address')}
|
||||||
desc={$t('admin.notification_email_from_address_description')}
|
description={$t('admin.notification_email_from_address_description')}
|
||||||
disabled={disabled || !config.notifications.smtp.enabled}
|
disabled={disabled || !config.notifications.smtp.enabled}
|
||||||
bind:value={config.notifications.smtp.from}
|
bind:value={config.notifications.smtp.from}
|
||||||
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
|
isEdited={config.notifications.smtp.from !== savedConfig.notifications.smtp.from}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex gap-2 place-items-center">
|
<div class="flex gap-2 place-items-center">
|
||||||
<Button size="sm" disabled={!config.notifications.smtp.enabled} on:click={handleSendTestEmail}>
|
<Button size="sm" disabled={!config.notifications.smtp.enabled} onclick={handleSendTestEmail}>
|
||||||
{#if disabled}
|
{#if disabled}
|
||||||
{$t('admin.notification_email_test_email')}
|
{$t('admin.notification_email_test_email')}
|
||||||
{:else}
|
{:else}
|
||||||
|
@ -3,28 +3,35 @@
|
|||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="mt-4 ml-4">
|
<div class="mt-4 ml-4">
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.server_external_domain_settings')}
|
label={$t('admin.server_external_domain_settings')}
|
||||||
desc={$t('admin.server_external_domain_settings_description')}
|
description={$t('admin.server_external_domain_settings_description')}
|
||||||
bind:value={config.server.externalDomain}
|
bind:value={config.server.externalDomain}
|
||||||
isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
|
isEdited={config.server.externalDomain !== savedConfig.server.externalDomain}
|
||||||
/>
|
/>
|
||||||
@ -32,7 +39,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.TEXT}
|
inputType={SettingInputFieldType.TEXT}
|
||||||
label={$t('admin.server_welcome_message')}
|
label={$t('admin.server_welcome_message')}
|
||||||
desc={$t('admin.server_welcome_message_description')}
|
description={$t('admin.server_welcome_message_description')}
|
||||||
bind:value={config.server.loginPageMessage}
|
bind:value={config.server.loginPageMessage}
|
||||||
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
|
isEdited={config.server.loginPageMessage !== savedConfig.server.loginPageMessage}
|
||||||
/>
|
/>
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||||
|
|
||||||
|
const bubble = createBubbler();
|
||||||
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute, SettingInputFieldType } from '$lib/constants';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import {
|
import {
|
||||||
getStorageTemplateOptions,
|
getStorageTemplateOptions,
|
||||||
@ -15,24 +18,38 @@
|
|||||||
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
|
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
|
||||||
import SupportedVariablesPanel from './supported-variables-panel.svelte';
|
import SupportedVariablesPanel from './supported-variables-panel.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
import FormatMessage from '$lib/components/i18n/format-message.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let minified = false;
|
disabled?: boolean;
|
||||||
export let onReset: SettingsResetEvent;
|
minified?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
export let duration: number = 500;
|
onSave: SettingsSaveEvent;
|
||||||
|
duration?: number;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
let templateOptions: SystemConfigTemplateStorageOptionDto;
|
let {
|
||||||
let selectedPreset = '';
|
savedConfig,
|
||||||
|
defaultConfig,
|
||||||
|
config = $bindable(),
|
||||||
|
disabled = false,
|
||||||
|
minified = false,
|
||||||
|
onReset,
|
||||||
|
onSave,
|
||||||
|
duration = 500,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let templateOptions: SystemConfigTemplateStorageOptionDto | undefined = $state();
|
||||||
|
let selectedPreset = $state('');
|
||||||
|
|
||||||
const getTemplateOptions = async () => {
|
const getTemplateOptions = async () => {
|
||||||
templateOptions = await getStorageTemplateOptions();
|
templateOptions = await getStorageTemplateOptions();
|
||||||
@ -41,15 +58,11 @@
|
|||||||
|
|
||||||
const getSupportDateTimeFormat = () => getStorageTemplateOptions();
|
const getSupportDateTimeFormat = () => getStorageTemplateOptions();
|
||||||
|
|
||||||
$: parsedTemplate = () => {
|
|
||||||
try {
|
|
||||||
return renderTemplate(config.storageTemplate.template);
|
|
||||||
} catch {
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTemplate = (templateString: string) => {
|
const renderTemplate = (templateString: string) => {
|
||||||
|
if (!templateOptions) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
const template = handlebar.compile(templateString, {
|
const template = handlebar.compile(templateString, {
|
||||||
knownHelpers: undefined,
|
knownHelpers: undefined,
|
||||||
});
|
});
|
||||||
@ -85,31 +98,40 @@
|
|||||||
const handlePresetSelection = () => {
|
const handlePresetSelection = () => {
|
||||||
config.storageTemplate.template = selectedPreset;
|
config.storageTemplate.template = selectedPreset;
|
||||||
};
|
};
|
||||||
|
let parsedTemplate = $derived(() => {
|
||||||
|
try {
|
||||||
|
return renderTemplate(config.storageTemplate.template);
|
||||||
|
} catch {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="dark:text-immich-dark-fg mt-2">
|
<section class="dark:text-immich-dark-fg mt-2">
|
||||||
<div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
|
<div in:fade={{ duration }} class="mx-4 flex flex-col gap-4 py-4">
|
||||||
<p class="text-sm dark:text-immich-dark-fg">
|
<p class="text-sm dark:text-immich-dark-fg">
|
||||||
<FormatMessage key="admin.storage_template_more_details" let:tag let:message>
|
<FormatMessage key="admin.storage_template_more_details">
|
||||||
{#if tag === 'template-link'}
|
{#snippet children({ tag, message })}
|
||||||
<a
|
{#if tag === 'template-link'}
|
||||||
href="https://immich.app/docs/administration/storage-template"
|
<a
|
||||||
class="underline"
|
href="https://immich.app/docs/administration/storage-template"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
{:else if tag === 'implications-link'}
|
</a>
|
||||||
<a
|
{:else if tag === 'implications-link'}
|
||||||
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
|
<a
|
||||||
class="underline"
|
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
|
||||||
target="_blank"
|
class="underline"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
{message}
|
>
|
||||||
</a>
|
{message}
|
||||||
{/if}
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -164,19 +186,18 @@
|
|||||||
<FormatMessage
|
<FormatMessage
|
||||||
key="admin.storage_template_path_length"
|
key="admin.storage_template_path_length"
|
||||||
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
|
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
|
||||||
let:message
|
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
|
{#snippet children({ message })}
|
||||||
|
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
<FormatMessage
|
<FormatMessage key="admin.storage_template_user_label" values={{ label: $user.storageLabel || $user.id }}>
|
||||||
key="admin.storage_template_user_label"
|
{#snippet children({ message })}
|
||||||
values={{ label: $user.storageLabel || $user.id }}
|
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
|
||||||
let:message
|
{/snippet}
|
||||||
>
|
|
||||||
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
|
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -186,24 +207,30 @@
|
|||||||
>/{parsedTemplate()}.jpg
|
>/{parsedTemplate()}.jpg
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
|
<form autocomplete="off" class="flex flex-col" onsubmit={preventDefault(bubble('submit'))}>
|
||||||
<div class="flex flex-col my-2">
|
<div class="flex flex-col my-2">
|
||||||
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="preset-select">
|
{#if templateOptions}
|
||||||
{$t('preset')}
|
<label
|
||||||
</label>
|
class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm"
|
||||||
<select
|
for="preset-select"
|
||||||
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
|
>
|
||||||
disabled={disabled || !config.storageTemplate.enabled}
|
{$t('preset')}
|
||||||
name="presets"
|
</label>
|
||||||
id="preset-select"
|
<select
|
||||||
bind:value={selectedPreset}
|
class="immich-form-input p-2 mt-2 text-sm rounded-lg bg-slate-200 hover:cursor-pointer dark:bg-gray-600"
|
||||||
on:change={handlePresetSelection}
|
disabled={disabled || !config.storageTemplate.enabled}
|
||||||
>
|
name="presets"
|
||||||
{#each templateOptions.presetOptions as preset}
|
id="preset-select"
|
||||||
<option value={preset}>{renderTemplate(preset)}</option>
|
bind:value={selectedPreset}
|
||||||
{/each}
|
onchange={handlePresetSelection}
|
||||||
</select>
|
>
|
||||||
|
{#each templateOptions.presetOptions as preset}
|
||||||
|
<option value={preset}>{renderTemplate(preset)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 align-bottom">
|
<div class="flex gap-2 align-bottom">
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
label={$t('template')}
|
label={$t('template')}
|
||||||
@ -232,11 +259,12 @@
|
|||||||
<FormatMessage
|
<FormatMessage
|
||||||
key="admin.storage_template_migration_info"
|
key="admin.storage_template_migration_info"
|
||||||
values={{ job: $t('admin.storage_template_migration_job') }}
|
values={{ job: $t('admin.storage_template_migration_job') }}
|
||||||
let:message
|
|
||||||
>
|
>
|
||||||
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
|
{#snippet children({ message })}
|
||||||
{message}
|
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
|
||||||
</a>
|
{message}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
</FormatMessage>
|
</FormatMessage>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@ -247,7 +275,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if minified}
|
{#if minified}
|
||||||
<slot />
|
{@render children?.()}
|
||||||
{:else}
|
{:else}
|
||||||
<SettingButtonsRow
|
<SettingButtonsRow
|
||||||
onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })}
|
onReset={(options) => onReset({ ...options, configKeys: ['storageTemplate'] })}
|
||||||
|
@ -4,7 +4,11 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let options: SystemConfigTemplateStorageOptionDto;
|
interface Props {
|
||||||
|
options: SystemConfigTemplateStorageOptionDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { options }: Props = $props();
|
||||||
|
|
||||||
const getLuxonExample = (format: string) => {
|
const getLuxonExample = (format: string) => {
|
||||||
return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
|
return DateTime.fromISO('2022-09-04T20:03:05.250Z', { locale: $locale }).toFormat(format);
|
||||||
|
@ -7,22 +7,30 @@
|
|||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingTextarea
|
<SettingTextarea
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.theme_custom_css_settings')}
|
label={$t('admin.theme_custom_css_settings')}
|
||||||
desc={$t('admin.theme_custom_css_settings_description')}
|
description={$t('admin.theme_custom_css_settings_description')}
|
||||||
bind:value={config.theme.customCss}
|
bind:value={config.theme.customCss}
|
||||||
required={true}
|
required={true}
|
||||||
isEdited={config.theme.customCss !== savedConfig.theme.customCss}
|
isEdited={config.theme.customCss !== savedConfig.theme.customCss}
|
||||||
|
@ -4,23 +4,30 @@
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
|
|
||||||
|
const onsubmit = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" {onsubmit}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} />
|
<SettingSwitch title={$t('admin.trash_enabled_description')} {disabled} bind:checked={config.trash.enabled} />
|
||||||
|
|
||||||
@ -29,7 +36,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.trash_number_of_days')}
|
label={$t('admin.trash_number_of_days')}
|
||||||
desc={$t('admin.trash_number_of_days_description')}
|
description={$t('admin.trash_number_of_days_description')}
|
||||||
bind:value={config.trash.days}
|
bind:value={config.trash.days}
|
||||||
required={true}
|
required={true}
|
||||||
disabled={disabled || !config.trash.enabled}
|
disabled={disabled || !config.trash.enabled}
|
||||||
|
@ -5,28 +5,31 @@
|
|||||||
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
|
||||||
|
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
import SettingInputField, {
|
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
SettingInputFieldType,
|
|
||||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
||||||
export let savedConfig: SystemConfigDto;
|
interface Props {
|
||||||
export let defaultConfig: SystemConfigDto;
|
savedConfig: SystemConfigDto;
|
||||||
export let config: SystemConfigDto; // this is the config that is being edited
|
defaultConfig: SystemConfigDto;
|
||||||
export let disabled = false;
|
config: SystemConfigDto;
|
||||||
export let onReset: SettingsResetEvent;
|
disabled?: boolean;
|
||||||
export let onSave: SettingsSaveEvent;
|
onReset: SettingsResetEvent;
|
||||||
|
onSave: SettingsSaveEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { savedConfig, defaultConfig, config = $bindable(), disabled = false, onReset, onSave }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div in:fade={{ duration: 500 }}>
|
<div in:fade={{ duration: 500 }}>
|
||||||
<form autocomplete="off" on:submit|preventDefault>
|
<form autocomplete="off" onsubmit={(e) => e.preventDefault()}>
|
||||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
min={1}
|
min={1}
|
||||||
label={$t('admin.user_delete_delay_settings')}
|
label={$t('admin.user_delete_delay_settings')}
|
||||||
desc={$t('admin.user_delete_delay_settings_description')}
|
description={$t('admin.user_delete_delay_settings_description')}
|
||||||
bind:value={config.user.deleteDelay}
|
bind:value={config.user.deleteDelay}
|
||||||
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
|
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
|
||||||
/>
|
/>
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { albumFactory } from '@test-data/factories/album-factory';
|
import { albumFactory } from '@test-data/factories/album-factory';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
|
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { init, register, waitLocale } from 'svelte-i18n';
|
import { init, register, waitLocale } from 'svelte-i18n';
|
||||||
import AlbumCard from '../album-card.svelte';
|
import AlbumCard from '../album-card.svelte';
|
||||||
|
|
||||||
const onShowContextMenu = vi.fn();
|
const onShowContextMenu = vi.fn();
|
||||||
|
|
||||||
describe('AlbumCard component', () => {
|
describe('AlbumCard component', () => {
|
||||||
let sut: RenderResult<AlbumCard>;
|
let sut: RenderResult<typeof AlbumCard>;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await init({ fallbackLocale: 'en-US' });
|
await init({ fallbackLocale: 'en-US' });
|
||||||
@ -110,13 +111,9 @@ describe('AlbumCard component', () => {
|
|||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await fireEvent(
|
const user = userEvent.setup();
|
||||||
contextMenuButton,
|
await user.click(contextMenuButton);
|
||||||
new MouseEvent('click', {
|
|
||||||
clientX: 123,
|
|
||||||
clientY: 456,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(onShowContextMenu).toHaveBeenCalledTimes(1);
|
expect(onShowContextMenu).toHaveBeenCalledTimes(1);
|
||||||
expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 }));
|
expect(onShowContextMenu).toHaveBeenCalledWith(expect.objectContaining({ x: 123, y: 456 }));
|
||||||
});
|
});
|
||||||
|
@ -11,28 +11,43 @@
|
|||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let albums: AlbumResponseDto[];
|
interface Props {
|
||||||
export let group: AlbumGroup | undefined = undefined;
|
albums: AlbumResponseDto[];
|
||||||
export let showOwner = false;
|
group?: AlbumGroup | undefined;
|
||||||
export let showDateRange = false;
|
showOwner?: boolean;
|
||||||
export let showItemCount = false;
|
showDateRange?: boolean;
|
||||||
export let onShowContextMenu: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined =
|
showItemCount?: boolean;
|
||||||
undefined;
|
onShowContextMenu?: ((position: ContextMenuPosition, album: AlbumResponseDto) => unknown) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
$: isCollapsed = !!group && isAlbumGroupCollapsed($albumViewSettings, group.id);
|
let {
|
||||||
|
albums,
|
||||||
|
group = undefined,
|
||||||
|
showOwner = false,
|
||||||
|
showDateRange = false,
|
||||||
|
showItemCount = false,
|
||||||
|
onShowContextMenu = undefined,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isCollapsed = $derived(!!group && isAlbumGroupCollapsed($albumViewSettings, group.id));
|
||||||
|
|
||||||
const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => {
|
const showContextMenu = (position: ContextMenuPosition, album: AlbumResponseDto) => {
|
||||||
onShowContextMenu?.(position, album);
|
onShowContextMenu?.(position, album);
|
||||||
};
|
};
|
||||||
|
|
||||||
$: iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90';
|
let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90');
|
||||||
|
|
||||||
|
const oncontextmenu = (event: MouseEvent, album: AlbumResponseDto) => {
|
||||||
|
event.preventDefault();
|
||||||
|
showContextMenu({ x: event.x, y: event.y }, album);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if group}
|
{#if group}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => toggleAlbumGroupCollapsing(group.id)}
|
onclick={() => toggleAlbumGroupCollapsing(group.id)}
|
||||||
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
|
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
|
||||||
aria-expanded={!isCollapsed}
|
aria-expanded={!isCollapsed}
|
||||||
>
|
>
|
||||||
@ -56,7 +71,7 @@
|
|||||||
data-sveltekit-preload-data="hover"
|
data-sveltekit-preload-data="hover"
|
||||||
href="{AppRoute.ALBUMS}/{album.id}"
|
href="{AppRoute.ALBUMS}/{album.id}"
|
||||||
animate:flip={{ duration: 400 }}
|
animate:flip={{ duration: 400 }}
|
||||||
on:contextmenu|preventDefault={(e) => showContextMenu({ x: e.x, y: e.y }, album)}
|
oncontextmenu={(event) => oncontextmenu(event, album)}
|
||||||
>
|
>
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
{album}
|
{album}
|
||||||
|
@ -8,12 +8,23 @@
|
|||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
export let album: AlbumResponseDto;
|
interface Props {
|
||||||
export let showOwner = false;
|
album: AlbumResponseDto;
|
||||||
export let showDateRange = false;
|
showOwner?: boolean;
|
||||||
export let showItemCount = false;
|
showDateRange?: boolean;
|
||||||
export let preload = false;
|
showItemCount?: boolean;
|
||||||
export let onShowContextMenu: ((position: ContextMenuPosition) => unknown) | undefined = undefined;
|
preload?: boolean;
|
||||||
|
onShowContextMenu?: ((position: ContextMenuPosition) => unknown) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
album,
|
||||||
|
showOwner = false,
|
||||||
|
showDateRange = false,
|
||||||
|
showItemCount = false,
|
||||||
|
preload = false,
|
||||||
|
onShowContextMenu = undefined,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const showAlbumContextMenu = (e: MouseEvent) => {
|
const showAlbumContextMenu = (e: MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -39,7 +50,7 @@
|
|||||||
size="20"
|
size="20"
|
||||||
padding="2"
|
padding="2"
|
||||||
class="icon-white-drop-shadow"
|
class="icon-white-drop-shadow"
|
||||||
on:click={showAlbumContextMenu}
|
onclick={showAlbumContextMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|